From f1202d0dd883d7f7ee34814f86c4ea20bb1ced15 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 21 Jul 2026 20:15:09 +0800 Subject: [PATCH 01/90] ci: enforce bounded build lanes --- ...kage-invariant-runtime-contracts.i18n.yaml | 4 +- ...-19-package-invariant-runtime-contracts.md | 2 +- ...-package-invariant-runtime-contracts.zh.md | 2 +- .../2026-07-06-parallel-github-ci-gates.md | 39 ++-- .../2026-07-06-parallel-pre-push-gates.md | 8 +- .github/workflows/ci.yml | 192 +++++++++++++++--- .../skill-local/tests/skill-local.spec.ts | 3 + scripts/coverage-shards.spec.ts | 43 ++++ scripts/coverage-shards.ts | 68 +++++++ scripts/publint-all.spec.ts | 61 ++++++ scripts/publint-all.ts | 160 ++++++++++----- scripts/run-gates.ts | 61 ++++-- scripts/static-shards.spec.ts | 16 ++ scripts/static-shards.ts | 77 +++++++ scripts/verify-built-package-invariants.mjs | 136 +++++++------ .../verify-built-package-invariants.spec.ts | 88 ++++++++ 16 files changed, 769 insertions(+), 191 deletions(-) create mode 100644 scripts/coverage-shards.spec.ts create mode 100644 scripts/coverage-shards.ts create mode 100644 scripts/publint-all.spec.ts create mode 100644 scripts/static-shards.spec.ts create mode 100644 scripts/static-shards.ts create mode 100644 scripts/verify-built-package-invariants.spec.ts diff --git a/.agents/notes/implemented/architecture/2026-07-19-package-invariant-runtime-contracts.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-19-package-invariant-runtime-contracts.i18n.yaml index 60f33a0e1e..0379a79e52 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-package-invariant-runtime-contracts.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-19-package-invariant-runtime-contracts.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 -2026-07-19-package-invariant-runtime-contracts.md: 7d1fb1ad5a2e7563bdddffde1f49368b9f0c13f7 -2026-07-19-package-invariant-runtime-contracts.zh.md: 669eb02221aea4b0654497bb81327d725648dabe +2026-07-19-package-invariant-runtime-contracts.md: 40d152b2320ac65f9ea7d8732b1a667236d2780a +2026-07-19-package-invariant-runtime-contracts.zh.md: bd2f440d5dce15b352e7bcea0d1243400d290f11 diff --git a/.agents/notes/implemented/architecture/2026-07-19-package-invariant-runtime-contracts.md b/.agents/notes/implemented/architecture/2026-07-19-package-invariant-runtime-contracts.md index 7d1fb1ad5a..40d152b232 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-package-invariant-runtime-contracts.md +++ b/.agents/notes/implemented/architecture/2026-07-19-package-invariant-runtime-contracts.md @@ -59,7 +59,7 @@ Session-backed companions validate existing durable events when they load, using `verify-package-invariants` discovers every workspace package and enforces companion source, exact-name registration, named-only Loader shape, `./invariant` exports, publication files, dependencies, TypeScript references, and bundle entries. Its AST rule rejects generated markers, default exports, and unexplained empty installers. A non-empty installer must accept and use the failure reporter, and registration must pass that checked local `install` function. The gate deliberately does not infer semantic quality from method names or helper calls. -Vitest mounts `InvariantService` with `{ enabled: true }` for every package test topology and loads the owning companion. The invariant subpath path mapping resolves source companions instead of stale built output. Focused suites cover every executable companion's valid and invalid observations, and the exhaustive topology runs every source companion through the real Loader namespace normalization. An artifact gate stages each package's exact `npm pack` file inventory, imports its compiled `./invariant` self-reference under plain Node, and repeats that Loader-shape check, so an unpublished shared runtime chunk fails before release. Tests that synthesize event streams must produce a valid surrounding lifecycle unless the test is intentionally asserting a violation. +Vitest mounts `InvariantService` with `{ enabled: true }` for every package test topology and loads the owning companion. The invariant subpath path mapping resolves source companions instead of stale built output. Focused suites cover every executable companion's valid and invalid observations, and the exhaustive topology runs every source companion through the real Loader namespace normalization. After the structural gate validates each publication map, an artifact gate stages its manifest-declared `lib/` files, imports the compiled `./invariant` self-reference under plain Node, and repeats that Loader-shape check, so a companion that imports an undeclared runtime chunk fails before release. Tests that synthesize event streams must produce a valid surrounding lifecycle unless the test is intentionally asserting a violation. ## Alternatives considered diff --git a/.agents/notes/implemented/architecture/2026-07-19-package-invariant-runtime-contracts.zh.md b/.agents/notes/implemented/architecture/2026-07-19-package-invariant-runtime-contracts.zh.md index 669eb02221..bd2f440d5d 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-package-invariant-runtime-contracts.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-19-package-invariant-runtime-contracts.zh.md @@ -59,7 +59,7 @@ Status: implemented `verify-package-invariants` 发现每个 workspace 包,并强制 companion 源文件、完整名称注册、仅含具名 export 的 Loader 形状、`./invariant` export、发布文件、依赖、TypeScript reference 和 bundle entry 完整。其 AST 规则拒绝生成标记、默认导出和没有解释的空安装器。非空安装器必须接收并使用失败报告器,注册时还必须传入该经检查的本地 `install` 函数。门禁不会通过方法名或 helper 调用推断语义质量。 -Vitest 为每个包测试拓扑使用 `{ enabled: true }` 挂载 `InvariantService`,并加载所有者 companion。不变量 subpath 的 path mapping 会解析源 companion,而不是陈旧的构建输出。聚焦 suite 覆盖每个可执行 companion 的有效和无效观测;穷举拓扑通过真实 Loader 命名空间归一化运行每个源 companion。产物门禁会按每个包的精确 `npm pack` 文件清单暂存文件,在 plain Node 下导入该包已编译的 `./invariant` 自引用,并重复执行该 Loader 形状检查;这样,未发布的共享运行时分片会在正式发布前导致门禁失败。合成事件流的测试必须构造有效的外围生命周期,除非测试本身就是在断言违规。 +Vitest 为每个包测试拓扑使用 `{ enabled: true }` 挂载 `InvariantService`,并加载所有者 companion。不变量 subpath 的 path mapping 会解析源 companion,而不是陈旧的构建输出。聚焦 suite 覆盖每个可执行 companion 的有效和无效观测;穷举拓扑通过真实 Loader 命名空间归一化运行每个源 companion。结构门禁验证每个包的发布映射后,产物门禁会暂存其 manifest(元数据清单)声明的 `lib/` 文件,在 plain Node 下导入已编译的 `./invariant` 自引用,并重复执行该 Loader 形状检查;这样,若 companion 导入未声明的运行时分片,门禁就会在发布前失败。合成事件流的测试必须构造有效的外围生命周期,除非测试本身就是在断言违规。 ## 考虑过的替代方案 diff --git a/.agents/notes/implemented/process/2026-07-06-parallel-github-ci-gates.md b/.agents/notes/implemented/process/2026-07-06-parallel-github-ci-gates.md index 65ad437ca9..0058a30660 100644 --- a/.agents/notes/implemented/process/2026-07-06-parallel-github-ci-gates.md +++ b/.agents/notes/implemented/process/2026-07-06-parallel-github-ci-gates.md @@ -4,36 +4,43 @@ Status: implemented ## Problem -The keyless GitHub CI gates are mostly orthogonal: typecheck, lint, documentation freshness, coverage, snapshot replay, build, package-publication hygiene, demo smoke, and built-bin smoke fail for different reasons and do not need each other's runtime state. Running them as one ordered command chain makes the workflow wall clock equal the sum of those gates, while splitting every leaf gate into its own GitHub job repeats checkout, Node setup, pnpm restore, and install work until orchestration overhead becomes the bottleneck. +The keyless GitHub CI gates are mostly orthogonal: typecheck, lint, documentation freshness, coverage, snapshot replay, build, package-publication hygiene, demo smoke, and built-bin smoke fail for different reasons and do not need each other's runtime state. Running them as one ordered command chain makes the workflow wall clock equal the sum of those gates, while splitting every short leaf into its own GitHub job repeats checkout, Node setup, pnpm restore, and install work until orchestration overhead becomes the bottleneck. -The hard part is the artifact boundary. `publint`, `verify-node-next-types`, and built-bin smoke tests need the built `lib/` outputs, while most gates only need source and dependencies. A blind fan-out either races those artifact consumers before `pnpm run build` has emitted declarations and bundles, or repeats the build in every artifact-dependent job. +The original broad-lane split stopped meeting that balance as the workspace grew. On the merge of PR #404, Linux static, coverage, snapshot, and artifact jobs took 148, 195, 94, and 230 seconds; Windows static and artifacts took 251 and 482 seconds. Package-manager packing once per package dominated both artifact validators, coverage needlessly rebuilt output before a source-only suite, and CPU-heavy gates contended inside the static and coverage lanes. + +The artifact boundary remains load-bearing. `publint`, `verify-node-next-types`, compiled invariant loading, and built-bin smoke tests need emitted `lib/` output. Sharding cannot race those consumers ahead of build or replace their published-artifact signal with source execution. ## Decision -[CI](../../../../.github/workflows/ci.yml) groups keyless checks into broad primary-runtime lanes plus a compatibility matrix. The workflow file owns the current lane and runtime inventory. +[CI](../../../../.github/workflows/ci.yml) bounds every non-Windows job to one minute and every Windows job to three minutes. The timeout is an executable regression ceiling; the lane design leaves headroom below it rather than treating a timeout as normal control flow. -Each lane delegates to [scripts/run-gates.ts](../../../../scripts/run-gates.ts), which schedules independent gates with bounded concurrency and prints an attributable result block for each one. Artifact consumers depend on one build within their lane, while compatibility jobs combine typechecking with a real unbuilt worker launch to cover runtime-specific loader behavior. +[scripts/run-gates.ts](../../../../scripts/run-gates.ts) remains the common bounded scheduler, but GitHub supplies explicit shard names for the expensive gate families. [scripts/static-shards.ts](../../../../scripts/static-shards.ts) partitions static gates into foundation, API-contract, catalog, prose, and documentation-site lanes and rejects a missing or duplicate gate assignment. [scripts/coverage-shards.ts](../../../../scripts/coverage-shards.ts) assigns every workspace package to exactly one source-coverage lane; its test expands the live package tree, so a new package makes CI red until it has an owner. Each coverage lane includes only its owned source files, repeats the exhaustive companion topology test, and runs without a preceding build because the complete coverage suite passes from a tree with every generated `lib/` removed. -Generated `.sessions/` logs and `.doc-typecheck-*` temp directories are ignored by lint. The aggregate local CI mode still runs demo smoke after lint, while the split GitHub static lane can run demo smoke directly because lint is isolated in its own lane. +Snapshot replay is four Vitest file shards. Each snapshot job builds the shipped runtime while its Linux runner installs bubblewrap, then runs only its assigned replay files. Static, coverage, and snapshot sharding changes only GitHub scheduling: the ordinary local package scripts still run their complete suites. -Build output is produced once inside the Node 24 artifact lane. The artifact consumers (`publint`, `verify-node-next-types`, and built-bin smoke) declare a dependency on `build`, so there is no upload/download handoff and no consumer can race ahead of declarations or bundles. The CI coverage reporter is text-only while local coverage keeps the HTML report. +Artifacts use three lanes: one metadata lane for `publint`, NodeNext declarations, and compiled invariant loading, plus two Vitest shards for built-bin smoke. Each lane produces its own build before its consumers. Repeating the short build costs runner minutes but avoids an upload/download dependency and keeps each job's critical path bounded. -Both workflows cache the pnpm store. The real-API workflow uses the shared bounded Vitest file pool rather than a separate job per test group. +[scripts/publint-all.ts](../../../../scripts/publint-all.ts) calls publint's supported API in-process against an in-memory publication view made from each manifest's declared files and npm's mandatory metadata files. This preserves the distinction between workspace files and published files without spawning a package-manager pack command 103 times. [scripts/verify-built-package-invariants.mjs](../../../../scripts/verify-built-package-invariants.mjs) stages those structurally validated manifest-declared `lib/` files below the real package, then imports the compiled self-reference through plain Node and Cordis Loader normalization. A companion that reaches an undeclared runtime chunk still fails. + +Compatibility lanes run the source worker and Zstandard runtime smokes on every advertised Node line. TypeScript checks the source graph once on the primary Node 24 lane; repeating the same compiler analysis on Node 22 and 26 added time without runtime-specific signal. + +The workflow caches the pnpm store, preserves native PowerShell for Windows measurements, and retains one aggregate `all checks passed` status for branch protection. Windows build remains blocking; the wider Windows static, lint, and artifact matrix remains observational while carrying the same three-minute ceiling. ## Alternatives considered -- **Keep the full serial chain in a Node matrix** - simplest to reason about, but it duplicates repo-wide gates that do not produce Node-version-specific signal and leaves every PR waiting for the sum of all gates. -- **Run every gate as a separate GitHub job** - maximizes GitHub-visible fan-out, but it creates too many checks and pays repeated setup/install overhead for gates whose runtime is shorter than the runner preparation. -- **Upload build artifacts to artifact-dependent jobs** - preserves correctness across many jobs, but it adds artifact upload/download time and keeps the workflow wide when the artifact consumers can run behind a local dependency in the primary job. -- **Run `typecheck` and `build` concurrently** - exposes more work to the scheduler, but both commands invoke `tsc -b`; sharing incremental build state between them is a needless race for a small wall-clock gain. -- **Use unbounded real-API e2e parallelism** - rejected because the suite includes many live model/tool scenarios; the worker pool needs an explicit `DSH_E2E_MAX_WORKERS` cap so CI and local runs can fan out without hiding quota or resource problems behind flaky rate-limit failures. +- **Keep the broad lanes and raise timeouts** - minimizes workflow YAML, but it preserves the measured multi-minute feedback loop and offers no regression budget. +- **Run every leaf gate as a separate GitHub job** - maximizes fan-out, but short generators and prose checks would spend more time preparing a runner than checking the repository. +- **Upload one build to artifact consumers** - avoids repeated compilation, but upload/download and dependency scheduling lengthen wall time; the clean build is short enough to repeat inside bounded lanes. +- **Keep package-manager packing in both publication gates** - delegates inventory selection to pnpm, but repeats more than 200 package-manager processes. The manifest structural gate plus publication-view fixtures make the optimized inventory contract explicit and fail on an on-disk but unpublished dependency. +- **Keep build before coverage** - provides emitted output the source suite no longer consumes; a clean-tree coverage proof showed it was pure latency. +- **Typecheck on every Node version** - repeats compiler work while the compatibility smokes already exercise actual Node-specific loading and compression behavior. ## Consequences -PR feedback arrives as a few GitHub checks with structured per-gate log blocks inside each broad job. That keeps runner setup overhead bounded and the Actions UI compact, at the cost of losing one status check per leaf gate. +The Actions UI contains more matrix checks and total runner time can exceed a serial workflow, but PR wall time is the slowest bounded lane instead of the sum of unrelated work. Repeated setup and builds are the deliberate price of sub-minute non-Windows feedback and sub-three-minute Windows feedback. -The broad-lane split repeats checkout, setup, and install more often than a single primary job. That setup cost is intentional: on GitHub's hosted runner, running lint, coverage, and snapshot replay in one process pool oversubscribes CPU badly enough that the single-job critical path is longer than the repeated setup. +Shard inventories are repository contracts. Static selection validates the complete live gate list at runtime, coverage tests validate exhaustive package ownership, and Vitest owns deterministic file sharding for snapshots and built-bin smokes. Adding a gate or package therefore requires an explicit scheduling decision instead of silently lengthening an existing lane. -The split introduces a maintenance obligation: when `package.json` adds or removes a gate that belongs in CI, [scripts/run-gates.ts](../../../../scripts/run-gates.ts) needs the matching leaf. That obligation is intentional because the runner is the parallel execution plan for the same gate vocabulary, not a separate quality policy. +The optimized publication validators rely on the manifest `files` contract enforced by `verify-package-invariants`. If publication rules grow beyond that contract, the structural gate and both staged views must change together. -The compatibility signal is narrower than the primary Node 24 signal. It proves that the source graph typechecks and that the real unbuilt workflow-worker launch path executes on every advertised runtime line without doubling documentation, coverage, publication, snapshot replay, and unrelated smoke checks whose failures are not expected to vary by Node version. +Compatibility jobs no longer claim that TypeScript itself was exercised under every Node runtime. They prove runtime-sensitive source loading on Node 22, 24, and 26, while the primary runtime owns the single source-graph typecheck. diff --git a/.agents/notes/implemented/process/2026-07-06-parallel-pre-push-gates.md b/.agents/notes/implemented/process/2026-07-06-parallel-pre-push-gates.md index 60472e51ec..1e7639ad1f 100644 --- a/.agents/notes/implemented/process/2026-07-06-parallel-pre-push-gates.md +++ b/.agents/notes/implemented/process/2026-07-06-parallel-pre-push-gates.md @@ -8,7 +8,7 @@ The pre-push hook is the last local checkpoint before a branch leaves the machin Flattening those members directly into `lefthook.yml` solves the local hook only. CI has the same scheduling problem, and duplicating a long leaf list in YAML gives future script changes two places to drift. -`publint` has the same shape one level lower. Each package is linted independently against its own manifest and built output, but the runner loops through every package in order. On this repo that makes one package-publication gate consume time proportional to the number of packages even though the checks do not share mutable state. +`publint` has the same shape one level lower. Each package is linted independently against its own manifest and built output, but invoking the CLI separately also asks the package manager to compute the same manifest-bounded publication view once per package. On this repo process and packing overhead dominate the publication checks. ## Decision @@ -18,7 +18,7 @@ The `pre-push` mode expands into leaf gates for the unit suite, snapshot suite, The build gate makes the hook self-contained from a clean worktree. `publint`, `verify-node-next-types`, and the pre-push form of `doc-typecheck` wait for that build output, while source-only gates continue in parallel. -[scripts/publint-all.ts](../../../../scripts/publint-all.ts) discovers the package list from `packages//` and runs `publint` with a worker pool sized from `availableParallelism()`. `DSH_PUBLINT_CONCURRENCY` can cap or raise the worker count for local machines and CI runners with different resource profiles. Results are buffered per package and printed in deterministic package order, so parallel execution does not scramble each package's log block. +[scripts/publint-all.ts](../../../../scripts/publint-all.ts) discovers the package list from `packages//` and calls publint's supported API against an in-memory view of each manifest's declared publication files plus npm's mandatory metadata files. That keeps unpublished workspace files invisible to publint without a package-manager subprocess per package. A worker pool sized from `availableParallelism()` bounds parallel file loading and linting; `DSH_PUBLINT_CONCURRENCY` can cap or raise it, and results print in deterministic package order. The aggregate package scripts remain the source of truth for ad hoc local runs. The scheduler is a parallel execution plan over their member gates, not a replacement vocabulary. @@ -29,7 +29,7 @@ The aggregate package scripts remain the source of truth for ad hoc local runs. - **Require developers to build before pushing** - avoids one hook gate, but it makes `publint` fail in a clean worktree and turns the final local checkpoint into a convention instead of a runnable check. - **Background subcommands inside shell scripts** - can parallelize work, but it loses lefthook's job names, per-job timing, and failure grouping, and makes signal handling harder to reason about. - **Declare one publint lefthook job per package** - exposes maximum parallelism, but it turns the hook into a hand-maintained package inventory that drifts exactly when new packages are added. -- **Run publint with unbounded concurrency** - minimizes elapsed time on small machines only by gambling with process count, memory pressure, package tarball creation, and readable logs. +- **Run publint with unbounded concurrency** - minimizes elapsed time on small machines only by gambling with file descriptors, memory pressure, and readable logs. ## Consequences @@ -37,4 +37,4 @@ The hook's critical path becomes the slowest real gate instead of the sum of hid The hook file stays short, and the duplicated member list lives in [scripts/run-gates.ts](../../../../scripts/run-gates.ts), where CI and pre-push can share it. The cost is a custom scheduler script instead of pure lefthook configuration, plus a build in the local pre-push path. -`publint-all.ts` becomes asynchronous code and buffers command output instead of inheriting stdio live. The payoff is package-level parallelism with stable output order and one environment variable for resource tuning. +`publint-all.ts` becomes asynchronous code and formats API results after each package completes. The payoff is package-level parallelism with stable output order, one environment variable for resource tuning, and no repeated package-manager packing. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bac25b07cb..9be2932c11 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,45 +19,128 @@ jobs: node-24: runs-on: ubuntu-latest name: node 24 / ${{ matrix.lane }} + timeout-minutes: 1 env: DSH_GATE_CONCURRENCY: ${{ matrix.gate_concurrency }} DSH_PUBLINT_CONCURRENCY: ${{ matrix.publint_concurrency }} + DSH_ARTIFACT_SHARD: ${{ matrix.artifact_shard }} DSH_COVERAGE_MAX_WORKERS: ${{ matrix.coverage_max_workers }} + DSH_COVERAGE_SHARD: ${{ matrix.coverage_shard }} + DSH_STATIC_SHARD: ${{ matrix.static_shard }} + DSH_SNAPSHOT_PREBUILT: ${{ matrix.snapshot_prebuilt }} + DSH_SNAPSHOT_SHARD: ${{ matrix.snapshot_shard }} DSH_ESLINT_CACHE: ${{ matrix.eslint_cache }} strategy: fail-fast: false matrix: include: - - lane: static + - lane: static-foundation command: pnpm run check:ci:static gate_concurrency: '4' - publint_concurrency: '8' - coverage_max_workers: '' - eslint_cache: '' + static_shard: foundation + - lane: static-api-contracts + command: pnpm run check:ci:static + gate_concurrency: '4' + static_shard: api-contracts + - lane: static-catalogs + command: pnpm run check:ci:static + gate_concurrency: '4' + static_shard: catalogs + - lane: static-prose + command: pnpm run check:ci:static + gate_concurrency: '4' + static_shard: prose + - lane: static-site + command: pnpm run check:ci:static + gate_concurrency: '1' + static_shard: site - lane: lint command: pnpm run check:ci:lint gate_concurrency: '1' - publint_concurrency: '8' - coverage_max_workers: '' eslint_cache: '1' - - lane: coverage + - lane: coverage-spine command: pnpm run check:ci:coverage gate_concurrency: '1' - publint_concurrency: '8' coverage_max_workers: '4' - eslint_cache: '' - - lane: snapshot + coverage_shard: spine + - lane: coverage-sdk + command: pnpm run check:ci:coverage + gate_concurrency: '1' + coverage_max_workers: '4' + coverage_shard: sdk + - lane: coverage-interfaces + command: pnpm run check:ci:coverage + gate_concurrency: '1' + coverage_max_workers: '4' + coverage_shard: interfaces + - lane: coverage-execution + command: pnpm run check:ci:coverage + gate_concurrency: '1' + coverage_max_workers: '4' + coverage_shard: execution + - lane: coverage-orchestration + command: pnpm run check:ci:coverage + gate_concurrency: '1' + coverage_max_workers: '4' + coverage_shard: orchestration + - lane: coverage-infrastructure + command: pnpm run check:ci:coverage + gate_concurrency: '1' + coverage_max_workers: '4' + coverage_shard: infrastructure + - lane: coverage-session-state + command: pnpm run check:ci:coverage + gate_concurrency: '1' + coverage_max_workers: '4' + coverage_shard: session-state + - lane: coverage-hooks-claude + command: pnpm run check:ci:coverage + gate_concurrency: '1' + coverage_max_workers: '4' + coverage_shard: hooks-claude + - lane: coverage-hooks-codex + command: pnpm run check:ci:coverage + gate_concurrency: '1' + coverage_max_workers: '4' + coverage_shard: hooks-codex + - lane: coverage-capabilities + command: pnpm run check:ci:coverage + gate_concurrency: '1' + coverage_max_workers: '4' + coverage_shard: capabilities + - lane: snapshot-1 command: pnpm run check:ci:snapshot gate_concurrency: '1' - publint_concurrency: '8' - coverage_max_workers: '' - eslint_cache: '' - - lane: artifacts + snapshot_prebuilt: '1' + snapshot_shard: '1/4' + - lane: snapshot-2 + command: pnpm run check:ci:snapshot + gate_concurrency: '1' + snapshot_prebuilt: '1' + snapshot_shard: '2/4' + - lane: snapshot-3 + command: pnpm run check:ci:snapshot + gate_concurrency: '1' + snapshot_prebuilt: '1' + snapshot_shard: '3/4' + - lane: snapshot-4 + command: pnpm run check:ci:snapshot + gate_concurrency: '1' + snapshot_prebuilt: '1' + snapshot_shard: '4/4' + - lane: artifacts-metadata command: pnpm run check:ci:artifacts gate_concurrency: '3' publint_concurrency: '8' - coverage_max_workers: '' - eslint_cache: '' + artifact_shard: metadata + - lane: artifacts-smoke-1 + command: pnpm run check:ci:artifacts + gate_concurrency: '1' + artifact_shard: smoke-1 + - lane: artifacts-smoke-2 + command: pnpm run check:ci:artifacts + gate_concurrency: '1' + artifact_shard: smoke-2 steps: - uses: actions/checkout@v6 @@ -82,19 +165,30 @@ jobs: - name: Install (immutable) run: pnpm install --frozen-lockfile - # The snapshot lane REPLAYS the sandbox example's recorded scenarios, + # The snapshot lanes REPLAY the sandbox example's recorded scenarios, # re-executing their bash calls under a real runner. ubuntu-latest has # no bubblewrap preinstalled and no built Landlock launcher, so without # this the confined executions fail closed (SANDBOX_UNAVAILABLE). Same # install as sandbox.yml's bwrap leg (incl. the Ubuntu 24.04 AppArmor - # userns knob). - - name: Install bubblewrap (unrestrict userns) - if: matrix.lane == 'snapshot' + # userns knob). Building does not depend on bubblewrap, so overlap them. + - name: Prepare built snapshot runtime and bubblewrap + if: startsWith(matrix.lane, 'snapshot-') run: | - sudo apt-get update -q - sudo apt-get install -yq bubblewrap - sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 \ - || echo "apparmor userns knob absent — the functional probe decides" + pnpm run build & + build_pid=$! + ( + sudo apt-get update -q + sudo apt-get install -yq bubblewrap + sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 \ + || echo "apparmor userns knob absent — the functional probe decides" + ) & + sandbox_pid=$! + build_status=0 + sandbox_status=0 + wait "$build_pid" || build_status=$? + wait "$sandbox_pid" || sandbox_status=$? + if (( build_status != 0 )); then exit "$build_status"; fi + exit "$sandbox_status" - uses: actions/cache@v4 if: matrix.lane == 'lint' @@ -110,12 +204,20 @@ jobs: node-compat: runs-on: ubuntu-latest name: node ${{ matrix.node }} + timeout-minutes: 1 env: DSH_GATE_CONCURRENCY: '2' + DSH_NODE_COMPAT_SKIP_TYPECHECK: ${{ matrix.skip_typecheck }} strategy: fail-fast: false matrix: - node: ['22.19', 24, 26] + include: + - node: '22.19' + skip_typecheck: '1' + - node: 24 + skip_typecheck: '' + - node: 26 + skip_typecheck: '1' steps: - uses: actions/checkout@v6 @@ -146,6 +248,7 @@ jobs: python-sdk: runs-on: ubuntu-latest name: python 3.10 / keyless SDK + timeout-minutes: 1 steps: - uses: actions/checkout@v6 @@ -166,6 +269,7 @@ jobs: windows-build: runs-on: windows-2025 name: windows / build + timeout-minutes: 3 steps: - uses: actions/checkout@v6 @@ -191,29 +295,54 @@ jobs: continue-on-error: true runs-on: windows-2025 name: windows node 24 / ${{ matrix.lane }} + timeout-minutes: 3 env: DSH_GATE_CONCURRENCY: ${{ matrix.gate_concurrency }} DSH_PUBLINT_CONCURRENCY: ${{ matrix.publint_concurrency }} + DSH_ARTIFACT_SHARD: ${{ matrix.artifact_shard }} + DSH_STATIC_SHARD: ${{ matrix.static_shard }} DSH_ESLINT_CACHE: ${{ matrix.eslint_cache }} strategy: fail-fast: false matrix: include: - - lane: static + - lane: static-foundation command: pnpm run check:ci:static gate_concurrency: '4' - publint_concurrency: '8' - eslint_cache: '' + static_shard: foundation + - lane: static-api-contracts + command: pnpm run check:ci:static + gate_concurrency: '4' + static_shard: api-contracts + - lane: static-catalogs + command: pnpm run check:ci:static + gate_concurrency: '4' + static_shard: catalogs + - lane: static-prose + command: pnpm run check:ci:static + gate_concurrency: '4' + static_shard: prose + - lane: static-site + command: pnpm run check:ci:static + gate_concurrency: '1' + static_shard: site - lane: lint command: pnpm run check:ci:lint gate_concurrency: '1' - publint_concurrency: '8' eslint_cache: '1' - - lane: artifacts + - lane: artifacts-metadata command: pnpm run check:ci:artifacts gate_concurrency: '3' publint_concurrency: '8' - eslint_cache: '' + artifact_shard: metadata + - lane: artifacts-smoke-1 + command: pnpm run check:ci:artifacts + gate_concurrency: '1' + artifact_shard: smoke-1 + - lane: artifacts-smoke-2 + command: pnpm run check:ci:artifacts + gate_concurrency: '1' + artifact_shard: smoke-2 steps: - uses: actions/checkout@v6 @@ -271,6 +400,7 @@ jobs: all-checks-passed: name: all checks passed runs-on: ubuntu-latest + timeout-minutes: 1 needs: [node-24, node-compat, python-sdk, windows-build] if: always() steps: diff --git a/packages/skill/skill-local/tests/skill-local.spec.ts b/packages/skill/skill-local/tests/skill-local.spec.ts index 43cc70c7ad..0c7d473b13 100644 --- a/packages/skill/skill-local/tests/skill-local.spec.ts +++ b/packages/skill/skill-local/tests/skill-local.spec.ts @@ -391,6 +391,9 @@ describe('LocalSkillProvider', () => { await empty.plugin(SkillService) SkillLocal.apply(empty, {}) expect(await empty.skills.list()).toEqual([]) + + delete process.env.DSH_AGENTS_HOME + expect(new SkillLocal.LocalSkillProvider(empty, { dshHome: join(envHome, 'empty-dsh') }).name).toBe('local') } finally { if (previousDshHome === undefined) { delete process.env.DSH_HOME diff --git a/scripts/coverage-shards.spec.ts b/scripts/coverage-shards.spec.ts new file mode 100644 index 0000000000..8db6ac1aba --- /dev/null +++ b/scripts/coverage-shards.spec.ts @@ -0,0 +1,43 @@ +import { readdirSync } from 'node:fs' +import { resolve } from 'node:path' +import { describe, expect, it } from 'vitest' +import { coverageArgs, coverageShards } from './coverage-shards.ts' + +const repositoryRoot = resolve(import.meta.dirname, '..') + +describe('coverage shards', () => { + it('assigns every workspace package to exactly one lane', () => { + const packagesRoot = resolve(repositoryRoot, 'packages') + const workspacePackages = readdirSync(packagesRoot, { withFileTypes: true }) + .filter(group => group.isDirectory()) + .flatMap(group => readdirSync(resolve(packagesRoot, group.name), { withFileTypes: true }) + .filter(entry => entry.isDirectory()) + .map(entry => `${group.name}/${entry.name}`)) + .sort() + const assignedPackages = coverageShards.flatMap(shard => shard.packageRoots.flatMap((packageRoot) => { + if (packageRoot.includes('/')) return [packageRoot] + return readdirSync(resolve(packagesRoot, packageRoot), { withFileTypes: true }) + .filter(entry => entry.isDirectory()) + .map(entry => `${packageRoot}/${entry.name}`) + })) + + expect([...assignedPackages].sort()).toEqual(workspacePackages) + expect(new Set(assignedPackages).size).toBe(assignedPackages.length) + }) + + it.each(coverageShards)('selects tests and source includes for $name', (shard) => { + const args = coverageArgs(shard.name) + for (const packageRoot of shard.packageRoots) { + expect(args).toContain(`packages/${packageRoot}`) + expect(args).toContain(packageRoot.includes('/') + ? `--coverage.include=packages/${packageRoot}/src/**/*.ts` + : `--coverage.include=packages/${packageRoot}/*/src/**/*.ts`) + } + expect(args).toContain('scripts/test-invariants.spec.ts') + expect(new Set(args).size).toBe(args.length) + }) + + it('rejects an unknown lane', () => { + expect(() => coverageArgs('missing')).toThrow('unknown DSH_COVERAGE_SHARD') + }) +}) diff --git a/scripts/coverage-shards.ts b/scripts/coverage-shards.ts new file mode 100644 index 0000000000..18c360401c --- /dev/null +++ b/scripts/coverage-shards.ts @@ -0,0 +1,68 @@ +/** Coverage shard definitions for the GitHub Actions source-test lanes. */ + +/** A coverage lane that owns complete package roots and optional cross-package tests. */ +export interface CoverageShard { + /** Stable lane identifier passed through `DSH_COVERAGE_SHARD`. */ + name: string + /** Group or package paths below `packages/` whose tests and source coverage belong to the lane. */ + packageRoots: readonly string[] + /** Additional test roots needed for cross-package behavior or repository scripts. */ + extraTestRoots?: readonly string[] +} + +/** Exhaustive, non-overlapping ownership of workspace packages in coverage CI. */ +export const coverageShards = [ + { + name: 'spine', + packageRoots: ['core', 'llm', 'compact', 'context'], + extraTestRoots: ['packages/examples/cli-demo/tests'], + }, + { name: 'sdk', packageRoots: ['sdk'] }, + { + name: 'interfaces', + packageRoots: ['ui', 'examples', 'goal'], + extraTestRoots: ['examples'], + }, + { name: 'execution', packageRoots: ['fs', 'bash', 'sandbox', 'code-runtime'] }, + { name: 'orchestration', packageRoots: ['workflow', 'subagent', 'tasks'] }, + { + name: 'infrastructure', + packageRoots: ['cordis', 'support', 'lsp', 'mcp'], + extraTestRoots: ['scripts'], + }, + { + name: 'session-state', + packageRoots: ['session-persistence', 'session-query'], + }, + { name: 'hooks-claude', packageRoots: ['hooks/hook-protocol', 'hooks/hooks-claude'] }, + { name: 'hooks-codex', packageRoots: ['hooks/hooks-codex'] }, + { + name: 'capabilities', + packageRoots: ['web', 'skill', 'spill', 'util', 'guard', 'todo', 'timeout'], + }, +] as const satisfies readonly CoverageShard[] + +/** + * Build Vitest filters and coverage include globs for one source-test lane. + * + * @param name Stable shard name from {@link coverageShards}. + * @returns Positional test roots followed by per-group coverage include flags. + */ +export function coverageArgs(name: string): string[] { + const shard = coverageShards.find(candidate => candidate.name === name) + if (shard === undefined) { + throw new Error(`run-gates: unknown DSH_COVERAGE_SHARD ${JSON.stringify(name)}.`) + } + + const testRoots = new Set([ + ...shard.packageRoots.map(packageRoot => `packages/${packageRoot}`), + ...('extraTestRoots' in shard ? shard.extraTestRoots : []), + 'scripts/test-invariants.spec.ts', + ]) + return [ + ...testRoots, + ...shard.packageRoots.map(packageRoot => packageRoot.includes('/') + ? `--coverage.include=packages/${packageRoot}/src/**/*.ts` + : `--coverage.include=packages/${packageRoot}/*/src/**/*.ts`), + ] +} diff --git a/scripts/publint-all.spec.ts b/scripts/publint-all.spec.ts new file mode 100644 index 0000000000..22dd80d6b0 --- /dev/null +++ b/scripts/publint-all.spec.ts @@ -0,0 +1,61 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { spawnSync } from 'node:child_process' +import { afterEach, describe, expect, it } from 'vitest' + +const repositoryRoot = fileURLToPath(new URL('..', import.meta.url)) +const runner = fileURLToPath(new URL('./publint-all.ts', import.meta.url)) +const roots: string[] = [] + +afterEach(() => { + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }) +}) + +function fixture(exportPath = './lib/index.js'): string { + const root = mkdtempSync(join(tmpdir(), 'dsh-publint-all-')) + roots.push(root) + const packageDir = join(root, 'packages/core/probe') + mkdirSync(join(packageDir, 'lib'), { recursive: true }) + writeFileSync(join(packageDir, 'package.json'), `${JSON.stringify({ + name: '@deepseek-ai/dsh-probe', + version: '0.0.1', + type: 'module', + license: 'MIT', + engines: { node: '>=22.19' }, + sideEffects: false, + files: ['lib'], + exports: { '.': { default: exportPath } }, + }, null, 2)}\n`) + writeFileSync(join(packageDir, 'README.md'), '# Probe\n') + writeFileSync(join(packageDir, 'lib/index.js'), 'export const probe = true\n') + writeFileSync(join(packageDir, 'unpublished.js'), 'export const hidden = true\n') + return root +} + +function run(root: string) { + return spawnSync(process.execPath, [ + '--import', 'tsx', runner, + '--packages-root', root, + ], { + cwd: repositoryRoot, + encoding: 'utf8', + timeout: 5_000, + }) +} + +describe('publint package runner', () => { + it('lints recursively declared files from an in-memory publication view', () => { + const result = run(fixture()) + expect(result.status, result.stderr).toBe(0) + expect(result.stdout).toContain('linting 1 package(s)') + expect(result.stdout).toContain('All good!') + }) + + it('rejects an export that exists in the workspace but is not published', () => { + const result = run(fixture('./unpublished.js')) + expect(result.status).toBe(1) + expect(result.stdout).toContain('unpublished.js') + }) +}) diff --git a/scripts/publint-all.ts b/scripts/publint-all.ts index 0911316f18..2ed1906763 100644 --- a/scripts/publint-all.ts +++ b/scripts/publint-all.ts @@ -1,46 +1,53 @@ -import { execFile } from 'node:child_process' -import { existsSync, readdirSync } from 'node:fs' +/** Run publint over the exact manifest-declared publication view of every package. */ + +import { + globSync, + readFileSync, + readdirSync, + statSync, +} from 'node:fs' import { availableParallelism } from 'node:os' -import { resolve } from 'node:path' -import { promisify } from 'node:util' +import { dirname, relative, resolve, sep } from 'node:path' +import { publint, type Message, type PackFile } from 'publint' +import { formatMessage } from 'publint/utils' -const execFileAsync = promisify(execFile) const CONCURRENCY_ENV = 'DSH_PUBLINT_CONCURRENCY' +const repositoryRoot = resolve(import.meta.dirname, '..') +const options = parseOptions(process.argv.slice(2)) +const packagesRoot = resolve(options.get('--packages-root') ?? repositoryRoot) -// Discover harness packages at packages//; group containers, -// examples, and private vendored sources are not package targets. -const root = resolve(import.meta.dirname, '..') -const packagesRoot = resolve(root, 'packages') +interface PackageTarget { + path: string + directory: string + manifest: PackageManifest +} -// Run publint's JS CLI through the current node, not the .bin shim: the -// extensionless shim isn't spawnable on Windows (CVE-2024-27980) and the .cmd -// variant needs shell:true, which space-joins args UNESCAPED (DEP0190) and -// breaks when the repo path contains spaces. The JS entry is identical on every -// platform (`bin` is `./src/cli.js` per publint's package.json). -const publintCli = resolve(root, 'node_modules/publint/src/cli.js') +interface PackageManifest { + name?: string + files?: unknown +} type PublintResult = - | { path: string; status: 'passed'; stdout: string; stderr: string } - | { path: string; status: 'failed'; stdout: string; stderr: string; message: string } + | { path: string; status: 'passed'; messages: Message[]; manifest: Record } + | { path: string; status: 'failed'; messages: Message[]; manifest: Record; failure?: string } -function workspacePackages(): string[] { - return readdirSync(packagesRoot, { withFileTypes: true }) - .filter(group => group.isDirectory()) - .flatMap(group => - readdirSync(resolve(packagesRoot, group.name), { withFileTypes: true }) - .filter(pkg => pkg.isDirectory()) - .filter(pkg => existsSync(resolve(packagesRoot, group.name, pkg.name, 'package.json'))) - .map(pkg => `packages/${group.name}/${pkg.name}`), - ) +function workspacePackages(): PackageTarget[] { + return globSync('packages/*/*/package.json', { cwd: packagesRoot }) + .sort() + .map((manifestPath) => { + const absoluteManifestPath = resolve(packagesRoot, manifestPath) + const manifest = JSON.parse(readFileSync(absoluteManifestPath, 'utf8')) as PackageManifest + return { path: dirname(manifestPath), directory: dirname(absoluteManifestPath), manifest } + }) } function publintConcurrency(total: number): number { if (total === 0) return 0 const raw = process.env[CONCURRENCY_ENV] - if (raw !== undefined) { + if (raw !== undefined && raw !== '') { const parsed = Number.parseInt(raw, 10) - if (!Number.isSafeInteger(parsed) || parsed < 1) { + if (!Number.isSafeInteger(parsed) || parsed < 1 || String(parsed) !== raw) { throw new Error(`publint-all: ${CONCURRENCY_ENV} must be a positive integer, got ${JSON.stringify(raw)}.`) } return Math.min(total, parsed) @@ -49,57 +56,106 @@ function publintConcurrency(total: number): number { return Math.min(total, availableParallelism()) } -function outputText(value: unknown): string { - if (typeof value === 'string') return value - if (Buffer.isBuffer(value)) return value.toString() - return '' +function publicationFiles(target: PackageTarget): PackFile[] { + const paths = new Set() + addPath(resolve(target.directory, 'package.json'), paths) + const declared = Array.isArray(target.manifest.files) + ? target.manifest.files.filter((value): value is string => typeof value === 'string') + : [] + for (const pattern of [ + ...declared, + 'README*', + 'LICENSE*', + 'LICENCE*', + 'CHANGELOG*', + 'CHANGES*', + 'HISTORY*', + 'NOTICE*', + ]) { + for (const match of globSync(pattern, { cwd: target.directory })) { + addPath(resolve(target.directory, match), paths) + } + } + + return [...paths] + .sort() + .map(path => ({ + name: `package/${relative(target.directory, path).split(sep).join('/')}`, + data: readFileSync(path), + })) } -async function runPublint(path: string): Promise { +function addPath(path: string, paths: Set): void { + const stat = statSync(path) + if (stat.isDirectory()) { + for (const entry of readdirSync(path)) addPath(resolve(path, entry), paths) + } else if (stat.isFile()) { + paths.add(path) + } +} + +async function runPublint(target: PackageTarget): Promise { try { - const { stdout, stderr } = await execFileAsync(process.execPath, [publintCli, path], { - cwd: root, - encoding: 'utf8', - maxBuffer: 10 * 1024 * 1024, + const result = await publint({ + pkgDir: 'package', + pack: { files: publicationFiles(target) }, }) - return { path, status: 'passed', stdout, stderr } + const manifest = result.pkg as Record + return result.messages.some(message => message.type === 'error') + ? { path: target.path, status: 'failed', messages: result.messages, manifest } + : { path: target.path, status: 'passed', messages: result.messages, manifest } } catch (error: unknown) { - const failed = error as { stdout?: unknown; stderr?: unknown; message?: string } return { - path, + path: target.path, status: 'failed', - stdout: outputText(failed.stdout), - stderr: outputText(failed.stderr), - message: failed.message ?? 'publint failed', + messages: [], + manifest: target.manifest as Record, + failure: error instanceof Error ? error.message : String(error), } } } -async function runAll(paths: string[], concurrency: number): Promise { +async function runAll(targets: PackageTarget[], concurrency: number): Promise { let next = 0 const results: Array = [] await Promise.all(Array.from({ length: concurrency }, async () => { for (;;) { const index = next next += 1 - const path = paths[index] - if (path === undefined) return - results[index] = await runPublint(path) + const target = targets[index] + if (target === undefined) return + results[index] = await runPublint(target) } })) - return paths.map((path, index) => { + return targets.map((target, index) => { const result = results[index] - if (result === undefined) throw new Error(`publint-all: missing result for ${path}.`) + if (result === undefined) throw new Error(`publint-all: missing result for ${target.path}.`) return result }) } function printResult(result: PublintResult): void { console.log(`Running publint for ${result.path}...`) - process.stdout.write(result.stdout) - process.stderr.write(result.stderr) - if (result.status === 'failed') console.error(result.message) + if ('failure' in result) console.error(result.failure) + for (const message of result.messages) { + console.log(formatMessage(message, result.manifest, { color: false }) ?? message.code) + } + if (result.status === 'passed' && result.messages.length === 0) console.log('All good!') +} + +function parseOptions(args: string[]): Map { + const parsed = new Map() + for (let index = 0; index < args.length; index += 2) { + const name = args[index] + const value = args[index + 1] + if (name !== '--packages-root' || value === undefined || value.startsWith('--')) { + throw new Error(`publint-all: expected [--packages-root PATH], got ${JSON.stringify(args)}.`) + } + if (parsed.has(name)) throw new Error(`publint-all: duplicate option ${name}.`) + parsed.set(name, value) + } + return parsed } const packages = workspacePackages() diff --git a/scripts/run-gates.ts b/scripts/run-gates.ts index 582972f778..7df9d8c4c5 100644 --- a/scripts/run-gates.ts +++ b/scripts/run-gates.ts @@ -8,6 +8,8 @@ import { spawn } from 'node:child_process' import { availableParallelism } from 'node:os' import { resolve } from 'node:path' import { performance } from 'node:perf_hooks' +import { coverageArgs } from './coverage-shards.ts' +import { selectStaticGates } from './static-shards.ts' type Mode = | 'ci-primary' @@ -161,20 +163,16 @@ function gatesForMode(selected: Mode): Gate[] { pnpmScript('duplication', 'duplication'), ] case 'ci-coverage': - return [ - pnpmScript('build', 'build'), - coverageGate(), - ] + return [coverageGate()] case 'ci-snapshot': - return [ - pnpmScript('build', 'build'), - snapshotGate(), - ] + return flagEnabled('DSH_SNAPSHOT_PREBUILT') + ? [snapshotGate([])] + : [pnpmScript('build', 'build'), snapshotGate()] case 'ci-artifacts': return ciArtifactGates() case 'node-compat': return [ - pnpmScript('typecheck', 'typecheck'), + ...flagEnabled('DSH_NODE_COMPAT_SKIP_TYPECHECK') ? [] : [pnpmScript('typecheck', 'typecheck')], pnpmExec('source-worker-smoke', [ 'vitest', 'run', @@ -230,7 +228,7 @@ function ciPrimaryGates(): Gate[] { } function ciStaticGates(): Gate[] { - return [ + const gates = [ pnpmScript('runtime-closure', 'verify-runtime-closure', { label: 'runtime closure' }), pnpmScript('constraints', 'constraints'), pnpmScript('package-invariants', 'verify-package-invariants', { label: 'package invariants' }), @@ -239,10 +237,12 @@ function ciStaticGates(): Gate[] { pnpmScript('module-graph', 'verify-module-graph', { label: 'module graph' }), pnpmScript('knip', 'knip'), ] + return selectStaticGates(gates, process.env.DSH_STATIC_SHARD) } function ciArtifactGates(): Gate[] { - return [ + const shard = process.env.DSH_ARTIFACT_SHARD + const metadataGates = [ pnpmScript('build', 'build'), pnpmScript('publint', 'publint', { needs: ['build'] }), pnpmScript('node-next-types', 'verify-node-next-types', { @@ -250,8 +250,14 @@ function ciArtifactGates(): Gate[] { needs: ['build'], }), builtPackageInvariantsGate(['build']), - builtBinSmokeGate(), ] + if (shard === 'metadata') return metadataGates + if (shard === 'smoke-1') return [pnpmScript('build', 'build'), builtBinSmokeGate('1/2')] + if (shard === 'smoke-2') return [pnpmScript('build', 'build'), builtBinSmokeGate('2/2')] + if (shard !== undefined && shard !== '') { + throw new Error(`run-gates: unknown DSH_ARTIFACT_SHARD ${JSON.stringify(shard)}.`) + } + return [...metadataGates, builtBinSmokeGate()] } function lintGate(): Gate { @@ -275,25 +281,36 @@ function lintGate(): Gate { } function coverageGate(): Gate { + const shard = process.env.DSH_COVERAGE_SHARD return pnpmExec('coverage', [ 'vitest', 'run', '--coverage', + ...(shard === undefined || shard === '' ? [] : coverageArgs(shard)), ...positiveIntArg('DSH_COVERAGE_MAX_WORKERS', '--maxWorkers'), ], { label: 'test:coverage', - env: { DSH_EXAMPLE_MODE: 'lib' }, - needs: ['build'], }) } // The snapshot suite boots the example bins in `lib` mode (built artifact under plain Node, // plugins via real exports) — CI and pre-push already build, so they exercise what ships rather // than the tsx/source path dev uses. It therefore waits on `build`. -function snapshotGate(): Gate { - return pnpmScript('snapshot', 'test:snapshot', { +function snapshotGate(needs: string[] = ['build']): Gate { + const shard = process.env.DSH_SNAPSHOT_SHARD + if (shard !== undefined && shard !== '' && !/^\d+\/\d+$/.test(shard)) { + throw new Error(`run-gates: DSH_SNAPSHOT_SHARD must be INDEX/TOTAL, got ${JSON.stringify(shard)}.`) + } + return pnpmExec('snapshot', [ + 'vitest', + 'run', + '--config', + 'vitest.snapshot.config.ts', + ...(shard === undefined || shard === '' ? [] : [`--shard=${shard}`]), + ], { + label: 'test:snapshot', env: { DSH_EXAMPLE_MODE: 'lib' }, - needs: ['build'], + ...needs.length === 0 ? {} : { needs }, }) } @@ -314,6 +331,13 @@ function positiveIntArg(envName: string, flag: string): string[] { return [`${flag}=${raw}`] } +function flagEnabled(envName: string): boolean { + const raw = process.env[envName] + if (raw === undefined || raw === '') return false + if (raw !== '1') throw new Error(`run-gates: ${envName} must be 1 when set, got ${JSON.stringify(raw)}.`) + return true +} + function hygieneLeafGates(options: { artifactNeeds?: string[] } = {}): Gate[] { const artifactOptions = options.artifactNeeds === undefined ? {} : { needs: options.artifactNeeds } return [ @@ -363,7 +387,7 @@ function docSyncLeafGates(options: { ] } -function builtBinSmokeGate(): Gate { +function builtBinSmokeGate(shard?: string): Gate { return pnpmExec('built-bin-smoke', [ 'vitest', 'run', @@ -379,6 +403,7 @@ function builtBinSmokeGate(): Gate { // (the e2e lane runs unbuilt, so these files self-skip there). 'packages/workflow/workflow-workerthread/tests/built-worker.e2e.ts', 'packages/code-runtime/code-runtime-worker/tests/built-lib.e2e.ts', + ...(shard === undefined ? [] : [`--shard=${shard}`]), ], { label: 'built-bin smoke', needs: ['build'], diff --git a/scripts/static-shards.spec.ts b/scripts/static-shards.spec.ts new file mode 100644 index 0000000000..2c57be6dd5 --- /dev/null +++ b/scripts/static-shards.spec.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from 'vitest' +import { selectStaticGates, staticShards } from './static-shards.ts' + +const completeInventory = staticShards.flatMap(shard => shard.gateIds).map(id => ({ id })) + +describe('static gate shards', () => { + it.each(staticShards)('selects only the gates owned by $name', (shard) => { + expect(selectStaticGates(completeInventory, shard.name).map(gate => gate.id)).toEqual(shard.gateIds) + }) + + it('rejects missing, duplicate, and unknown assignments', () => { + expect(() => selectStaticGates(completeInventory.slice(1))).toThrow('assign every static gate exactly once') + expect(() => selectStaticGates([...completeInventory, completeInventory[0]!])).toThrow('static gate IDs must be unique') + expect(() => selectStaticGates(completeInventory, 'missing')).toThrow('unknown DSH_STATIC_SHARD') + }) +}) diff --git a/scripts/static-shards.ts b/scripts/static-shards.ts new file mode 100644 index 0000000000..4d7a63b8c9 --- /dev/null +++ b/scripts/static-shards.ts @@ -0,0 +1,77 @@ +/** Static-gate shard definitions for GitHub Actions. */ + +/** A static CI lane identified by the gate IDs it owns. */ +export interface StaticShard { + /** Stable lane identifier passed through `DSH_STATIC_SHARD`. */ + name: string + /** Gate IDs selected from the static gate inventory. */ + gateIds: readonly string[] +} + +/** Exhaustive, non-overlapping ownership of static CI gates. */ +export const staticShards = [ + { + name: 'foundation', + gateIds: [ + 'runtime-closure', + 'constraints', + 'package-invariants', + 'cordis-config', + 'module-graph', + 'knip', + ], + }, + { + name: 'api-contracts', + gateIds: ['doc-typecheck', 'export-jsdoc', 'scoped-events', 'type-equivalence'], + }, + { + name: 'catalogs', + gateIds: ['cordis-catalog', 'tool-catalog', 'config-catalog', 'persistence-catalog', 'doc-graphs'], + }, + { + name: 'prose', + gateIds: [ + 'markdown-wrap', + 'markdown-links', + 'doc-refs', + 'package-paths', + 'package-readme-model-experience', + 'mermaid', + 'agent-note-classification', + 'agent-note-format', + 'translation-prompt', + 'translation-pairing', + 'doc-budgets', + 'package-readme-limitations', + ], + }, + { name: 'site', gateIds: ['docs-site'] }, +] as const satisfies readonly StaticShard[] + +/** + * Validate the complete gate partition and optionally select one lane. + * + * @param gates Complete static gate inventory. + * @param name Optional stable shard name. + * @returns All gates when no shard is requested, otherwise the selected lane. + */ +export function selectStaticGates(gates: readonly T[], name?: string): T[] { + const gateIds = gates.map(gate => gate.id) + const assignedIds = staticShards.flatMap(shard => shard.gateIds) + const uniqueGateIds = new Set(gateIds) + const uniqueAssignedIds = new Set(assignedIds) + if (uniqueGateIds.size !== gateIds.length) throw new Error('run-gates: static gate IDs must be unique.') + if (uniqueAssignedIds.size !== assignedIds.length) throw new Error('run-gates: static shard gate IDs must be unique.') + if (gateIds.length !== assignedIds.length + || gateIds.some(id => !uniqueAssignedIds.has(id)) + || assignedIds.some(id => !uniqueGateIds.has(id))) { + throw new Error('run-gates: static shards must assign every static gate exactly once.') + } + if (name === undefined || name === '') return [...gates] + + const shard = staticShards.find(candidate => candidate.name === name) + if (shard === undefined) throw new Error(`run-gates: unknown DSH_STATIC_SHARD ${JSON.stringify(name)}.`) + const selectedIds = new Set(shard.gateIds) + return gates.filter(gate => selectedIds.has(gate.id)) +} diff --git a/scripts/verify-built-package-invariants.mjs b/scripts/verify-built-package-invariants.mjs index 4b298946d1..2d5a496354 100644 --- a/scripts/verify-built-package-invariants.mjs +++ b/scripts/verify-built-package-invariants.mjs @@ -1,102 +1,106 @@ -/** Verify every packed companion through its package self-reference under plain Node. */ +/** Verify every compiled companion through its staged package self-reference under plain Node. */ -import { spawnSync } from 'node:child_process' import { copyFileSync, + cpSync, + existsSync, globSync, mkdirSync, mkdtempSync, readFileSync, rmSync, + writeFileSync, } from 'node:fs' import { dirname, resolve } from 'node:path' import { pathToFileURL } from 'node:url' -const root = resolve(import.meta.dirname, '..') -const loaderUrl = pathToFileURL(resolve(root, 'vendor/loader/lib/index.js')).href +const repositoryRoot = resolve(import.meta.dirname, '..') +const options = parseOptions(process.argv.slice(2)) +const packagesRoot = resolve(options.get('--packages-root') ?? repositoryRoot) +const loaderUrl = options.get('--loader-url') + ?? pathToFileURL(resolve(repositoryRoot, 'vendor/loader/lib/index.js')).href const failures = [] -const manifests = globSync('packages/*/*/package.json', { cwd: root }).sort() -const packArgs = ['pack', '--dry-run', '--json', '--ignore-scripts'] -// Windows cannot spawn npm's .cmd shim directly; setup-node installs this JS -// entrypoint beside node.exe, so the probe stays shell-free on every runner. -const npmInvocation = process.platform === 'win32' - ? [process.execPath, [resolve(dirname(process.execPath), 'node_modules/npm/bin/npm-cli.js'), ...packArgs]] - : ['npm', packArgs] +const manifests = globSync('packages/*/*/package.json', { cwd: packagesRoot }).sort() +const { default: Loader } = await import(loaderUrl) +const loader = Object.create(Loader.prototype) for (const manifestPath of manifests) { - const packageDir = dirname(resolve(root, manifestPath)) - const manifest = JSON.parse(readFileSync(resolve(root, manifestPath), 'utf8')) + const packageDir = dirname(resolve(packagesRoot, manifestPath)) + const manifest = JSON.parse(readFileSync(resolve(packagesRoot, manifestPath), 'utf8')) const packageName = manifest.name if (typeof packageName !== 'string' || packageName.length === 0) { failures.push(`${manifestPath}: missing package name`) continue } - - const pack = spawnSync(npmInvocation[0], npmInvocation[1], { - cwd: packageDir, - encoding: 'utf8', - }) - if (pack.status !== 0) { - const detail = pack.error?.message - ?? (pack.stderr.trim() || pack.stdout.trim() || `npm pack exited ${pack.status}`) - failures.push(`${packageName}: ${detail}`) + const invariantExport = manifest.exports?.['./invariant'] + if (typeof invariantExport !== 'object' + || invariantExport.default !== './lib/invariant.js' + || !manifest.files?.includes('lib/invariant.js')) { + failures.push(`${packageName}: manifest does not publish ./lib/invariant.js as ./invariant`) continue } - let files - try { - const result = JSON.parse(pack.stdout) - files = result[0]?.files - if (!Array.isArray(files)) throw new Error('npm pack returned no file inventory') - } catch (error) { - failures.push(`${packageName}: cannot parse npm pack inventory: ${String(error)}`) - continue - } - - // Keep the packed view below its owning package so Node reaches the real + // Keep the staged view below its owning package so Node reaches the real // pnpm dependency links. Junctioning node_modules elsewhere breaks pnpm's - // relative workspace links on Windows. - const stagedPackageDir = mkdtempSync(resolve(packageDir, '.dsh-packed-invariant-')) + // relative workspace links on Windows. Copy only the statically required + // runtime entry so a companion that imports an undeclared chunk fails here. + const stagedPackageDir = mkdtempSync(resolve(packageDir, '.dsh-built-invariant-')) try { - for (const file of files) { - if (typeof file.path !== 'string' - || (file.path !== 'package.json' && !file.path.startsWith('lib/'))) continue - const target = resolve(stagedPackageDir, file.path) - mkdirSync(dirname(target), { recursive: true }) - copyFileSync(resolve(packageDir, file.path), target) - } - - const probe = ` - const companion = await import(${JSON.stringify(`${packageName}/invariant`)}); - const { default: Loader } = await import(${JSON.stringify(loaderUrl)}); - if ('default' in companion) throw new Error('companion has a default export'); - const loader = Object.create(Loader.prototype); - const unwrapped = loader.unwrapExports(companion); - if (unwrapped !== companion) throw new Error('Loader collapsed the companion namespace'); - if (typeof unwrapped.name !== 'string') throw new Error('companion name is missing'); - if (!Array.isArray(unwrapped.inject) || !unwrapped.inject.includes('invariants')) { - throw new Error('companion does not inject invariants'); - } - if (typeof unwrapped.apply !== 'function') throw new Error('companion apply is missing'); - ` - const result = spawnSync(process.execPath, ['--input-type=module', '--eval', probe], { - cwd: stagedPackageDir, - encoding: 'utf8', - }) - if (result.status !== 0) { - const detail = result.error?.message - ?? (result.stderr.trim() || result.stdout.trim() || `node exited ${result.status}`) - failures.push(`${packageName}: ${detail}`) + copyFileSync(resolve(packageDir, 'package.json'), resolve(stagedPackageDir, 'package.json')) + copyDeclaredLibFiles(packageDir, stagedPackageDir, manifest.files) + const probePath = resolve(stagedPackageDir, 'probe.mjs') + writeFileSync( + probePath, + `import * as companion from ${JSON.stringify(`${packageName}/invariant`)}\nexport default companion\n`, + ) + const { default: companion } = await import(pathToFileURL(probePath).href) + if ('default' in companion) throw new Error('companion has a default export') + const unwrapped = loader.unwrapExports(companion) + if (unwrapped !== companion) throw new Error('Loader collapsed the companion namespace') + if (typeof unwrapped.name !== 'string') throw new Error('companion name is missing') + if (!Array.isArray(unwrapped.inject) || !unwrapped.inject.includes('invariants')) { + throw new Error('companion does not inject invariants') } + if (typeof unwrapped.apply !== 'function') throw new Error('companion apply is missing') + } catch (error) { + failures.push(`${packageName}: ${error instanceof Error ? error.message : String(error)}`) } finally { rmSync(stagedPackageDir, { recursive: true, force: true }) } } if (failures.length > 0) { - console.error('verify-built-package-invariants: packed companion failures:') + console.error('verify-built-package-invariants: compiled companion failures:') for (const failure of failures) console.error(` ${failure}`) process.exit(1) } -console.log(`verify-built-package-invariants: ${manifests.length} packed companion(s) passed plain-Node Loader checks.`) +console.log(`verify-built-package-invariants: ${manifests.length} compiled companion(s) passed plain-Node Loader checks.`) + +function parseOptions(args) { + const allowed = new Set(['--packages-root', '--loader-url']) + const parsed = new Map() + for (let index = 0; index < args.length; index += 2) { + const name = args[index] + const value = args[index + 1] + if (!allowed.has(name) || value === undefined || value.startsWith('--')) { + throw new Error(`verify-built-package-invariants: expected [--packages-root PATH] [--loader-url URL], got ${JSON.stringify(args)}.`) + } + if (parsed.has(name)) throw new Error(`verify-built-package-invariants: duplicate option ${name}.`) + parsed.set(name, value) + } + return parsed +} + +function copyDeclaredLibFiles(packageDir, stagedPackageDir, files) { + for (const pattern of files) { + if (!pattern.startsWith('lib/')) continue + for (const relativePath of globSync(pattern, { cwd: packageDir })) { + const source = resolve(packageDir, relativePath) + if (!existsSync(source)) continue + const target = resolve(stagedPackageDir, relativePath) + mkdirSync(dirname(target), { recursive: true }) + cpSync(source, target, { recursive: true }) + } + } +} diff --git a/scripts/verify-built-package-invariants.spec.ts b/scripts/verify-built-package-invariants.spec.ts new file mode 100644 index 0000000000..4863e30c54 --- /dev/null +++ b/scripts/verify-built-package-invariants.spec.ts @@ -0,0 +1,88 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { fileURLToPath, pathToFileURL } from 'node:url' +import { spawnSync } from 'node:child_process' +import { afterEach, describe, expect, it } from 'vitest' + +const verifier = fileURLToPath(new URL('./verify-built-package-invariants.mjs', import.meta.url)) +const roots: string[] = [] + +afterEach(() => { + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }) +}) + +function fixture(options: { + invariantSource?: string + invariantExport?: string + runtimeChunk?: string +} = {}): { root: string; loaderUrl: string } { + const root = mkdtempSync(join(tmpdir(), 'dsh-built-package-invariants-')) + roots.push(root) + const packageDir = join(root, 'packages/core/probe') + mkdirSync(join(packageDir, 'lib'), { recursive: true }) + writeFileSync(join(packageDir, 'package.json'), `${JSON.stringify({ + name: '@deepseek-ai/dsh-probe', + type: 'module', + files: ['lib/invariant.js'], + exports: { + './invariant': { + default: options.invariantExport ?? './lib/invariant.js', + }, + }, + }, null, 2)}\n`) + writeFileSync( + join(packageDir, 'lib/invariant.js'), + options.invariantSource ?? "export const name = 'probe-invariant'\nexport const inject = ['invariants']\nexport const apply = () => {}\n", + ) + if (options.runtimeChunk !== undefined) { + writeFileSync(join(packageDir, 'lib/chunk.js'), options.runtimeChunk) + } + const loaderPath = join(root, 'loader.mjs') + writeFileSync(loaderPath, 'export default class Loader { unwrapExports(value) { return value } }\n') + return { root, loaderUrl: pathToFileURL(loaderPath).href } +} + +function verify(root: string, loaderUrl: string) { + return spawnSync(process.execPath, [ + verifier, + '--packages-root', root, + '--loader-url', loaderUrl, + ], { + encoding: 'utf8', + timeout: 5_000, + }) +} + +describe('built package invariant verifier', () => { + it('loads the staged compiled self-reference through plain Node and Loader normalization', () => { + const { root, loaderUrl } = fixture() + const result = verify(root, loaderUrl) + expect(result.status, result.stderr).toBe(0) + expect(result.stdout).toContain('1 compiled companion(s) passed plain-Node Loader checks') + }) + + it('rejects a default export and a broken invariant export map', () => { + const withDefault = fixture({ + invariantSource: "export default {}\nexport const name = 'probe-invariant'\nexport const inject = ['invariants']\nexport const apply = () => {}\n", + }) + const defaultResult = verify(withDefault.root, withDefault.loaderUrl) + expect(defaultResult.status).toBe(1) + expect(defaultResult.stderr).toContain('companion has a default export') + + const brokenExport = fixture({ invariantExport: './lib/missing.js' }) + const exportResult = verify(brokenExport.root, brokenExport.loaderUrl) + expect(exportResult.status).toBe(1) + expect(exportResult.stderr).toContain('@deepseek-ai/dsh-probe') + }) + + it('rejects an invariant bundle that needs an unstaged runtime chunk', () => { + const { root, loaderUrl } = fixture({ + invariantSource: "export * from './chunk.js'\n", + runtimeChunk: "export const name = 'probe-invariant'\nexport const inject = ['invariants']\nexport const apply = () => {}\n", + }) + const result = verify(root, loaderUrl) + expect(result.status).toBe(1) + expect(result.stderr).toContain('chunk.js') + }) +}) From 91bd51c23a6bdea7109c2847659189c89d056da6 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 21 Jul 2026 20:32:57 +0800 Subject: [PATCH 02/90] ci: add coverage lane headroom --- .github/workflows/ci.yml | 42 ++++++++++++++++++--- scripts/coverage-shards.ts | 30 ++++++++++++--- scripts/verify-built-package-invariants.mjs | 4 +- 3 files changed, 62 insertions(+), 14 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9be2932c11..a8dd6f9ec0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -58,11 +58,16 @@ jobs: command: pnpm run check:ci:lint gate_concurrency: '1' eslint_cache: '1' - - lane: coverage-spine + - lane: coverage-core command: pnpm run check:ci:coverage gate_concurrency: '1' coverage_max_workers: '4' - coverage_shard: spine + coverage_shard: core + - lane: coverage-models + command: pnpm run check:ci:coverage + gate_concurrency: '1' + coverage_max_workers: '4' + coverage_shard: models - lane: coverage-sdk command: pnpm run check:ci:coverage gate_concurrency: '1' @@ -78,16 +83,36 @@ jobs: gate_concurrency: '1' coverage_max_workers: '4' coverage_shard: execution - - lane: coverage-orchestration + - lane: coverage-workflow command: pnpm run check:ci:coverage gate_concurrency: '1' coverage_max_workers: '4' - coverage_shard: orchestration - - lane: coverage-infrastructure + coverage_shard: workflow + - lane: coverage-workflow-worker command: pnpm run check:ci:coverage gate_concurrency: '1' coverage_max_workers: '4' - coverage_shard: infrastructure + coverage_shard: workflow-worker + - lane: coverage-delegation + command: pnpm run check:ci:coverage + gate_concurrency: '1' + coverage_max_workers: '4' + coverage_shard: delegation + - lane: coverage-repository + command: pnpm run check:ci:coverage + gate_concurrency: '1' + coverage_max_workers: '4' + coverage_shard: repository + - lane: coverage-scripts + command: pnpm run check:ci:coverage + gate_concurrency: '1' + coverage_max_workers: '4' + coverage_shard: scripts + - lane: coverage-integrations + command: pnpm run check:ci:coverage + gate_concurrency: '1' + coverage_max_workers: '4' + coverage_shard: integrations - lane: coverage-session-state command: pnpm run check:ci:coverage gate_concurrency: '1' @@ -98,6 +123,11 @@ jobs: gate_concurrency: '1' coverage_max_workers: '4' coverage_shard: hooks-claude + - lane: coverage-hook-protocol + command: pnpm run check:ci:coverage + gate_concurrency: '1' + coverage_max_workers: '4' + coverage_shard: hook-protocol - lane: coverage-hooks-codex command: pnpm run check:ci:coverage gate_concurrency: '1' diff --git a/scripts/coverage-shards.ts b/scripts/coverage-shards.ts index 18c360401c..5ac8f999f3 100644 --- a/scripts/coverage-shards.ts +++ b/scripts/coverage-shards.ts @@ -13,10 +13,11 @@ export interface CoverageShard { /** Exhaustive, non-overlapping ownership of workspace packages in coverage CI. */ export const coverageShards = [ { - name: 'spine', - packageRoots: ['core', 'llm', 'compact', 'context'], + name: 'core', + packageRoots: ['core', 'context'], extraTestRoots: ['packages/examples/cli-demo/tests'], }, + { name: 'models', packageRoots: ['llm', 'compact'] }, { name: 'sdk', packageRoots: ['sdk'] }, { name: 'interfaces', @@ -24,17 +25,34 @@ export const coverageShards = [ extraTestRoots: ['examples'], }, { name: 'execution', packageRoots: ['fs', 'bash', 'sandbox', 'code-runtime'] }, - { name: 'orchestration', packageRoots: ['workflow', 'subagent', 'tasks'] }, { - name: 'infrastructure', - packageRoots: ['cordis', 'support', 'lsp', 'mcp'], + name: 'workflow', + packageRoots: ['workflow/workflow', 'workflow/tool-workflow', 'workflow/tool-ralph'], + }, + { name: 'workflow-worker', packageRoots: ['workflow/workflow-workerthread'] }, + { name: 'delegation', packageRoots: ['subagent', 'tasks'] }, + { + name: 'repository', + packageRoots: [ + 'cordis', + 'support/acp-snapshot', + 'support/agent-loop-testkit', + 'support/llm-replay', + 'support/loader-smoke', + ], + }, + { + name: 'scripts', + packageRoots: ['support/invariants'], extraTestRoots: ['scripts'], }, + { name: 'integrations', packageRoots: ['lsp', 'mcp'] }, { name: 'session-state', packageRoots: ['session-persistence', 'session-query'], }, - { name: 'hooks-claude', packageRoots: ['hooks/hook-protocol', 'hooks/hooks-claude'] }, + { name: 'hook-protocol', packageRoots: ['hooks/hook-protocol'] }, + { name: 'hooks-claude', packageRoots: ['hooks/hooks-claude'] }, { name: 'hooks-codex', packageRoots: ['hooks/hooks-codex'] }, { name: 'capabilities', diff --git a/scripts/verify-built-package-invariants.mjs b/scripts/verify-built-package-invariants.mjs index 2d5a496354..9c672e05f0 100644 --- a/scripts/verify-built-package-invariants.mjs +++ b/scripts/verify-built-package-invariants.mjs @@ -42,8 +42,8 @@ for (const manifestPath of manifests) { // Keep the staged view below its owning package so Node reaches the real // pnpm dependency links. Junctioning node_modules elsewhere breaks pnpm's - // relative workspace links on Windows. Copy only the statically required - // runtime entry so a companion that imports an undeclared chunk fails here. + // relative workspace links on Windows. Copy the manifest-declared lib view + // so a companion that imports an undeclared runtime chunk fails here. const stagedPackageDir = mkdtempSync(resolve(packageDir, '.dsh-built-invariant-')) try { copyFileSync(resolve(packageDir, 'package.json'), resolve(stagedPackageDir, 'package.json')) From 3d965082443d7216e8755b122652255226095aa3 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 21 Jul 2026 20:51:28 +0800 Subject: [PATCH 03/90] ci: restore hosted-run headroom --- .../2026-07-06-parallel-github-ci-gates.md | 4 +- .github/workflows/ci.yml | 24 +++++++++-- scripts/lint-shards.spec.ts | 34 ++++++++++++++++ scripts/lint-shards.ts | 40 +++++++++++++++++++ scripts/run-gates.ts | 13 +++--- scripts/static-shards.ts | 2 +- 6 files changed, 105 insertions(+), 12 deletions(-) create mode 100644 scripts/lint-shards.spec.ts create mode 100644 scripts/lint-shards.ts diff --git a/.agents/notes/implemented/process/2026-07-06-parallel-github-ci-gates.md b/.agents/notes/implemented/process/2026-07-06-parallel-github-ci-gates.md index 0058a30660..a76c425da7 100644 --- a/.agents/notes/implemented/process/2026-07-06-parallel-github-ci-gates.md +++ b/.agents/notes/implemented/process/2026-07-06-parallel-github-ci-gates.md @@ -14,9 +14,9 @@ The artifact boundary remains load-bearing. `publint`, `verify-node-next-types`, [CI](../../../../.github/workflows/ci.yml) bounds every non-Windows job to one minute and every Windows job to three minutes. The timeout is an executable regression ceiling; the lane design leaves headroom below it rather than treating a timeout as normal control flow. -[scripts/run-gates.ts](../../../../scripts/run-gates.ts) remains the common bounded scheduler, but GitHub supplies explicit shard names for the expensive gate families. [scripts/static-shards.ts](../../../../scripts/static-shards.ts) partitions static gates into foundation, API-contract, catalog, prose, and documentation-site lanes and rejects a missing or duplicate gate assignment. [scripts/coverage-shards.ts](../../../../scripts/coverage-shards.ts) assigns every workspace package to exactly one source-coverage lane; its test expands the live package tree, so a new package makes CI red until it has an owner. Each coverage lane includes only its owned source files, repeats the exhaustive companion topology test, and runs without a preceding build because the complete coverage suite passes from a tree with every generated `lib/` removed. +[scripts/run-gates.ts](../../../../scripts/run-gates.ts) remains the common bounded scheduler, but GitHub supplies explicit shard names for the expensive gate families. [scripts/static-shards.ts](../../../../scripts/static-shards.ts) partitions static gates into foundation, API-contract, catalog, prose, and documentation-site lanes and rejects a missing or duplicate gate assignment. Lint uses disjoint package-source, package-test, and repository-complement lanes; the complement still starts from `.` so a new top-level lint target cannot disappear between shards, and it owns the single cross-file duplication run. [scripts/coverage-shards.ts](../../../../scripts/coverage-shards.ts) assigns every workspace package to exactly one source-coverage lane; its test expands the live package tree, so a new package makes CI red until it has an owner. Each coverage lane includes only its owned source files, repeats the exhaustive companion topology test, and runs without a preceding build because the complete coverage suite passes from a tree with every generated `lib/` removed. -Snapshot replay is four Vitest file shards. Each snapshot job builds the shipped runtime while its Linux runner installs bubblewrap, then runs only its assigned replay files. Static, coverage, and snapshot sharding changes only GitHub scheduling: the ordinary local package scripts still run their complete suites. +Snapshot replay is four Vitest file shards. Each snapshot job builds the shipped runtime while its Linux runner installs bubblewrap from the hosted image's existing package index, then runs only its assigned replay files. CI explicitly retains the suite's bounded concurrency of five subprocesses instead of clamping it to the runner's two logical CPUs, because replay spends most of its time waiting on child protocol I/O. Static, lint, coverage, and snapshot sharding changes only GitHub scheduling: the ordinary local package scripts still run their complete suites. Artifacts use three lanes: one metadata lane for `publint`, NodeNext declarations, and compiled invariant loading, plus two Vitest shards for built-bin smoke. Each lane produces its own build before its consumers. Repeating the short build costs runner minutes but avoids an upload/download dependency and keeps each job's critical path bounded. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a8dd6f9ec0..1ae8eb35de 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,7 +26,9 @@ jobs: DSH_ARTIFACT_SHARD: ${{ matrix.artifact_shard }} DSH_COVERAGE_MAX_WORKERS: ${{ matrix.coverage_max_workers }} DSH_COVERAGE_SHARD: ${{ matrix.coverage_shard }} + DSH_LINT_SHARD: ${{ matrix.lint_shard }} DSH_STATIC_SHARD: ${{ matrix.static_shard }} + DSH_SNAPSHOT_MAX_CONCURRENCY: ${{ matrix.snapshot_max_concurrency }} DSH_SNAPSHOT_PREBUILT: ${{ matrix.snapshot_prebuilt }} DSH_SNAPSHOT_SHARD: ${{ matrix.snapshot_shard }} DSH_ESLINT_CACHE: ${{ matrix.eslint_cache }} @@ -54,10 +56,21 @@ jobs: command: pnpm run check:ci:static gate_concurrency: '1' static_shard: site - - lane: lint + - lane: lint-package-sources command: pnpm run check:ci:lint gate_concurrency: '1' eslint_cache: '1' + lint_shard: package-sources + - lane: lint-package-tests + command: pnpm run check:ci:lint + gate_concurrency: '1' + eslint_cache: '1' + lint_shard: package-tests + - lane: lint-repository + command: pnpm run check:ci:lint + gate_concurrency: '1' + eslint_cache: '1' + lint_shard: repository - lane: coverage-core command: pnpm run check:ci:coverage gate_concurrency: '1' @@ -141,21 +154,25 @@ jobs: - lane: snapshot-1 command: pnpm run check:ci:snapshot gate_concurrency: '1' + snapshot_max_concurrency: '5' snapshot_prebuilt: '1' snapshot_shard: '1/4' - lane: snapshot-2 command: pnpm run check:ci:snapshot gate_concurrency: '1' + snapshot_max_concurrency: '5' snapshot_prebuilt: '1' snapshot_shard: '2/4' - lane: snapshot-3 command: pnpm run check:ci:snapshot gate_concurrency: '1' + snapshot_max_concurrency: '5' snapshot_prebuilt: '1' snapshot_shard: '3/4' - lane: snapshot-4 command: pnpm run check:ci:snapshot gate_concurrency: '1' + snapshot_max_concurrency: '5' snapshot_prebuilt: '1' snapshot_shard: '4/4' - lane: artifacts-metadata @@ -207,8 +224,7 @@ jobs: pnpm run build & build_pid=$! ( - sudo apt-get update -q - sudo apt-get install -yq bubblewrap + sudo apt-get install -yq --no-install-recommends bubblewrap sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 \ || echo "apparmor userns knob absent — the functional probe decides" ) & @@ -221,7 +237,7 @@ jobs: exit "$sandbox_status" - uses: actions/cache@v4 - if: matrix.lane == 'lint' + if: startsWith(matrix.lane, 'lint-') with: path: .cache/eslint key: ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-eslint-${{ hashFiles('pnpm-lock.yaml', 'eslint.config.mjs', 'tsconfig.json', 'packages/*/*/tsconfig.json', 'examples/*/tsconfig.json') }} diff --git a/scripts/lint-shards.spec.ts b/scripts/lint-shards.spec.ts new file mode 100644 index 0000000000..df50790260 --- /dev/null +++ b/scripts/lint-shards.spec.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from 'vitest' +import { selectLintShard } from './lint-shards.ts' + +describe('lint gate shards', () => { + it('keeps the unsharded local command complete', () => { + expect(selectLintShard()).toEqual({ eslintTargets: ['.'], includeDuplication: true }) + expect(selectLintShard('')).toEqual({ eslintTargets: ['.'], includeDuplication: true }) + }) + + it('partitions package sources, package tests, and their repository complement', () => { + expect(selectLintShard('package-sources')).toEqual({ + eslintTargets: ['packages/*/*/src/**/*.ts'], + includeDuplication: false, + }) + expect(selectLintShard('package-tests')).toEqual({ + eslintTargets: ['packages/*/*/tests/**/*.ts'], + includeDuplication: false, + }) + expect(selectLintShard('repository')).toEqual({ + eslintTargets: [ + '.', + '--ignore-pattern', + 'packages/*/*/src/**', + '--ignore-pattern', + 'packages/*/*/tests/**', + ], + includeDuplication: true, + }) + }) + + it('rejects an unknown lane', () => { + expect(() => selectLintShard('missing')).toThrow('unknown DSH_LINT_SHARD') + }) +}) diff --git a/scripts/lint-shards.ts b/scripts/lint-shards.ts new file mode 100644 index 0000000000..ec09a7aca2 --- /dev/null +++ b/scripts/lint-shards.ts @@ -0,0 +1,40 @@ +/** Lint-lane selection for GitHub Actions. */ + +/** One ESLint target set and whether it owns the cross-file duplication gate. */ +export interface LintSelection { + /** Shell-free arguments passed to ESLint before its cache options. */ + eslintTargets: readonly string[] + /** Whether this lane also runs the repository-wide duplication check. */ + includeDuplication: boolean +} + +/** + * Select an exhaustive lint partition without changing the ordinary local lint command. + * + * @param name Optional stable shard name from `DSH_LINT_SHARD`. + * @returns ESLint targets and ownership of the duplication gate. + */ +export function selectLintShard(name?: string): LintSelection { + switch (name) { + case undefined: + case '': + return { eslintTargets: ['.'], includeDuplication: true } + case 'package-sources': + return { eslintTargets: ['packages/*/*/src/**/*.ts'], includeDuplication: false } + case 'package-tests': + return { eslintTargets: ['packages/*/*/tests/**/*.ts'], includeDuplication: false } + case 'repository': + return { + eslintTargets: [ + '.', + '--ignore-pattern', + 'packages/*/*/src/**', + '--ignore-pattern', + 'packages/*/*/tests/**', + ], + includeDuplication: true, + } + default: + throw new Error(`run-gates: unknown DSH_LINT_SHARD ${JSON.stringify(name)}.`) + } +} diff --git a/scripts/run-gates.ts b/scripts/run-gates.ts index 8eade29028..262fac886f 100644 --- a/scripts/run-gates.ts +++ b/scripts/run-gates.ts @@ -9,6 +9,7 @@ import { availableParallelism } from 'node:os' import { resolve } from 'node:path' import { performance } from 'node:perf_hooks' import { coverageArgs } from './coverage-shards.ts' +import { selectLintShard } from './lint-shards.ts' import { selectStaticGates } from './static-shards.ts' type Mode = @@ -162,11 +163,13 @@ function gatesForMode(selected: Mode): Gate[] { return ciPrimaryGates() case 'ci-static': return ciStaticGates() - case 'ci-lint': + case 'ci-lint': { + const selection = selectLintShard(process.env.DSH_LINT_SHARD) return [ - lintGate(), - pnpmScript('duplication', 'duplication'), + lintGate(selection.eslintTargets), + ...selection.includeDuplication ? [pnpmScript('duplication', 'duplication')] : [], ] + } case 'ci-coverage': return [coverageGate()] case 'ci-snapshot': @@ -267,11 +270,11 @@ function ciArtifactGates(): Gate[] { return [...metadataGates, builtBinSmokeGate()] } -function lintGate(): Gate { +function lintGate(eslintTargets: readonly string[] = ['.']): Gate { if (process.env.DSH_ESLINT_CACHE === '1') { return pnpmExec('lint', [ 'eslint', - '.', + ...eslintTargets, '--cache', '--cache-location', '.cache/eslint/', diff --git a/scripts/static-shards.ts b/scripts/static-shards.ts index 4d7a63b8c9..7376aa44d7 100644 --- a/scripts/static-shards.ts +++ b/scripts/static-shards.ts @@ -23,7 +23,7 @@ export const staticShards = [ }, { name: 'api-contracts', - gateIds: ['doc-typecheck', 'export-jsdoc', 'scoped-events', 'type-equivalence'], + gateIds: ['doc-typecheck', 'cordis-api', 'export-jsdoc', 'scoped-events', 'type-equivalence'], }, { name: 'catalogs', From 25f9035ecdbd470f5c4ec85db62e9258e5e0f854 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 21 Jul 2026 21:05:39 +0800 Subject: [PATCH 04/90] ci: split remaining one-minute lanes --- .../2026-07-06-parallel-github-ci-gates.md | 6 +- .github/workflows/ci.yml | 56 ++++++++++++++----- examples/acp-agent/tests/acp.snapshot.ts | 4 ++ .../tests/snapshot-scenario-shard.spec.ts | 20 +++++++ .../tests/snapshot-scenario-shard.ts | 18 ++++++ packages/support/acp-snapshot/README.md | 2 +- packages/support/acp-snapshot/src/index.ts | 1 + .../acp-snapshot/src/scenario-shard.ts | 36 ++++++++++++ packages/support/acp-snapshot/src/suite.ts | 14 ++++- .../acp-snapshot/tests/scenario-shard.spec.ts | 25 +++++++++ .../support/acp-snapshot/tests/suite.spec.ts | 22 ++++++++ scripts/run-gates.ts | 20 ++++--- scripts/snapshot-shards.spec.ts | 45 +++++++++++++++ scripts/snapshot-shards.ts | 54 ++++++++++++++++++ scripts/static-shards.ts | 3 +- 15 files changed, 297 insertions(+), 29 deletions(-) create mode 100644 examples/acp-agent/tests/snapshot-scenario-shard.spec.ts create mode 100644 examples/acp-agent/tests/snapshot-scenario-shard.ts create mode 100644 packages/support/acp-snapshot/src/scenario-shard.ts create mode 100644 packages/support/acp-snapshot/tests/scenario-shard.spec.ts create mode 100644 scripts/snapshot-shards.spec.ts create mode 100644 scripts/snapshot-shards.ts diff --git a/.agents/notes/implemented/process/2026-07-06-parallel-github-ci-gates.md b/.agents/notes/implemented/process/2026-07-06-parallel-github-ci-gates.md index a76c425da7..a77493d8a9 100644 --- a/.agents/notes/implemented/process/2026-07-06-parallel-github-ci-gates.md +++ b/.agents/notes/implemented/process/2026-07-06-parallel-github-ci-gates.md @@ -14,15 +14,15 @@ The artifact boundary remains load-bearing. `publint`, `verify-node-next-types`, [CI](../../../../.github/workflows/ci.yml) bounds every non-Windows job to one minute and every Windows job to three minutes. The timeout is an executable regression ceiling; the lane design leaves headroom below it rather than treating a timeout as normal control flow. -[scripts/run-gates.ts](../../../../scripts/run-gates.ts) remains the common bounded scheduler, but GitHub supplies explicit shard names for the expensive gate families. [scripts/static-shards.ts](../../../../scripts/static-shards.ts) partitions static gates into foundation, API-contract, catalog, prose, and documentation-site lanes and rejects a missing or duplicate gate assignment. Lint uses disjoint package-source, package-test, and repository-complement lanes; the complement still starts from `.` so a new top-level lint target cannot disappear between shards, and it owns the single cross-file duplication run. [scripts/coverage-shards.ts](../../../../scripts/coverage-shards.ts) assigns every workspace package to exactly one source-coverage lane; its test expands the live package tree, so a new package makes CI red until it has an owner. Each coverage lane includes only its owned source files, repeats the exhaustive companion topology test, and runs without a preceding build because the complete coverage suite passes from a tree with every generated `lib/` removed. +[scripts/run-gates.ts](../../../../scripts/run-gates.ts) remains the common bounded scheduler, but GitHub supplies explicit shard names for the expensive gate families. [scripts/static-shards.ts](../../../../scripts/static-shards.ts) partitions static gates into foundation, API-contract, catalog, prose, documentation-projection, and documentation-build lanes and rejects a missing or duplicate gate assignment. Lint uses disjoint package-source, package-test, and repository-complement lanes; the complement still starts from `.` so a new top-level lint target cannot disappear between shards, and it owns the single cross-file duplication run. [scripts/coverage-shards.ts](../../../../scripts/coverage-shards.ts) assigns every workspace package to exactly one source-coverage lane; its test expands the live package tree, so a new package makes CI red until it has an owner. Each coverage lane includes only its owned source files, repeats the exhaustive companion topology test, and runs without a preceding build because the complete coverage suite passes from a tree with every generated `lib/` removed. -Snapshot replay is four Vitest file shards. Each snapshot job builds the shipped runtime while its Linux runner installs bubblewrap from the hosted image's existing package index, then runs only its assigned replay files. CI explicitly retains the suite's bounded concurrency of five subprocesses instead of clamping it to the runner's two logical CPUs, because replay spends most of its time waiting on child protocol I/O. Static, lint, coverage, and snapshot sharding changes only GitHub scheduling: the ordinary local package scripts still run their complete suites. +Snapshot replay uses three explicit multi-file lanes and four scenario partitions of the large ACP file. [scripts/snapshot-shards.ts](../../../../scripts/snapshot-shards.ts) owns that inventory, and its test discovers every file admitted by the snapshot config so a new file cannot land outside CI. Each snapshot job builds the shipped runtime while its Linux runner installs bubblewrap from the hosted image's existing package index, then runs only its assigned replay surface. CI explicitly retains the suite's bounded concurrency of five subprocesses instead of clamping it to the runner's two logical CPUs, because replay spends most of its time waiting on child protocol I/O. Fixture guards still inspect the complete ACP scenario table in every partition. Static, lint, coverage, and snapshot sharding changes only GitHub scheduling: the ordinary local package scripts still run their complete suites. Artifacts use three lanes: one metadata lane for `publint`, NodeNext declarations, and compiled invariant loading, plus two Vitest shards for built-bin smoke. Each lane produces its own build before its consumers. Repeating the short build costs runner minutes but avoids an upload/download dependency and keeps each job's critical path bounded. [scripts/publint-all.ts](../../../../scripts/publint-all.ts) calls publint's supported API in-process against an in-memory publication view made from each manifest's declared files and npm's mandatory metadata files. This preserves the distinction between workspace files and published files without spawning a package-manager pack command 103 times. [scripts/verify-built-package-invariants.mjs](../../../../scripts/verify-built-package-invariants.mjs) stages those structurally validated manifest-declared `lib/` files below the real package, then imports the compiled self-reference through plain Node and Cordis Loader normalization. A companion that reaches an undeclared runtime chunk still fails. -Compatibility lanes run the source worker and Zstandard runtime smokes on every advertised Node line. TypeScript checks the source graph once on the primary Node 24 lane; repeating the same compiler analysis on Node 22 and 26 added time without runtime-specific signal. +Compatibility lanes run the source worker and Zstandard runtime smokes on every advertised Node line. TypeScript checks the source graph once in a dedicated primary Node 24 lane; repeating the same compiler analysis in runtime compatibility jobs added time without runtime-specific signal. The workflow caches the pnpm store, preserves native PowerShell for Windows measurements, and retains one aggregate `all checks passed` status for branch protection. Windows build remains blocking; the wider Windows static, lint, and artifact matrix remains observational while carrying the same three-minute ceiling. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1ae8eb35de..d78cb52af4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -27,10 +27,10 @@ jobs: DSH_COVERAGE_MAX_WORKERS: ${{ matrix.coverage_max_workers }} DSH_COVERAGE_SHARD: ${{ matrix.coverage_shard }} DSH_LINT_SHARD: ${{ matrix.lint_shard }} + DSH_SNAPSHOT_LANE: ${{ matrix.snapshot_lane }} DSH_STATIC_SHARD: ${{ matrix.static_shard }} DSH_SNAPSHOT_MAX_CONCURRENCY: ${{ matrix.snapshot_max_concurrency }} DSH_SNAPSHOT_PREBUILT: ${{ matrix.snapshot_prebuilt }} - DSH_SNAPSHOT_SHARD: ${{ matrix.snapshot_shard }} DSH_ESLINT_CACHE: ${{ matrix.eslint_cache }} strategy: fail-fast: false @@ -52,10 +52,16 @@ jobs: command: pnpm run check:ci:static gate_concurrency: '4' static_shard: prose - - lane: static-site + - lane: static-site-projection command: pnpm run check:ci:static gate_concurrency: '1' - static_shard: site + static_shard: site-projection + - lane: static-site-build + command: pnpm run check:ci:static + gate_concurrency: '1' + static_shard: site-build + - lane: typecheck + command: pnpm run typecheck - lane: lint-package-sources command: pnpm run check:ci:lint gate_concurrency: '1' @@ -151,30 +157,48 @@ jobs: gate_concurrency: '1' coverage_max_workers: '4' coverage_shard: capabilities - - lane: snapshot-1 + - lane: snapshot-support command: pnpm run check:ci:snapshot gate_concurrency: '1' + snapshot_lane: support snapshot_max_concurrency: '5' snapshot_prebuilt: '1' - snapshot_shard: '1/4' - - lane: snapshot-2 + - lane: snapshot-demos command: pnpm run check:ci:snapshot gate_concurrency: '1' + snapshot_lane: demos snapshot_max_concurrency: '5' snapshot_prebuilt: '1' - snapshot_shard: '2/4' - - lane: snapshot-3 + - lane: snapshot-agents command: pnpm run check:ci:snapshot gate_concurrency: '1' + snapshot_lane: agents snapshot_max_concurrency: '5' snapshot_prebuilt: '1' - snapshot_shard: '3/4' - - lane: snapshot-4 + - lane: snapshot-acp-1 command: pnpm run check:ci:snapshot gate_concurrency: '1' + snapshot_lane: acp-1 + snapshot_max_concurrency: '5' + snapshot_prebuilt: '1' + - lane: snapshot-acp-2 + command: pnpm run check:ci:snapshot + gate_concurrency: '1' + snapshot_lane: acp-2 + snapshot_max_concurrency: '5' + snapshot_prebuilt: '1' + - lane: snapshot-acp-3 + command: pnpm run check:ci:snapshot + gate_concurrency: '1' + snapshot_lane: acp-3 + snapshot_max_concurrency: '5' + snapshot_prebuilt: '1' + - lane: snapshot-acp-4 + command: pnpm run check:ci:snapshot + gate_concurrency: '1' + snapshot_lane: acp-4 snapshot_max_concurrency: '5' snapshot_prebuilt: '1' - snapshot_shard: '4/4' - lane: artifacts-metadata command: pnpm run check:ci:artifacts gate_concurrency: '3' @@ -261,7 +285,7 @@ jobs: - node: '22.19' skip_typecheck: '1' - node: 24 - skip_typecheck: '' + skip_typecheck: '1' - node: 26 skip_typecheck: '1' steps: @@ -368,10 +392,14 @@ jobs: command: pnpm run check:ci:static gate_concurrency: '4' static_shard: prose - - lane: static-site + - lane: static-site-projection command: pnpm run check:ci:static gate_concurrency: '1' - static_shard: site + static_shard: site-projection + - lane: static-site-build + command: pnpm run check:ci:static + gate_concurrency: '1' + static_shard: site-build - lane: lint command: pnpm run check:ci:lint gate_concurrency: '1' diff --git a/examples/acp-agent/tests/acp.snapshot.ts b/examples/acp-agent/tests/acp.snapshot.ts index a3d939c874..485811c42f 100644 --- a/examples/acp-agent/tests/acp.snapshot.ts +++ b/examples/acp-agent/tests/acp.snapshot.ts @@ -1,6 +1,7 @@ import { fileURLToPath } from 'node:url' import { dirname, join } from 'node:path' import { defineAcpSnapshotSuite, type Scenario, type SnapshotSuiteOptions } from '@deepseek-ai/dsh-acp-snapshot' +import { snapshotScenarioShardFromEnv } from './snapshot-scenario-shard.ts' /** * The acp-agent example's snapshot suite: the scenario table for @@ -191,9 +192,12 @@ const SCENARIOS: Scenario[] = [ { name: 'fs-escalation-approved', hasModelTurn: true, recorded: true, headerClass: 'sandbox' }, ] +const scenarioShard = snapshotScenarioShardFromEnv(process.env.DSH_SNAPSHOT_SCENARIO_SHARD) + defineAcpSnapshotSuite({ agent: AGENT, snapshotsDir: join(dirname(fileURLToPath(import.meta.url)), 'snapshots'), scenarios: SCENARIOS, mode: snapshotModeFromEnv(process.env.DSH_SNAPSHOT), + ...scenarioShard === undefined ? {} : { scenarioShard }, }) diff --git a/examples/acp-agent/tests/snapshot-scenario-shard.spec.ts b/examples/acp-agent/tests/snapshot-scenario-shard.spec.ts new file mode 100644 index 0000000000..5a70cfe3b1 --- /dev/null +++ b/examples/acp-agent/tests/snapshot-scenario-shard.spec.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from 'vitest' +import { snapshotScenarioShardFromEnv } from './snapshot-scenario-shard.ts' + +describe('ACP snapshot scenario shard environment', () => { + it('keeps ordinary snapshot runs complete', () => { + expect(snapshotScenarioShardFromEnv()).toBeUndefined() + expect(snapshotScenarioShardFromEnv('')).toBeUndefined() + }) + + it('parses a valid one-based shard', () => { + expect(snapshotScenarioShardFromEnv('2/4')).toEqual({ index: 2, total: 4 }) + }) + + it.each(['0/1', '1/0', '2/1', '1.5/2', 'missing', '999999999999999999999/999999999999999999999'])( + 'rejects %s', + (value) => { + expect(() => snapshotScenarioShardFromEnv(value)).toThrow('DSH_SNAPSHOT_SCENARIO_SHARD') + }, + ) +}) diff --git a/examples/acp-agent/tests/snapshot-scenario-shard.ts b/examples/acp-agent/tests/snapshot-scenario-shard.ts new file mode 100644 index 0000000000..4e2763bdb6 --- /dev/null +++ b/examples/acp-agent/tests/snapshot-scenario-shard.ts @@ -0,0 +1,18 @@ +import type { SnapshotScenarioShard } from '@deepseek-ai/dsh-acp-snapshot' + +/** + * Parse the optional CI scenario shard passed to this snapshot suite. + * + * @param value An `INDEX/TOTAL` string or an unset value. + * @returns A validated one-based shard, or undefined for the complete suite. + */ +export function snapshotScenarioShardFromEnv(value?: string): SnapshotScenarioShard | undefined { + if (value === undefined || value === '') return undefined + const match = /^([1-9]\d*)\/([1-9]\d*)$/.exec(value) + if (match === null) throw new Error(`DSH_SNAPSHOT_SCENARIO_SHARD must be INDEX/TOTAL, got ${JSON.stringify(value)}`) + const shard = { index: Number(match[1]), total: Number(match[2]) } + if (!Number.isSafeInteger(shard.index) || !Number.isSafeInteger(shard.total) || shard.index > shard.total) { + throw new Error(`DSH_SNAPSHOT_SCENARIO_SHARD is out of range: ${JSON.stringify(value)}`) + } + return shard +} diff --git a/packages/support/acp-snapshot/README.md b/packages/support/acp-snapshot/README.md index b109ca3afa..2766240135 100644 --- a/packages/support/acp-snapshot/README.md +++ b/packages/support/acp-snapshot/README.md @@ -7,7 +7,7 @@ Four layers, importable separately: - **`launchAcpTestAgent` (launcher)** — boots an unbuilt ACP agent from a temp cwd, pins tsx to the repo tsconfig, connects the SDK client over a raw-byte stdout tee, collects session updates and stderr, surfaces asynchronous spawn failures through its startup lifecycle, fails closed on unhandled permission requests, and owns graceful or signalled shutdown. Shutdown waits for process exit, inherited stdio closure, and ACP parser exhaustion before resolving or propagating a child error, so captures are complete and callers can remove owned paths after either outcome. Snapshot and ordinary e2e suites share this process boundary; a test supplies only agent paths, cwd, environment overrides, and any permission policy. - **`runScenario` (harness)** — boots the real agent bin as a subprocess via tsx (unbuilt, Loader path), drives it over ACP JSON-RPC stdio from a deterministic `input.json` script, tees raw stdout for the expected-output and purity checks, and harvests every persisted session JSONL (parent + subagent children, primary-first) after a graceful stdin-EOF shutdown. Parameterized by `AgentUnderTest` (`binScript`, `configPath`, `tsconfigPath` — absolute paths; the subprocess cwd is a temp dir outside the repo). Startup failures preserve captured agent stderr in the rejected diagnostic. - **Normalizers** — pure functions turning the two captured surfaces into stable text: `normalizeStdout` (JSON-RPC ids → first-seen sequence; UUIDs/cwd → tokens; doubles as the stdout-purity check), `normalizeSessionLog` (times zeroed, `seq` kept), `scrubSystemPrompts` (prompt text → `{{system}}`), `scrubToolSchemas` (schema bulk → `{{tools}}`), and `scrubRequestHeaders` (all header bulk → `{{system}}`/`{{tools}}`/`{{messagePrefix}}` outside each pin, structure kept — [pinned-header Agent Note](../../../.agents/notes/implemented/testing/2026-07-06-pin-request-header-content-in-one-scenario.md)). -- **`defineAcpSnapshotSuite` (factory)** — registers the whole describe/it tree for a scenario table: per-scenario expected-output and re-persisted-log comparisons, record/refresh fixture write-back, rejection of structured `UNKNOWN_TOOL` results, the per-header-class pin (`system-prompt.expected.md` plus `tool-schemas.expected.json`) with its live uniformity guard, and the fixture guard block (no orphan scenario dirs, required files present, exactly one pin per class, every JSONL prompt/schema-scrubbed, non-pinning fixtures fully header-scrubbed). Each scenario directory's `session.jsonl` plus contiguous `session..jsonl` siblings are the ordered primary/child inventory; the scenario table does not duplicate their count. Must be called at vitest collection time. +- **`defineAcpSnapshotSuite` (factory)** — registers the whole describe/it tree for a scenario table: per-scenario expected-output and re-persisted-log comparisons, record/refresh fixture write-back, rejection of structured `UNKNOWN_TOOL` results, the per-header-class pin (`system-prompt.expected.md` plus `tool-schemas.expected.json`) with its live uniformity guard, and the fixture guard block (no orphan scenario dirs, required files present, exactly one pin per class, every JSONL prompt/schema-scrubbed, non-pinning fixtures fully header-scrubbed). Replay may partition subprocess-backed scenarios with `scenarioShard`; every lane still runs fixture guards against the complete table, while record and refresh reject sharding because they write fixtures. Each scenario directory's `session.jsonl` plus contiguous `session..jsonl` siblings are the ordered primary/child inventory; the scenario table does not duplicate their count. Must be called at vitest collection time. A consuming `*.snapshot.ts` is the scenario table plus one factory call: diff --git a/packages/support/acp-snapshot/src/index.ts b/packages/support/acp-snapshot/src/index.ts index 4d99cc96a2..6dd87b7cab 100644 --- a/packages/support/acp-snapshot/src/index.ts +++ b/packages/support/acp-snapshot/src/index.ts @@ -44,3 +44,4 @@ export { type Scenario, type SnapshotSuiteOptions, } from './suite.ts' +export type { SnapshotScenarioShard } from './scenario-shard.ts' diff --git a/packages/support/acp-snapshot/src/scenario-shard.ts b/packages/support/acp-snapshot/src/scenario-shard.ts new file mode 100644 index 0000000000..67b2492b80 --- /dev/null +++ b/packages/support/acp-snapshot/src/scenario-shard.ts @@ -0,0 +1,36 @@ +/** Scenario-level sharding for one ACP snapshot suite. */ + +/** A one-based, exhaustive partition of a scenario table. */ +export interface SnapshotScenarioShard { + /** One-based lane index. */ + index: number + /** Total number of lanes. */ + total: number +} + +/** + * Select one stable modulo partition while rejecting empty or malformed lanes. + * + * @param scenarios Complete ordered scenario table. + * @param shard Optional one-based shard description. + * @returns The complete table or the selected non-empty partition. + */ +export function selectSnapshotScenarios( + scenarios: readonly T[], + shard?: SnapshotScenarioShard, +): T[] { + if (shard === undefined) return [...scenarios] + if (!Number.isSafeInteger(shard.index) || shard.index < 1) { + throw new Error(`acp-snapshot: shard index must be a positive integer, got ${shard.index}`) + } + if (!Number.isSafeInteger(shard.total) || shard.total < 1) { + throw new Error(`acp-snapshot: shard total must be a positive integer, got ${shard.total}`) + } + if (shard.index > shard.total) { + throw new Error(`acp-snapshot: shard index ${shard.index} exceeds total ${shard.total}`) + } + if (shard.total > scenarios.length) { + throw new Error(`acp-snapshot: ${shard.total} shards exceed ${scenarios.length} scenarios`) + } + return scenarios.filter((_, offset) => offset % shard.total === shard.index - 1) +} diff --git a/packages/support/acp-snapshot/src/suite.ts b/packages/support/acp-snapshot/src/suite.ts index 60f5cfeadb..1c2d16b630 100644 --- a/packages/support/acp-snapshot/src/suite.ts +++ b/packages/support/acp-snapshot/src/suite.ts @@ -28,6 +28,7 @@ import { scrubSystemPrompts, scrubToolSchemas, } from './normalize.ts' +import { selectSnapshotScenarios, type SnapshotScenarioShard } from './scenario-shard.ts' /** The readable system-prompt snapshot beside each header-pinning fixture. */ const SYSTEM_PROMPT_SNAPSHOT = 'system-prompt.expected.md' @@ -110,6 +111,11 @@ export interface SnapshotSuiteOptions { snapshotsDir: string /** The scenario table; exactly one entry per header class must set `pinsHeader`. */ scenarios: Scenario[] + /** + * Optional replay-only scenario partition. Fixture guards still validate the + * complete table in every lane; only subprocess-backed scenario tests split. + */ + scenarioShard?: SnapshotScenarioShard /** * `replay` (keyless, the default tier), `record` (live API; re-records the * `recorded` scenarios' fixtures and refreshes the Vitest expected outputs under @@ -439,7 +445,11 @@ export function stabilizeRefreshLog(fresh: string, existing: string, replacement * @param options The agent, snapshots directory, scenario table, and mode. */ export function defineAcpSnapshotSuite(options: SnapshotSuiteOptions): void { - const { agent, snapshotsDir, scenarios, mode } = options + const { agent, snapshotsDir, scenarios, mode, scenarioShard } = options + if (scenarioShard !== undefined && mode !== 'replay') { + throw new Error('acp-snapshot: scenario sharding is supported only in replay mode') + } + const selectedScenarios = selectSnapshotScenarios(scenarios, scenarioShard) const RECORDING = mode === 'record' const REFRESHING = mode === 'refresh' const childMode: 'replay' | 'record' = RECORDING ? 'record' : 'replay' @@ -464,7 +474,7 @@ export function defineAcpSnapshotSuite(options: SnapshotSuiteOptions): void { } scenarioSuite('snapshot scenarios', () => { - for (const scenario of scenarios) { + for (const scenario of selectedScenarios) { // In RECORD mode, only re-run the `recorded` (live-API) scenarios; the `authored` ones // (sidecar-driven errors/cancel) are never re-recorded. it.skipIf(RECORDING && !scenario.recorded)(`snapshot: ${scenario.name} matches the expected outputs`, async ({ expect }) => { diff --git a/packages/support/acp-snapshot/tests/scenario-shard.spec.ts b/packages/support/acp-snapshot/tests/scenario-shard.spec.ts new file mode 100644 index 0000000000..eb5c117153 --- /dev/null +++ b/packages/support/acp-snapshot/tests/scenario-shard.spec.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from 'vitest' +import { selectSnapshotScenarios } from '../src/scenario-shard.ts' + +describe('ACP snapshot scenario shards', () => { + it('keeps the ordinary suite complete', () => { + expect(selectSnapshotScenarios(['a', 'b', 'c'])).toEqual(['a', 'b', 'c']) + }) + + it('partitions the ordered table without gaps or overlap', () => { + const scenarios = ['a', 'b', 'c', 'd', 'e'] + expect(selectSnapshotScenarios(scenarios, { index: 1, total: 2 })).toEqual(['a', 'c', 'e']) + expect(selectSnapshotScenarios(scenarios, { index: 2, total: 2 })).toEqual(['b', 'd']) + }) + + it.each([ + [{ index: 0, total: 1 }, 'index must be a positive integer'], + [{ index: 1.5, total: 2 }, 'index must be a positive integer'], + [{ index: 1, total: 0 }, 'total must be a positive integer'], + [{ index: 1, total: Number.NaN }, 'total must be a positive integer'], + [{ index: 3, total: 2 }, 'exceeds total'], + [{ index: 1, total: 4 }, 'exceed 3 scenarios'], + ] as const)('rejects malformed shard %#', (shard, message) => { + expect(() => selectSnapshotScenarios(['a', 'b', 'c'], shard)).toThrow(message) + }) +}) diff --git a/packages/support/acp-snapshot/tests/suite.spec.ts b/packages/support/acp-snapshot/tests/suite.spec.ts index 6fbb06bb9d..33fff5d2bc 100644 --- a/packages/support/acp-snapshot/tests/suite.spec.ts +++ b/packages/support/acp-snapshot/tests/suite.spec.ts @@ -105,6 +105,16 @@ describe('defineAcpSnapshotSuite: replay mode', () => { defineAcpSnapshotSuite({ agent: AGENT, snapshotsDir: REPLAY_DIR, scenarios: REPLAY_SCENARIOS, mode: 'replay' }) }) +describe('defineAcpSnapshotSuite: sharded replay mode', () => { + defineAcpSnapshotSuite({ + agent: AGENT, + snapshotsDir: REPLAY_DIR, + scenarios: REPLAY_SCENARIOS, + mode: 'replay', + scenarioShard: { index: 2, total: 2 }, + }) +}) + // The record suite's tests run in registration order: rec-pin re-records the // pinned fixture FIRST, so rec-child's uniformity guard reads the fresh pin. describe('defineAcpSnapshotSuite: record mode', () => { @@ -154,6 +164,18 @@ describe('defineAcpSnapshotSuite: record inventory write-back', () => { }) describe('defineAcpSnapshotSuite: registration contract', () => { + it('rejects scenario sharding in a fixture-writing mode', () => { + expect(() => { + defineAcpSnapshotSuite({ + agent: AGENT, + snapshotsDir: REPLAY_DIR, + scenarios: REPLAY_SCENARIOS, + mode: 'refresh', + scenarioShard: { index: 1, total: 2 }, + }) + }).toThrow('supported only in replay mode') + }) + it("throws when a scenario's header class has no pinning scenario", () => { expect(() => { defineAcpSnapshotSuite({ diff --git a/scripts/run-gates.ts b/scripts/run-gates.ts index 262fac886f..e45a254905 100644 --- a/scripts/run-gates.ts +++ b/scripts/run-gates.ts @@ -10,6 +10,7 @@ import { resolve } from 'node:path' import { performance } from 'node:perf_hooks' import { coverageArgs } from './coverage-shards.ts' import { selectLintShard } from './lint-shards.ts' +import { selectSnapshotLane } from './snapshot-shards.ts' import { selectStaticGates } from './static-shards.ts' type Mode = @@ -307,19 +308,19 @@ function coverageGate(): Gate { // plugins via real exports) — CI and pre-push already build, so they exercise what ships rather // than the tsx/source path dev uses. It therefore waits on `build`. function snapshotGate(needs: string[] = ['build']): Gate { - const shard = process.env.DSH_SNAPSHOT_SHARD - if (shard !== undefined && shard !== '' && !/^\d+\/\d+$/.test(shard)) { - throw new Error(`run-gates: DSH_SNAPSHOT_SHARD must be INDEX/TOTAL, got ${JSON.stringify(shard)}.`) - } + const lane = selectSnapshotLane(process.env.DSH_SNAPSHOT_LANE) return pnpmExec('snapshot', [ 'vitest', 'run', '--config', 'vitest.snapshot.config.ts', - ...(shard === undefined || shard === '' ? [] : [`--shard=${shard}`]), + ...lane.files, ], { label: 'test:snapshot', - env: { DSH_EXAMPLE_MODE: 'lib' }, + env: { + DSH_EXAMPLE_MODE: 'lib', + ...lane.scenarioShard === undefined ? {} : { DSH_SNAPSHOT_SCENARIO_SHARD: lane.scenarioShard }, + }, ...needs.length === 0 ? {} : { needs }, }) } @@ -392,8 +393,11 @@ function docSyncLeafGates(options: { pnpmScript('translation-prompt', 'verify-translation-prompt', { label: 'translation prompt' }), pnpmScript('translation-pairing', 'verify-translation-pairing', { label: 'translation pairing' }), pnpmScript('doc-budgets', 'verify-doc-budgets', { label: 'doc budgets' }), - // Keep the VitePress build in this single gate because projection rewrites website/.generated. - pnpmScript('docs-site', 'docs:check', { label: 'documentation site' }), + pnpmExec('docs-site-projection', ['vitest', 'run', 'scripts/project-doc-site.spec.ts'], { + label: 'documentation projection', + }), + // Keep the VitePress build itself in one gate because projection rewrites website/.generated. + pnpmScript('docs-site-build', 'docs:build', { label: 'documentation build' }), pnpmScript('package-readme-limitations', 'verify-package-readme-limitations', { label: 'package README limitations' }), ] } diff --git a/scripts/snapshot-shards.spec.ts b/scripts/snapshot-shards.spec.ts new file mode 100644 index 0000000000..dc4bef62ad --- /dev/null +++ b/scripts/snapshot-shards.spec.ts @@ -0,0 +1,45 @@ +import { existsSync, readdirSync } from 'node:fs' +import { join, relative, sep } from 'node:path' +import { describe, expect, it } from 'vitest' +import { selectSnapshotLane, snapshotLanes } from './snapshot-shards.ts' + +const root = join(import.meta.dirname, '..') + +function snapshotFiles(dir: string): string[] { + if (!existsSync(dir)) return [] + return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => { + const path = join(dir, entry.name) + if (entry.isDirectory()) return snapshotFiles(path) + return entry.name.endsWith('.snapshot.ts') ? [relative(root, path).split(sep).join('/')] : [] + }) +} + +describe('snapshot lanes', () => { + it('assigns every configured snapshot file and every ACP scenario shard', () => { + const discovered = [ + ...snapshotFiles(join(root, 'examples')), + ...snapshotFiles(join(root, 'packages/sdk')), + ...snapshotFiles(join(root, 'packages/ui/tui')), + ].filter(path => !path.includes('/node_modules/') && !path.includes('/lib/')).sort() + const ordinary = snapshotLanes.filter(lane => lane.scenarioShard === undefined).flatMap(lane => lane.files) + const acp = snapshotLanes.filter(lane => lane.scenarioShard !== undefined) + + expect(new Set(ordinary).size).toBe(ordinary.length) + expect(acp.map(lane => lane.files)).toEqual(Array.from( + { length: 4 }, + () => ['examples/acp-agent/tests/acp.snapshot.ts'], + )) + expect(acp.map(lane => lane.scenarioShard)).toEqual(['1/4', '2/4', '3/4', '4/4']) + expect([...ordinary, 'examples/acp-agent/tests/acp.snapshot.ts'].sort()).toEqual(discovered) + }) + + it('keeps ordinary runs complete and selects known lanes', () => { + expect(selectSnapshotLane()).toEqual({ name: 'complete', files: [] }) + expect(selectSnapshotLane('')).toEqual({ name: 'complete', files: [] }) + for (const lane of snapshotLanes) expect(selectSnapshotLane(lane.name)).toBe(lane) + }) + + it('rejects an unknown lane', () => { + expect(() => selectSnapshotLane('missing')).toThrow('unknown DSH_SNAPSHOT_LANE') + }) +}) diff --git a/scripts/snapshot-shards.ts b/scripts/snapshot-shards.ts new file mode 100644 index 0000000000..a6126f23c2 --- /dev/null +++ b/scripts/snapshot-shards.ts @@ -0,0 +1,54 @@ +/** Snapshot-lane definitions for GitHub Actions. */ + +/** One explicit snapshot file lane, optionally split again by ACP scenarios. */ +export interface SnapshotLane { + /** Stable lane name passed through `DSH_SNAPSHOT_LANE`. */ + name: string + /** Snapshot test files owned by the lane. */ + files: readonly string[] + /** Optional one-based ACP scenario partition. */ + scenarioShard?: string +} + +/** Exhaustive file ownership plus scenario partitions for the large ACP suite. */ +export const snapshotLanes: readonly SnapshotLane[] = [ + { + name: 'support', + files: [ + 'packages/sdk/scripts/tests/config.snapshot.ts', + 'packages/ui/tui/tests/tui.snapshot.ts', + ], + }, + { + name: 'demos', + files: [ + 'examples/tui-agent/tests/tui.snapshot.ts', + 'packages/sdk/create-sdk/tests/create.snapshot.ts', + ], + }, + { + name: 'agents', + files: [ + 'examples/acp-agent/tests/goal.snapshot.ts', + 'examples/headless-agent/tests/headless.snapshot.ts', + ], + }, + ...Array.from({ length: 4 }, (_, offset) => ({ + name: `acp-${offset + 1}`, + files: ['examples/acp-agent/tests/acp.snapshot.ts'], + scenarioShard: `${offset + 1}/4`, + })), +] + +/** + * Resolve one CI lane while preserving a complete ordinary snapshot run. + * + * @param name Optional stable lane name. + * @returns An empty file list for the full suite, or one explicit CI lane. + */ +export function selectSnapshotLane(name?: string): SnapshotLane { + if (name === undefined || name === '') return { name: 'complete', files: [] } + const lane = snapshotLanes.find(candidate => candidate.name === name) + if (lane === undefined) throw new Error(`run-gates: unknown DSH_SNAPSHOT_LANE ${JSON.stringify(name)}.`) + return lane +} diff --git a/scripts/static-shards.ts b/scripts/static-shards.ts index 7376aa44d7..4133470d87 100644 --- a/scripts/static-shards.ts +++ b/scripts/static-shards.ts @@ -46,7 +46,8 @@ export const staticShards = [ 'package-readme-limitations', ], }, - { name: 'site', gateIds: ['docs-site'] }, + { name: 'site-projection', gateIds: ['docs-site-projection'] }, + { name: 'site-build', gateIds: ['docs-site-build'] }, ] as const satisfies readonly StaticShard[] /** From 7813dbcf0f227c7a7c1807e02a35c9b52a796bb8 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 21 Jul 2026 21:14:55 +0800 Subject: [PATCH 05/90] ci: bound hosted fanout --- .../2026-07-06-parallel-github-ci-gates.md | 4 +- .github/workflows/ci.yml | 70 ++++--------------- scripts/coverage-shards.ts | 17 ++--- scripts/run-gates.ts | 3 +- scripts/snapshot-shards.spec.ts | 4 +- scripts/snapshot-shards.ts | 13 ++-- 6 files changed, 26 insertions(+), 85 deletions(-) diff --git a/.agents/notes/implemented/process/2026-07-06-parallel-github-ci-gates.md b/.agents/notes/implemented/process/2026-07-06-parallel-github-ci-gates.md index a77493d8a9..5321db9bd7 100644 --- a/.agents/notes/implemented/process/2026-07-06-parallel-github-ci-gates.md +++ b/.agents/notes/implemented/process/2026-07-06-parallel-github-ci-gates.md @@ -16,9 +16,9 @@ The artifact boundary remains load-bearing. `publint`, `verify-node-next-types`, [scripts/run-gates.ts](../../../../scripts/run-gates.ts) remains the common bounded scheduler, but GitHub supplies explicit shard names for the expensive gate families. [scripts/static-shards.ts](../../../../scripts/static-shards.ts) partitions static gates into foundation, API-contract, catalog, prose, documentation-projection, and documentation-build lanes and rejects a missing or duplicate gate assignment. Lint uses disjoint package-source, package-test, and repository-complement lanes; the complement still starts from `.` so a new top-level lint target cannot disappear between shards, and it owns the single cross-file duplication run. [scripts/coverage-shards.ts](../../../../scripts/coverage-shards.ts) assigns every workspace package to exactly one source-coverage lane; its test expands the live package tree, so a new package makes CI red until it has an owner. Each coverage lane includes only its owned source files, repeats the exhaustive companion topology test, and runs without a preceding build because the complete coverage suite passes from a tree with every generated `lib/` removed. -Snapshot replay uses three explicit multi-file lanes and four scenario partitions of the large ACP file. [scripts/snapshot-shards.ts](../../../../scripts/snapshot-shards.ts) owns that inventory, and its test discovers every file admitted by the snapshot config so a new file cannot land outside CI. Each snapshot job builds the shipped runtime while its Linux runner installs bubblewrap from the hosted image's existing package index, then runs only its assigned replay surface. CI explicitly retains the suite's bounded concurrency of five subprocesses instead of clamping it to the runner's two logical CPUs, because replay spends most of its time waiting on child protocol I/O. Fixture guards still inspect the complete ACP scenario table in every partition. Static, lint, coverage, and snapshot sharding changes only GitHub scheduling: the ordinary local package scripts still run their complete suites. +Snapshot replay uses two explicit multi-file lanes and two scenario partitions of the large ACP file. [scripts/snapshot-shards.ts](../../../../scripts/snapshot-shards.ts) owns that inventory, and its test discovers every file admitted by the snapshot config so a new file cannot land outside CI. Each snapshot job builds the shipped runtime while its Linux runner installs bubblewrap from the hosted image's existing package index, then runs only its assigned replay surface. CI explicitly retains the suite's bounded concurrency of five subprocesses instead of clamping it to the runner's two logical CPUs, because replay spends most of its time waiting on child protocol I/O. Fixture guards still inspect the complete ACP scenario table in every partition. Static, lint, coverage, and snapshot sharding changes only GitHub scheduling: the ordinary local package scripts still run their complete suites. -Artifacts use three lanes: one metadata lane for `publint`, NodeNext declarations, and compiled invariant loading, plus two Vitest shards for built-bin smoke. Each lane produces its own build before its consumers. Repeating the short build costs runner minutes but avoids an upload/download dependency and keeps each job's critical path bounded. +Artifacts use two lanes: one metadata lane for `publint`, NodeNext declarations, and compiled invariant loading, plus one built-bin smoke lane. Each lane produces its own build before its consumers. Repeating the short build costs runner minutes but avoids an upload/download dependency and keeps each job's critical path bounded. [scripts/publint-all.ts](../../../../scripts/publint-all.ts) calls publint's supported API in-process against an in-memory publication view made from each manifest's declared files and npm's mandatory metadata files. This preserves the distinction between workspace files and published files without spawning a package-manager pack command 103 times. [scripts/verify-built-package-invariants.mjs](../../../../scripts/verify-built-package-invariants.mjs) stages those structurally validated manifest-declared `lib/` files below the real package, then imports the compiled self-reference through plain Node and Cordis Loader normalization. A companion that reaches an undeclared runtime chunk still fails. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d78cb52af4..cb08cefae7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -82,16 +82,16 @@ jobs: gate_concurrency: '1' coverage_max_workers: '4' coverage_shard: core - - lane: coverage-models + - lane: coverage-models-protocol command: pnpm run check:ci:coverage gate_concurrency: '1' coverage_max_workers: '4' - coverage_shard: models - - lane: coverage-sdk + coverage_shard: models-protocol + - lane: coverage-sdk-codex command: pnpm run check:ci:coverage gate_concurrency: '1' coverage_max_workers: '4' - coverage_shard: sdk + coverage_shard: sdk-codex - lane: coverage-interfaces command: pnpm run check:ci:coverage gate_concurrency: '1' @@ -122,36 +122,16 @@ jobs: gate_concurrency: '1' coverage_max_workers: '4' coverage_shard: repository - - lane: coverage-scripts + - lane: coverage-integrations-claude command: pnpm run check:ci:coverage gate_concurrency: '1' coverage_max_workers: '4' - coverage_shard: scripts - - lane: coverage-integrations + coverage_shard: integrations-claude + - lane: coverage-session-scripts command: pnpm run check:ci:coverage gate_concurrency: '1' coverage_max_workers: '4' - coverage_shard: integrations - - lane: coverage-session-state - command: pnpm run check:ci:coverage - gate_concurrency: '1' - coverage_max_workers: '4' - coverage_shard: session-state - - lane: coverage-hooks-claude - command: pnpm run check:ci:coverage - gate_concurrency: '1' - coverage_max_workers: '4' - coverage_shard: hooks-claude - - lane: coverage-hook-protocol - command: pnpm run check:ci:coverage - gate_concurrency: '1' - coverage_max_workers: '4' - coverage_shard: hook-protocol - - lane: coverage-hooks-codex - command: pnpm run check:ci:coverage - gate_concurrency: '1' - coverage_max_workers: '4' - coverage_shard: hooks-codex + coverage_shard: session-scripts - lane: coverage-capabilities command: pnpm run check:ci:coverage gate_concurrency: '1' @@ -163,12 +143,6 @@ jobs: snapshot_lane: support snapshot_max_concurrency: '5' snapshot_prebuilt: '1' - - lane: snapshot-demos - command: pnpm run check:ci:snapshot - gate_concurrency: '1' - snapshot_lane: demos - snapshot_max_concurrency: '5' - snapshot_prebuilt: '1' - lane: snapshot-agents command: pnpm run check:ci:snapshot gate_concurrency: '1' @@ -187,31 +161,15 @@ jobs: snapshot_lane: acp-2 snapshot_max_concurrency: '5' snapshot_prebuilt: '1' - - lane: snapshot-acp-3 - command: pnpm run check:ci:snapshot - gate_concurrency: '1' - snapshot_lane: acp-3 - snapshot_max_concurrency: '5' - snapshot_prebuilt: '1' - - lane: snapshot-acp-4 - command: pnpm run check:ci:snapshot - gate_concurrency: '1' - snapshot_lane: acp-4 - snapshot_max_concurrency: '5' - snapshot_prebuilt: '1' - lane: artifacts-metadata command: pnpm run check:ci:artifacts gate_concurrency: '3' publint_concurrency: '8' artifact_shard: metadata - - lane: artifacts-smoke-1 + - lane: artifacts-smoke command: pnpm run check:ci:artifacts gate_concurrency: '1' - artifact_shard: smoke-1 - - lane: artifacts-smoke-2 - command: pnpm run check:ci:artifacts - gate_concurrency: '1' - artifact_shard: smoke-2 + artifact_shard: smoke steps: - uses: actions/checkout@v6 @@ -409,14 +367,10 @@ jobs: gate_concurrency: '3' publint_concurrency: '8' artifact_shard: metadata - - lane: artifacts-smoke-1 + - lane: artifacts-smoke command: pnpm run check:ci:artifacts gate_concurrency: '1' - artifact_shard: smoke-1 - - lane: artifacts-smoke-2 - command: pnpm run check:ci:artifacts - gate_concurrency: '1' - artifact_shard: smoke-2 + artifact_shard: smoke steps: - uses: actions/checkout@v6 diff --git a/scripts/coverage-shards.ts b/scripts/coverage-shards.ts index 5ac8f999f3..f866bbb04d 100644 --- a/scripts/coverage-shards.ts +++ b/scripts/coverage-shards.ts @@ -17,8 +17,8 @@ export const coverageShards = [ packageRoots: ['core', 'context'], extraTestRoots: ['packages/examples/cli-demo/tests'], }, - { name: 'models', packageRoots: ['llm', 'compact'] }, - { name: 'sdk', packageRoots: ['sdk'] }, + { name: 'models-protocol', packageRoots: ['llm', 'compact', 'hooks/hook-protocol'] }, + { name: 'sdk-codex', packageRoots: ['sdk', 'hooks/hooks-codex'] }, { name: 'interfaces', packageRoots: ['ui', 'examples', 'goal'], @@ -42,18 +42,11 @@ export const coverageShards = [ ], }, { - name: 'scripts', - packageRoots: ['support/invariants'], + name: 'session-scripts', + packageRoots: ['session-persistence', 'session-query', 'support/invariants'], extraTestRoots: ['scripts'], }, - { name: 'integrations', packageRoots: ['lsp', 'mcp'] }, - { - name: 'session-state', - packageRoots: ['session-persistence', 'session-query'], - }, - { name: 'hook-protocol', packageRoots: ['hooks/hook-protocol'] }, - { name: 'hooks-claude', packageRoots: ['hooks/hooks-claude'] }, - { name: 'hooks-codex', packageRoots: ['hooks/hooks-codex'] }, + { name: 'integrations-claude', packageRoots: ['lsp', 'mcp', 'hooks/hooks-claude'] }, { name: 'capabilities', packageRoots: ['web', 'skill', 'spill', 'util', 'guard', 'todo', 'timeout'], diff --git a/scripts/run-gates.ts b/scripts/run-gates.ts index e45a254905..efd94c92a0 100644 --- a/scripts/run-gates.ts +++ b/scripts/run-gates.ts @@ -263,8 +263,7 @@ function ciArtifactGates(): Gate[] { builtPackageInvariantsGate(['build']), ] if (shard === 'metadata') return metadataGates - if (shard === 'smoke-1') return [pnpmScript('build', 'build'), builtBinSmokeGate('1/2')] - if (shard === 'smoke-2') return [pnpmScript('build', 'build'), builtBinSmokeGate('2/2')] + if (shard === 'smoke') return [pnpmScript('build', 'build'), builtBinSmokeGate()] if (shard !== undefined && shard !== '') { throw new Error(`run-gates: unknown DSH_ARTIFACT_SHARD ${JSON.stringify(shard)}.`) } diff --git a/scripts/snapshot-shards.spec.ts b/scripts/snapshot-shards.spec.ts index dc4bef62ad..9f3c4e7cbd 100644 --- a/scripts/snapshot-shards.spec.ts +++ b/scripts/snapshot-shards.spec.ts @@ -26,10 +26,10 @@ describe('snapshot lanes', () => { expect(new Set(ordinary).size).toBe(ordinary.length) expect(acp.map(lane => lane.files)).toEqual(Array.from( - { length: 4 }, + { length: 2 }, () => ['examples/acp-agent/tests/acp.snapshot.ts'], )) - expect(acp.map(lane => lane.scenarioShard)).toEqual(['1/4', '2/4', '3/4', '4/4']) + expect(acp.map(lane => lane.scenarioShard)).toEqual(['1/2', '2/2']) expect([...ordinary, 'examples/acp-agent/tests/acp.snapshot.ts'].sort()).toEqual(discovered) }) diff --git a/scripts/snapshot-shards.ts b/scripts/snapshot-shards.ts index a6126f23c2..3106297a2e 100644 --- a/scripts/snapshot-shards.ts +++ b/scripts/snapshot-shards.ts @@ -16,27 +16,22 @@ export const snapshotLanes: readonly SnapshotLane[] = [ name: 'support', files: [ 'packages/sdk/scripts/tests/config.snapshot.ts', - 'packages/ui/tui/tests/tui.snapshot.ts', - ], - }, - { - name: 'demos', - files: [ - 'examples/tui-agent/tests/tui.snapshot.ts', 'packages/sdk/create-sdk/tests/create.snapshot.ts', + 'packages/ui/tui/tests/tui.snapshot.ts', ], }, { name: 'agents', files: [ + 'examples/tui-agent/tests/tui.snapshot.ts', 'examples/acp-agent/tests/goal.snapshot.ts', 'examples/headless-agent/tests/headless.snapshot.ts', ], }, - ...Array.from({ length: 4 }, (_, offset) => ({ + ...Array.from({ length: 2 }, (_, offset) => ({ name: `acp-${offset + 1}`, files: ['examples/acp-agent/tests/acp.snapshot.ts'], - scenarioShard: `${offset + 1}/4`, + scenarioShard: `${offset + 1}/2`, })), ] From ca0c9a7c48ec7f97ecc228e20e49ebfca35cf5b4 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 21 Jul 2026 21:28:49 +0800 Subject: [PATCH 06/90] ci: restore hosted lane headroom --- .../2026-07-06-parallel-github-ci-gates.md | 4 ++- .github/workflows/ci.yml | 31 +++++++++++-------- package.json | 1 + scripts/run-gates.ts | 10 ++++-- scripts/snapshot-shards.spec.ts | 4 +-- scripts/snapshot-shards.ts | 11 ++----- scripts/static-shards.ts | 2 +- 7 files changed, 36 insertions(+), 27 deletions(-) diff --git a/.agents/notes/implemented/process/2026-07-06-parallel-github-ci-gates.md b/.agents/notes/implemented/process/2026-07-06-parallel-github-ci-gates.md index 5321db9bd7..22d3b1ad5c 100644 --- a/.agents/notes/implemented/process/2026-07-06-parallel-github-ci-gates.md +++ b/.agents/notes/implemented/process/2026-07-06-parallel-github-ci-gates.md @@ -16,7 +16,9 @@ The artifact boundary remains load-bearing. `publint`, `verify-node-next-types`, [scripts/run-gates.ts](../../../../scripts/run-gates.ts) remains the common bounded scheduler, but GitHub supplies explicit shard names for the expensive gate families. [scripts/static-shards.ts](../../../../scripts/static-shards.ts) partitions static gates into foundation, API-contract, catalog, prose, documentation-projection, and documentation-build lanes and rejects a missing or duplicate gate assignment. Lint uses disjoint package-source, package-test, and repository-complement lanes; the complement still starts from `.` so a new top-level lint target cannot disappear between shards, and it owns the single cross-file duplication run. [scripts/coverage-shards.ts](../../../../scripts/coverage-shards.ts) assigns every workspace package to exactly one source-coverage lane; its test expands the live package tree, so a new package makes CI red until it has an owner. Each coverage lane includes only its owned source files, repeats the exhaustive companion topology test, and runs without a preceding build because the complete coverage suite passes from a tree with every generated `lib/` removed. -Snapshot replay uses two explicit multi-file lanes and two scenario partitions of the large ACP file. [scripts/snapshot-shards.ts](../../../../scripts/snapshot-shards.ts) owns that inventory, and its test discovers every file admitted by the snapshot config so a new file cannot land outside CI. Each snapshot job builds the shipped runtime while its Linux runner installs bubblewrap from the hosted image's existing package index, then runs only its assigned replay surface. CI explicitly retains the suite's bounded concurrency of five subprocesses instead of clamping it to the runner's two logical CPUs, because replay spends most of its time waiting on child protocol I/O. Fixture guards still inspect the complete ACP scenario table in every partition. Static, lint, coverage, and snapshot sharding changes only GitHub scheduling: the ordinary local package scripts still run their complete suites. +Snapshot replay uses one explicit multi-file lane and four scenario partitions of the large ACP file. [scripts/snapshot-shards.ts](../../../../scripts/snapshot-shards.ts) owns that inventory, and its test discovers every file admitted by the snapshot config so a new file cannot land outside CI. Each snapshot job builds the shipped runtime while its Linux runner installs bubblewrap from the hosted image's existing package index, then runs only its assigned replay surface. CI explicitly retains the suite's bounded concurrency of five subprocesses instead of clamping it to the runner's two logical CPUs, because replay spends most of its time waiting on child protocol I/O. Fixture guards still inspect the complete ACP scenario table in every partition. Static, lint, coverage, and snapshot sharding changes only GitHub scheduling: the ordinary local package scripts still run their complete suites. + +Cold standalone documentation typechecking rebuilds the complete project-reference graph, so the API-contract lane now builds once and checks Markdown blocks against those declarations. The Linux documentation lane uses VitePress's MPA build to retain page rendering and dead-link validation inside the one-minute budget; the blocking Windows build also produces the normal production SPA, preserving the shipped-site check under its three-minute budget. Artifacts use two lanes: one metadata lane for `publint`, NodeNext declarations, and compiled invariant loading, plus one built-bin smoke lane. Each lane produces its own build before its consumers. Repeating the short build costs runner minutes but avoids an upload/download dependency and keeps each job's critical path bounded. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cb08cefae7..9c98781cc6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -56,7 +56,7 @@ jobs: command: pnpm run check:ci:static gate_concurrency: '1' static_shard: site-projection - - lane: static-site-build + - lane: static-site-mpa command: pnpm run check:ci:static gate_concurrency: '1' static_shard: site-build @@ -137,16 +137,10 @@ jobs: gate_concurrency: '1' coverage_max_workers: '4' coverage_shard: capabilities - - lane: snapshot-support + - lane: snapshot-ordinary command: pnpm run check:ci:snapshot gate_concurrency: '1' - snapshot_lane: support - snapshot_max_concurrency: '5' - snapshot_prebuilt: '1' - - lane: snapshot-agents - command: pnpm run check:ci:snapshot - gate_concurrency: '1' - snapshot_lane: agents + snapshot_lane: ordinary snapshot_max_concurrency: '5' snapshot_prebuilt: '1' - lane: snapshot-acp-1 @@ -161,6 +155,18 @@ jobs: snapshot_lane: acp-2 snapshot_max_concurrency: '5' snapshot_prebuilt: '1' + - lane: snapshot-acp-3 + command: pnpm run check:ci:snapshot + gate_concurrency: '1' + snapshot_lane: acp-3 + snapshot_max_concurrency: '5' + snapshot_prebuilt: '1' + - lane: snapshot-acp-4 + command: pnpm run check:ci:snapshot + gate_concurrency: '1' + snapshot_lane: acp-4 + snapshot_max_concurrency: '5' + snapshot_prebuilt: '1' - lane: artifacts-metadata command: pnpm run check:ci:artifacts gate_concurrency: '3' @@ -314,6 +320,9 @@ jobs: - name: Build (tsc -b + tsdown) run: pnpm run build + - name: Build documentation site (production SPA) + run: pnpm run docs:build + # Observational, non-blocking Windows static, lint, and artifact lanes. Coverage # and snapshot stay Linux-only until their platform-specific runtime failures # have dedicated support. Run the gates from native PowerShell: an MSYS parent @@ -354,10 +363,6 @@ jobs: command: pnpm run check:ci:static gate_concurrency: '1' static_shard: site-projection - - lane: static-site-build - command: pnpm run check:ci:static - gate_concurrency: '1' - static_shard: site-build - lane: lint command: pnpm run check:ci:lint gate_concurrency: '1' diff --git a/package.json b/package.json index c3998bad0e..9dca83dc6a 100644 --- a/package.json +++ b/package.json @@ -52,6 +52,7 @@ "verify-doc-budgets": "tsx scripts/verify-doc-budgets.ts", "docs:dev": "pnpm --filter @deepseek-ai/website run dev", "docs:build": "pnpm --filter @deepseek-ai/website run build", + "docs:build:mpa": "pnpm --filter @deepseek-ai/website exec vitepress build . --mpa", "docs:preview": "pnpm --filter @deepseek-ai/website run preview", "docs:check": "pnpm exec vitest run scripts/project-doc-site.spec.ts && pnpm run docs:build", "website:dev": "pnpm run docs:dev", diff --git a/scripts/run-gates.ts b/scripts/run-gates.ts index efd94c92a0..504dc26ba7 100644 --- a/scripts/run-gates.ts +++ b/scripts/run-gates.ts @@ -244,7 +244,12 @@ function ciStaticGates(): Gate[] { pnpmScript('constraints', 'constraints'), pnpmScript('package-invariants', 'verify-package-invariants', { label: 'package invariants' }), pnpmScript('cordis-config', 'verify-cordis-config', { label: 'Cordis config' }), - ...docSyncLeafGates(), + pnpmScript('build', 'build'), + ...docSyncLeafGates({ + docTypecheckNeeds: ['build'], + docTypecheckEnv: { DSH_DOC_TYPECHECK_USE_BUILD_OUTPUT: '1' }, + docsBuildScript: 'docs:build:mpa', + }), pnpmScript('module-graph', 'verify-module-graph', { label: 'module graph' }), pnpmScript('knip', 'knip'), ] @@ -366,6 +371,7 @@ function hygieneLeafGates(options: { artifactNeeds?: string[] } = {}): Gate[] { function docSyncLeafGates(options: { docTypecheckNeeds?: string[] docTypecheckEnv?: Record + docsBuildScript?: 'docs:build' | 'docs:build:mpa' } = {}): Gate[] { const docTypecheckOptions: Partial = {} if (options.docTypecheckNeeds !== undefined) docTypecheckOptions.needs = options.docTypecheckNeeds @@ -396,7 +402,7 @@ function docSyncLeafGates(options: { label: 'documentation projection', }), // Keep the VitePress build itself in one gate because projection rewrites website/.generated. - pnpmScript('docs-site-build', 'docs:build', { label: 'documentation build' }), + pnpmScript('docs-site-build', options.docsBuildScript ?? 'docs:build', { label: 'documentation build' }), pnpmScript('package-readme-limitations', 'verify-package-readme-limitations', { label: 'package README limitations' }), ] } diff --git a/scripts/snapshot-shards.spec.ts b/scripts/snapshot-shards.spec.ts index 9f3c4e7cbd..dc4bef62ad 100644 --- a/scripts/snapshot-shards.spec.ts +++ b/scripts/snapshot-shards.spec.ts @@ -26,10 +26,10 @@ describe('snapshot lanes', () => { expect(new Set(ordinary).size).toBe(ordinary.length) expect(acp.map(lane => lane.files)).toEqual(Array.from( - { length: 2 }, + { length: 4 }, () => ['examples/acp-agent/tests/acp.snapshot.ts'], )) - expect(acp.map(lane => lane.scenarioShard)).toEqual(['1/2', '2/2']) + expect(acp.map(lane => lane.scenarioShard)).toEqual(['1/4', '2/4', '3/4', '4/4']) expect([...ordinary, 'examples/acp-agent/tests/acp.snapshot.ts'].sort()).toEqual(discovered) }) diff --git a/scripts/snapshot-shards.ts b/scripts/snapshot-shards.ts index 3106297a2e..a72a4e0e77 100644 --- a/scripts/snapshot-shards.ts +++ b/scripts/snapshot-shards.ts @@ -13,25 +13,20 @@ export interface SnapshotLane { /** Exhaustive file ownership plus scenario partitions for the large ACP suite. */ export const snapshotLanes: readonly SnapshotLane[] = [ { - name: 'support', + name: 'ordinary', files: [ 'packages/sdk/scripts/tests/config.snapshot.ts', 'packages/sdk/create-sdk/tests/create.snapshot.ts', 'packages/ui/tui/tests/tui.snapshot.ts', - ], - }, - { - name: 'agents', - files: [ 'examples/tui-agent/tests/tui.snapshot.ts', 'examples/acp-agent/tests/goal.snapshot.ts', 'examples/headless-agent/tests/headless.snapshot.ts', ], }, - ...Array.from({ length: 2 }, (_, offset) => ({ + ...Array.from({ length: 4 }, (_, offset) => ({ name: `acp-${offset + 1}`, files: ['examples/acp-agent/tests/acp.snapshot.ts'], - scenarioShard: `${offset + 1}/2`, + scenarioShard: `${offset + 1}/4`, })), ] diff --git a/scripts/static-shards.ts b/scripts/static-shards.ts index 4133470d87..4f495b5fda 100644 --- a/scripts/static-shards.ts +++ b/scripts/static-shards.ts @@ -23,7 +23,7 @@ export const staticShards = [ }, { name: 'api-contracts', - gateIds: ['doc-typecheck', 'cordis-api', 'export-jsdoc', 'scoped-events', 'type-equivalence'], + gateIds: ['build', 'doc-typecheck', 'cordis-api', 'export-jsdoc', 'scoped-events', 'type-equivalence'], }, { name: 'catalogs', From 89add445fe7571728f7b19450963a9502c4480c5 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 21 Jul 2026 21:39:51 +0800 Subject: [PATCH 07/90] ci: split boundary lanes --- .../2026-07-06-parallel-github-ci-gates.md | 6 +-- .github/workflows/ci.yml | 40 +++++++++++-------- scripts/static-shards.spec.ts | 10 +++++ scripts/static-shards.ts | 20 +++++++--- 4 files changed, 51 insertions(+), 25 deletions(-) diff --git a/.agents/notes/implemented/process/2026-07-06-parallel-github-ci-gates.md b/.agents/notes/implemented/process/2026-07-06-parallel-github-ci-gates.md index 22d3b1ad5c..f80568510e 100644 --- a/.agents/notes/implemented/process/2026-07-06-parallel-github-ci-gates.md +++ b/.agents/notes/implemented/process/2026-07-06-parallel-github-ci-gates.md @@ -14,11 +14,11 @@ The artifact boundary remains load-bearing. `publint`, `verify-node-next-types`, [CI](../../../../.github/workflows/ci.yml) bounds every non-Windows job to one minute and every Windows job to three minutes. The timeout is an executable regression ceiling; the lane design leaves headroom below it rather than treating a timeout as normal control flow. -[scripts/run-gates.ts](../../../../scripts/run-gates.ts) remains the common bounded scheduler, but GitHub supplies explicit shard names for the expensive gate families. [scripts/static-shards.ts](../../../../scripts/static-shards.ts) partitions static gates into foundation, API-contract, catalog, prose, documentation-projection, and documentation-build lanes and rejects a missing or duplicate gate assignment. Lint uses disjoint package-source, package-test, and repository-complement lanes; the complement still starts from `.` so a new top-level lint target cannot disappear between shards, and it owns the single cross-file duplication run. [scripts/coverage-shards.ts](../../../../scripts/coverage-shards.ts) assigns every workspace package to exactly one source-coverage lane; its test expands the live package tree, so a new package makes CI red until it has an owner. Each coverage lane includes only its owned source files, repeats the exhaustive companion topology test, and runs without a preceding build because the complete coverage suite passes from a tree with every generated `lib/` removed. +[scripts/run-gates.ts](../../../../scripts/run-gates.ts) remains the common bounded scheduler, but GitHub supplies explicit shard names for the expensive gate families. [scripts/static-shards.ts](../../../../scripts/static-shards.ts) partitions static gates into foundation, documentation-type, API-contract, catalog, prose, documentation-projection, and documentation-build lanes and rejects a missing or duplicate gate assignment. Lint uses disjoint package-source, package-test, and repository-complement lanes; the complement still starts from `.` so a new top-level lint target cannot disappear between shards, and it owns the single cross-file duplication run. [scripts/coverage-shards.ts](../../../../scripts/coverage-shards.ts) assigns every workspace package to exactly one source-coverage lane; its test expands the live package tree, so a new package makes CI red until it has an owner. Each coverage lane includes only its owned source files, repeats the exhaustive companion topology test, and runs without a preceding build because the complete coverage suite passes from a tree with every generated `lib/` removed. Snapshot replay uses one explicit multi-file lane and four scenario partitions of the large ACP file. [scripts/snapshot-shards.ts](../../../../scripts/snapshot-shards.ts) owns that inventory, and its test discovers every file admitted by the snapshot config so a new file cannot land outside CI. Each snapshot job builds the shipped runtime while its Linux runner installs bubblewrap from the hosted image's existing package index, then runs only its assigned replay surface. CI explicitly retains the suite's bounded concurrency of five subprocesses instead of clamping it to the runner's two logical CPUs, because replay spends most of its time waiting on child protocol I/O. Fixture guards still inspect the complete ACP scenario table in every partition. Static, lint, coverage, and snapshot sharding changes only GitHub scheduling: the ordinary local package scripts still run their complete suites. -Cold standalone documentation typechecking rebuilds the complete project-reference graph, so the API-contract lane now builds once and checks Markdown blocks against those declarations. The Linux documentation lane uses VitePress's MPA build to retain page rendering and dead-link validation inside the one-minute budget; the blocking Windows build also produces the normal production SPA, preserving the shipped-site check under its three-minute budget. +Cold standalone documentation typechecking rebuilds the complete project-reference graph, so a dedicated documentation-type lane builds once and checks Markdown blocks against those declarations. The Linux documentation lane uses VitePress's MPA build to retain page rendering and dead-link validation inside the one-minute budget; the blocking Windows build also produces the normal production SPA, preserving the shipped-site check under its three-minute budget. Artifacts use two lanes: one metadata lane for `publint`, NodeNext declarations, and compiled invariant loading, plus one built-bin smoke lane. Each lane produces its own build before its consumers. Repeating the short build costs runner minutes but avoids an upload/download dependency and keeps each job's critical path bounded. @@ -26,7 +26,7 @@ Artifacts use two lanes: one metadata lane for `publint`, NodeNext declarations, Compatibility lanes run the source worker and Zstandard runtime smokes on every advertised Node line. TypeScript checks the source graph once in a dedicated primary Node 24 lane; repeating the same compiler analysis in runtime compatibility jobs added time without runtime-specific signal. -The workflow caches the pnpm store, preserves native PowerShell for Windows measurements, and retains one aggregate `all checks passed` status for branch protection. Windows build remains blocking; the wider Windows static, lint, and artifact matrix remains observational while carrying the same three-minute ceiling. +The workflow caches the pnpm store, preserves native PowerShell for Windows measurements, and retains one aggregate `all checks passed` status for branch protection. Windows reuses the three exhaustive lint partitions and groups foundation, catalog, and prose gates behind one shared runner setup; only scheduling differs from the Linux partitions. Windows build remains blocking; the wider Windows static, lint, and artifact matrix remains observational while carrying the same three-minute ceiling. ## Alternatives considered diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9c98781cc6..5abe8a7371 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -40,6 +40,10 @@ jobs: command: pnpm run check:ci:static gate_concurrency: '4' static_shard: foundation + - lane: static-doc-types + command: pnpm run check:ci:static + gate_concurrency: '1' + static_shard: doc-types - lane: static-api-contracts command: pnpm run check:ci:static gate_concurrency: '4' @@ -337,36 +341,40 @@ jobs: DSH_GATE_CONCURRENCY: ${{ matrix.gate_concurrency }} DSH_PUBLINT_CONCURRENCY: ${{ matrix.publint_concurrency }} DSH_ARTIFACT_SHARD: ${{ matrix.artifact_shard }} + DSH_LINT_SHARD: ${{ matrix.lint_shard }} DSH_STATIC_SHARD: ${{ matrix.static_shard }} DSH_ESLINT_CACHE: ${{ matrix.eslint_cache }} strategy: fail-fast: false matrix: include: - - lane: static-foundation + - lane: static-general command: pnpm run check:ci:static gate_concurrency: '4' - static_shard: foundation + static_shard: 'foundation,catalogs,prose' + - lane: static-doc-types + command: pnpm run check:ci:static + gate_concurrency: '1' + static_shard: doc-types - lane: static-api-contracts command: pnpm run check:ci:static gate_concurrency: '4' static_shard: api-contracts - - lane: static-catalogs - command: pnpm run check:ci:static - gate_concurrency: '4' - static_shard: catalogs - - lane: static-prose - command: pnpm run check:ci:static - gate_concurrency: '4' - static_shard: prose - - lane: static-site-projection - command: pnpm run check:ci:static - gate_concurrency: '1' - static_shard: site-projection - - lane: lint + - lane: lint-package-sources command: pnpm run check:ci:lint gate_concurrency: '1' eslint_cache: '1' + lint_shard: package-sources + - lane: lint-package-tests + command: pnpm run check:ci:lint + gate_concurrency: '1' + eslint_cache: '1' + lint_shard: package-tests + - lane: lint-repository + command: pnpm run check:ci:lint + gate_concurrency: '1' + eslint_cache: '1' + lint_shard: repository - lane: artifacts-metadata command: pnpm run check:ci:artifacts gate_concurrency: '3' @@ -410,7 +418,7 @@ jobs: run: pnpm install --frozen-lockfile - uses: actions/cache@v4 - if: matrix.lane == 'lint' + if: startsWith(matrix.lane, 'lint-') with: path: .cache/eslint key: ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-eslint-${{ hashFiles('pnpm-lock.yaml', 'eslint.config.mjs', 'tsconfig.json', 'packages/*/*/tsconfig.json', 'examples/*/tsconfig.json') }} diff --git a/scripts/static-shards.spec.ts b/scripts/static-shards.spec.ts index 2c57be6dd5..df9e2922f1 100644 --- a/scripts/static-shards.spec.ts +++ b/scripts/static-shards.spec.ts @@ -8,9 +8,19 @@ describe('static gate shards', () => { expect(selectStaticGates(completeInventory, shard.name).map(gate => gate.id)).toEqual(shard.gateIds) }) + it('selects multiple lanes in gate inventory order', () => { + const selectedNames = new Set(['foundation', 'catalogs', 'prose']) + const expected = staticShards + .filter(shard => selectedNames.has(shard.name)) + .flatMap(shard => shard.gateIds) + expect(selectStaticGates(completeInventory, 'foundation,catalogs,prose').map(gate => gate.id)).toEqual(expected) + }) + it('rejects missing, duplicate, and unknown assignments', () => { expect(() => selectStaticGates(completeInventory.slice(1))).toThrow('assign every static gate exactly once') expect(() => selectStaticGates([...completeInventory, completeInventory[0]!])).toThrow('static gate IDs must be unique') expect(() => selectStaticGates(completeInventory, 'missing')).toThrow('unknown DSH_STATIC_SHARD') + expect(() => selectStaticGates(completeInventory, 'foundation,foundation')).toThrow('nonempty and unique') + expect(() => selectStaticGates(completeInventory, 'foundation,,prose')).toThrow('nonempty and unique') }) }) diff --git a/scripts/static-shards.ts b/scripts/static-shards.ts index 4f495b5fda..3476099993 100644 --- a/scripts/static-shards.ts +++ b/scripts/static-shards.ts @@ -21,9 +21,10 @@ export const staticShards = [ 'knip', ], }, + { name: 'doc-types', gateIds: ['build', 'doc-typecheck'] }, { name: 'api-contracts', - gateIds: ['build', 'doc-typecheck', 'cordis-api', 'export-jsdoc', 'scoped-events', 'type-equivalence'], + gateIds: ['cordis-api', 'export-jsdoc', 'scoped-events', 'type-equivalence'], }, { name: 'catalogs', @@ -54,8 +55,8 @@ export const staticShards = [ * Validate the complete gate partition and optionally select one lane. * * @param gates Complete static gate inventory. - * @param name Optional stable shard name. - * @returns All gates when no shard is requested, otherwise the selected lane. + * @param name Optional comma-separated stable shard names. + * @returns All gates when no shard is requested, otherwise the selected lanes in inventory order. */ export function selectStaticGates(gates: readonly T[], name?: string): T[] { const gateIds = gates.map(gate => gate.id) @@ -71,8 +72,15 @@ export function selectStaticGates(gates: readonly T[], } if (name === undefined || name === '') return [...gates] - const shard = staticShards.find(candidate => candidate.name === name) - if (shard === undefined) throw new Error(`run-gates: unknown DSH_STATIC_SHARD ${JSON.stringify(name)}.`) - const selectedIds = new Set(shard.gateIds) + const shardNames = name.split(',') + if (shardNames.some(shardName => shardName === '') || new Set(shardNames).size !== shardNames.length) { + throw new Error(`run-gates: DSH_STATIC_SHARD names must be nonempty and unique, got ${JSON.stringify(name)}.`) + } + const selectedShards = shardNames.map((shardName) => { + const shard = staticShards.find(candidate => candidate.name === shardName) + if (shard === undefined) throw new Error(`run-gates: unknown DSH_STATIC_SHARD ${JSON.stringify(shardName)}.`) + return shard + }) + const selectedIds = new Set(selectedShards.flatMap(shard => shard.gateIds)) return gates.filter(gate => selectedIds.has(gate.id)) } From ba1e34f8fbeb02da2d5e87133000e027f2086ed8 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 21 Jul 2026 21:47:38 +0800 Subject: [PATCH 08/90] test(lsp): synchronize closed-stdin fixture --- packages/lsp/lsp-local/tests/connection.spec.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/lsp/lsp-local/tests/connection.spec.ts b/packages/lsp/lsp-local/tests/connection.spec.ts index 6d9ca6d6a3..f12fd04e84 100644 --- a/packages/lsp/lsp-local/tests/connection.spec.ts +++ b/packages/lsp/lsp-local/tests/connection.spec.ts @@ -210,8 +210,8 @@ describe('LspConnection edge behavior', () => { }) it('rejects a pending request when child stdin closes but the process stays alive', async () => { - const conn = connectScript('require("node:fs").closeSync(0); setInterval(()=>{}, 1000)') - await new Promise(resolve => setTimeout(resolve, 100)) + const conn = connectScript('require("node:fs").closeSync(0); process.stderr.write("stdin closed"); setInterval(()=>{}, 1000)') + await waitFor(() => conn.stderrTail === 'stdin closed') const timeout = new Promise((_resolve, reject) => { setTimeout(() => { reject(new Error('request timed out')) }, 1000) }) From 311cbe1e4c36b98ee6f353059315b35ff5841fbd Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 21 Jul 2026 21:49:35 +0800 Subject: [PATCH 09/90] ci: rebalance final boundary lanes --- .../2026-07-06-parallel-github-ci-gates.md | 4 ++-- .github/workflows/ci.yml | 18 ++++++++++-------- scripts/snapshot-shards.ts | 7 ++++++- 3 files changed, 18 insertions(+), 11 deletions(-) diff --git a/.agents/notes/implemented/process/2026-07-06-parallel-github-ci-gates.md b/.agents/notes/implemented/process/2026-07-06-parallel-github-ci-gates.md index f80568510e..70a8a4d2a1 100644 --- a/.agents/notes/implemented/process/2026-07-06-parallel-github-ci-gates.md +++ b/.agents/notes/implemented/process/2026-07-06-parallel-github-ci-gates.md @@ -16,7 +16,7 @@ The artifact boundary remains load-bearing. `publint`, `verify-node-next-types`, [scripts/run-gates.ts](../../../../scripts/run-gates.ts) remains the common bounded scheduler, but GitHub supplies explicit shard names for the expensive gate families. [scripts/static-shards.ts](../../../../scripts/static-shards.ts) partitions static gates into foundation, documentation-type, API-contract, catalog, prose, documentation-projection, and documentation-build lanes and rejects a missing or duplicate gate assignment. Lint uses disjoint package-source, package-test, and repository-complement lanes; the complement still starts from `.` so a new top-level lint target cannot disappear between shards, and it owns the single cross-file duplication run. [scripts/coverage-shards.ts](../../../../scripts/coverage-shards.ts) assigns every workspace package to exactly one source-coverage lane; its test expands the live package tree, so a new package makes CI red until it has an owner. Each coverage lane includes only its owned source files, repeats the exhaustive companion topology test, and runs without a preceding build because the complete coverage suite passes from a tree with every generated `lib/` removed. -Snapshot replay uses one explicit multi-file lane and four scenario partitions of the large ACP file. [scripts/snapshot-shards.ts](../../../../scripts/snapshot-shards.ts) owns that inventory, and its test discovers every file admitted by the snapshot config so a new file cannot land outside CI. Each snapshot job builds the shipped runtime while its Linux runner installs bubblewrap from the hosted image's existing package index, then runs only its assigned replay surface. CI explicitly retains the suite's bounded concurrency of five subprocesses instead of clamping it to the runner's two logical CPUs, because replay spends most of its time waiting on child protocol I/O. Fixture guards still inspect the complete ACP scenario table in every partition. Static, lint, coverage, and snapshot sharding changes only GitHub scheduling: the ordinary local package scripts still run their complete suites. +Snapshot replay uses two explicit multi-file lanes and four scenario partitions of the large ACP file. [scripts/snapshot-shards.ts](../../../../scripts/snapshot-shards.ts) owns that inventory, and its test discovers every file admitted by the snapshot config so a new file cannot land outside CI. Each snapshot job builds the shipped runtime while its Linux runner installs bubblewrap from the hosted image's existing package index, then runs only its assigned replay surface. CI explicitly retains the suite's bounded concurrency of five subprocesses instead of clamping it to the runner's two logical CPUs, because replay spends most of its time waiting on child protocol I/O. Fixture guards still inspect the complete ACP scenario table in every partition. Static, lint, coverage, and snapshot sharding changes only GitHub scheduling: the ordinary local package scripts still run their complete suites. Cold standalone documentation typechecking rebuilds the complete project-reference graph, so a dedicated documentation-type lane builds once and checks Markdown blocks against those declarations. The Linux documentation lane uses VitePress's MPA build to retain page rendering and dead-link validation inside the one-minute budget; the blocking Windows build also produces the normal production SPA, preserving the shipped-site check under its three-minute budget. @@ -26,7 +26,7 @@ Artifacts use two lanes: one metadata lane for `publint`, NodeNext declarations, Compatibility lanes run the source worker and Zstandard runtime smokes on every advertised Node line. TypeScript checks the source graph once in a dedicated primary Node 24 lane; repeating the same compiler analysis in runtime compatibility jobs added time without runtime-specific signal. -The workflow caches the pnpm store, preserves native PowerShell for Windows measurements, and retains one aggregate `all checks passed` status for branch protection. Windows reuses the three exhaustive lint partitions and groups foundation, catalog, and prose gates behind one shared runner setup; only scheduling differs from the Linux partitions. Windows build remains blocking; the wider Windows static, lint, and artifact matrix remains observational while carrying the same three-minute ceiling. +The workflow caches the pnpm store, preserves native PowerShell for Windows measurements, and retains one aggregate `all checks passed` status for branch protection. Windows reuses the three exhaustive lint partitions and groups foundation/catalog/prose plus documentation-type/API-contract gates behind shared runner setups; only scheduling differs from the Linux partitions. Windows build remains blocking; the wider Windows static, lint, and artifact matrix remains observational while carrying the same three-minute ceiling. ## Alternatives considered diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5abe8a7371..82df288a23 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -141,10 +141,16 @@ jobs: gate_concurrency: '1' coverage_max_workers: '4' coverage_shard: capabilities - - lane: snapshot-ordinary + - lane: snapshot-support command: pnpm run check:ci:snapshot gate_concurrency: '1' - snapshot_lane: ordinary + snapshot_lane: support + snapshot_max_concurrency: '5' + snapshot_prebuilt: '1' + - lane: snapshot-agents + command: pnpm run check:ci:snapshot + gate_concurrency: '1' + snapshot_lane: agents snapshot_max_concurrency: '5' snapshot_prebuilt: '1' - lane: snapshot-acp-1 @@ -352,14 +358,10 @@ jobs: command: pnpm run check:ci:static gate_concurrency: '4' static_shard: 'foundation,catalogs,prose' - - lane: static-doc-types - command: pnpm run check:ci:static - gate_concurrency: '1' - static_shard: doc-types - - lane: static-api-contracts + - lane: static-contracts command: pnpm run check:ci:static gate_concurrency: '4' - static_shard: api-contracts + static_shard: 'doc-types,api-contracts' - lane: lint-package-sources command: pnpm run check:ci:lint gate_concurrency: '1' diff --git a/scripts/snapshot-shards.ts b/scripts/snapshot-shards.ts index a72a4e0e77..179c2e1517 100644 --- a/scripts/snapshot-shards.ts +++ b/scripts/snapshot-shards.ts @@ -13,11 +13,16 @@ export interface SnapshotLane { /** Exhaustive file ownership plus scenario partitions for the large ACP suite. */ export const snapshotLanes: readonly SnapshotLane[] = [ { - name: 'ordinary', + name: 'support', files: [ 'packages/sdk/scripts/tests/config.snapshot.ts', 'packages/sdk/create-sdk/tests/create.snapshot.ts', 'packages/ui/tui/tests/tui.snapshot.ts', + ], + }, + { + name: 'agents', + files: [ 'examples/tui-agent/tests/tui.snapshot.ts', 'examples/acp-agent/tests/goal.snapshot.ts', 'examples/headless-agent/tests/headless.snapshot.ts', From e4c56b35e805504a9f8f948fcd82317723223bf2 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 21 Jul 2026 22:00:34 +0800 Subject: [PATCH 10/90] ci: add tail-lane headroom --- .../2026-07-06-parallel-github-ci-gates.md | 2 +- .github/workflows/ci.yml | 44 +++++++------- scripts/coverage-shards.ts | 57 +++++++++++++------ scripts/snapshot-shards.spec.ts | 4 +- scripts/snapshot-shards.ts | 4 +- 5 files changed, 69 insertions(+), 42 deletions(-) diff --git a/.agents/notes/implemented/process/2026-07-06-parallel-github-ci-gates.md b/.agents/notes/implemented/process/2026-07-06-parallel-github-ci-gates.md index 70a8a4d2a1..fbbd8c46d3 100644 --- a/.agents/notes/implemented/process/2026-07-06-parallel-github-ci-gates.md +++ b/.agents/notes/implemented/process/2026-07-06-parallel-github-ci-gates.md @@ -16,7 +16,7 @@ The artifact boundary remains load-bearing. `publint`, `verify-node-next-types`, [scripts/run-gates.ts](../../../../scripts/run-gates.ts) remains the common bounded scheduler, but GitHub supplies explicit shard names for the expensive gate families. [scripts/static-shards.ts](../../../../scripts/static-shards.ts) partitions static gates into foundation, documentation-type, API-contract, catalog, prose, documentation-projection, and documentation-build lanes and rejects a missing or duplicate gate assignment. Lint uses disjoint package-source, package-test, and repository-complement lanes; the complement still starts from `.` so a new top-level lint target cannot disappear between shards, and it owns the single cross-file duplication run. [scripts/coverage-shards.ts](../../../../scripts/coverage-shards.ts) assigns every workspace package to exactly one source-coverage lane; its test expands the live package tree, so a new package makes CI red until it has an owner. Each coverage lane includes only its owned source files, repeats the exhaustive companion topology test, and runs without a preceding build because the complete coverage suite passes from a tree with every generated `lib/` removed. -Snapshot replay uses two explicit multi-file lanes and four scenario partitions of the large ACP file. [scripts/snapshot-shards.ts](../../../../scripts/snapshot-shards.ts) owns that inventory, and its test discovers every file admitted by the snapshot config so a new file cannot land outside CI. Each snapshot job builds the shipped runtime while its Linux runner installs bubblewrap from the hosted image's existing package index, then runs only its assigned replay surface. CI explicitly retains the suite's bounded concurrency of five subprocesses instead of clamping it to the runner's two logical CPUs, because replay spends most of its time waiting on child protocol I/O. Fixture guards still inspect the complete ACP scenario table in every partition. Static, lint, coverage, and snapshot sharding changes only GitHub scheduling: the ordinary local package scripts still run their complete suites. +Snapshot replay uses two explicit multi-file lanes and six scenario partitions of the large ACP file. [scripts/snapshot-shards.ts](../../../../scripts/snapshot-shards.ts) owns that inventory, and its test discovers every file admitted by the snapshot config so a new file cannot land outside CI. Each snapshot job builds the shipped runtime while its Linux runner installs bubblewrap from the hosted image's existing package index, then runs only its assigned replay surface. CI explicitly retains the suite's bounded concurrency of five subprocesses instead of clamping it to the runner's two logical CPUs, because replay spends most of its time waiting on child protocol I/O. Fixture guards still inspect the complete ACP scenario table in every partition. Static, lint, coverage, and snapshot sharding changes only GitHub scheduling: the ordinary local package scripts still run their complete suites. Cold standalone documentation typechecking rebuilds the complete project-reference graph, so a dedicated documentation-type lane builds once and checks Markdown blocks against those declarations. The Linux documentation lane uses VitePress's MPA build to retain page rendering and dead-link validation inside the one-minute budget; the blocking Windows build also produces the normal production SPA, preserving the shipped-site check under its three-minute budget. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 82df288a23..45b8cd5407 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -81,21 +81,26 @@ jobs: gate_concurrency: '1' eslint_cache: '1' lint_shard: repository - - lane: coverage-core + - lane: coverage-core-loop command: pnpm run check:ci:coverage gate_concurrency: '1' coverage_max_workers: '4' - coverage_shard: core - - lane: coverage-models-protocol + coverage_shard: core-loop + - lane: coverage-state-session command: pnpm run check:ci:coverage gate_concurrency: '1' coverage_max_workers: '4' - coverage_shard: models-protocol - - lane: coverage-sdk-codex + coverage_shard: state-session + - lane: coverage-models-integrations command: pnpm run check:ci:coverage gate_concurrency: '1' coverage_max_workers: '4' - coverage_shard: sdk-codex + coverage_shard: models-integrations + - lane: coverage-sdk-capabilities + command: pnpm run check:ci:coverage + gate_concurrency: '1' + coverage_max_workers: '4' + coverage_shard: sdk-capabilities - lane: coverage-interfaces command: pnpm run check:ci:coverage gate_concurrency: '1' @@ -126,21 +131,6 @@ jobs: gate_concurrency: '1' coverage_max_workers: '4' coverage_shard: repository - - lane: coverage-integrations-claude - command: pnpm run check:ci:coverage - gate_concurrency: '1' - coverage_max_workers: '4' - coverage_shard: integrations-claude - - lane: coverage-session-scripts - command: pnpm run check:ci:coverage - gate_concurrency: '1' - coverage_max_workers: '4' - coverage_shard: session-scripts - - lane: coverage-capabilities - command: pnpm run check:ci:coverage - gate_concurrency: '1' - coverage_max_workers: '4' - coverage_shard: capabilities - lane: snapshot-support command: pnpm run check:ci:snapshot gate_concurrency: '1' @@ -177,6 +167,18 @@ jobs: snapshot_lane: acp-4 snapshot_max_concurrency: '5' snapshot_prebuilt: '1' + - lane: snapshot-acp-5 + command: pnpm run check:ci:snapshot + gate_concurrency: '1' + snapshot_lane: acp-5 + snapshot_max_concurrency: '5' + snapshot_prebuilt: '1' + - lane: snapshot-acp-6 + command: pnpm run check:ci:snapshot + gate_concurrency: '1' + snapshot_lane: acp-6 + snapshot_max_concurrency: '5' + snapshot_prebuilt: '1' - lane: artifacts-metadata command: pnpm run check:ci:artifacts gate_concurrency: '3' diff --git a/scripts/coverage-shards.ts b/scripts/coverage-shards.ts index f866bbb04d..4bcc0277c1 100644 --- a/scripts/coverage-shards.ts +++ b/scripts/coverage-shards.ts @@ -13,12 +13,44 @@ export interface CoverageShard { /** Exhaustive, non-overlapping ownership of workspace packages in coverage CI. */ export const coverageShards = [ { - name: 'core', - packageRoots: ['core', 'context'], - extraTestRoots: ['packages/examples/cli-demo/tests'], + name: 'core-loop', + packageRoots: ['core/agent', 'core/agent-loop', 'core/tools'], + }, + { + name: 'state-session', + packageRoots: [ + 'core/session', + 'core/scope', + 'core/system-prompt', + 'context', + 'session-persistence', + 'session-query', + 'support/invariants', + ], + extraTestRoots: [ + 'packages/examples/cli-demo/tests', + 'packages/llm/token-meter/tests', + 'scripts', + ], + }, + { + name: 'models-integrations', + packageRoots: ['llm', 'compact', 'hooks/hook-protocol', 'lsp', 'mcp', 'hooks/hooks-claude'], + }, + { + name: 'sdk-capabilities', + packageRoots: [ + 'sdk', + 'hooks/hooks-codex', + 'web', + 'skill', + 'spill', + 'util', + 'guard', + 'todo', + 'timeout', + ], }, - { name: 'models-protocol', packageRoots: ['llm', 'compact', 'hooks/hook-protocol'] }, - { name: 'sdk-codex', packageRoots: ['sdk', 'hooks/hooks-codex'] }, { name: 'interfaces', packageRoots: ['ui', 'examples', 'goal'], @@ -29,7 +61,10 @@ export const coverageShards = [ name: 'workflow', packageRoots: ['workflow/workflow', 'workflow/tool-workflow', 'workflow/tool-ralph'], }, - { name: 'workflow-worker', packageRoots: ['workflow/workflow-workerthread'] }, + { + name: 'workflow-worker', + packageRoots: ['workflow/workflow-workerthread'], + }, { name: 'delegation', packageRoots: ['subagent', 'tasks'] }, { name: 'repository', @@ -41,16 +76,6 @@ export const coverageShards = [ 'support/loader-smoke', ], }, - { - name: 'session-scripts', - packageRoots: ['session-persistence', 'session-query', 'support/invariants'], - extraTestRoots: ['scripts'], - }, - { name: 'integrations-claude', packageRoots: ['lsp', 'mcp', 'hooks/hooks-claude'] }, - { - name: 'capabilities', - packageRoots: ['web', 'skill', 'spill', 'util', 'guard', 'todo', 'timeout'], - }, ] as const satisfies readonly CoverageShard[] /** diff --git a/scripts/snapshot-shards.spec.ts b/scripts/snapshot-shards.spec.ts index dc4bef62ad..7cc37fb7bd 100644 --- a/scripts/snapshot-shards.spec.ts +++ b/scripts/snapshot-shards.spec.ts @@ -26,10 +26,10 @@ describe('snapshot lanes', () => { expect(new Set(ordinary).size).toBe(ordinary.length) expect(acp.map(lane => lane.files)).toEqual(Array.from( - { length: 4 }, + { length: 6 }, () => ['examples/acp-agent/tests/acp.snapshot.ts'], )) - expect(acp.map(lane => lane.scenarioShard)).toEqual(['1/4', '2/4', '3/4', '4/4']) + expect(acp.map(lane => lane.scenarioShard)).toEqual(['1/6', '2/6', '3/6', '4/6', '5/6', '6/6']) expect([...ordinary, 'examples/acp-agent/tests/acp.snapshot.ts'].sort()).toEqual(discovered) }) diff --git a/scripts/snapshot-shards.ts b/scripts/snapshot-shards.ts index 179c2e1517..fae8010261 100644 --- a/scripts/snapshot-shards.ts +++ b/scripts/snapshot-shards.ts @@ -28,10 +28,10 @@ export const snapshotLanes: readonly SnapshotLane[] = [ 'examples/headless-agent/tests/headless.snapshot.ts', ], }, - ...Array.from({ length: 4 }, (_, offset) => ({ + ...Array.from({ length: 6 }, (_, offset) => ({ name: `acp-${offset + 1}`, files: ['examples/acp-agent/tests/acp.snapshot.ts'], - scenarioShard: `${offset + 1}/4`, + scenarioShard: `${offset + 1}/6`, })), ] From 3fa5795bf47494bff879859ac99f014c2d85e9b4 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 21 Jul 2026 22:09:50 +0800 Subject: [PATCH 11/90] test(lsp): synchronize idle server eviction --- .../lsp/lsp-local/tests/lifecycle.spec.ts | 45 +++++++++++++++---- 1 file changed, 36 insertions(+), 9 deletions(-) diff --git a/packages/lsp/lsp-local/tests/lifecycle.spec.ts b/packages/lsp/lsp-local/tests/lifecycle.spec.ts index 826905ed26..dd235e800f 100644 --- a/packages/lsp/lsp-local/tests/lifecycle.spec.ts +++ b/packages/lsp/lsp-local/tests/lifecycle.spec.ts @@ -1,11 +1,11 @@ -import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises' import { realpath } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { pathToFileURL, fileURLToPath } from 'node:url' import { Context } from 'cordis' -import Lsp, { type LspQueryRequest, type LspQueryResult } from '@deepseek-ai/dsh-lsp' +import Lsp, { type LspProvider, type LspQueryRequest, type LspQueryResult } from '@deepseek-ai/dsh-lsp' import { deadline } from '@deepseek-ai/dsh-timeout' import * as LspLocal from '@deepseek-ai/dsh-lsp-local' import type { LspLocalServerConfig } from '@deepseek-ai/dsh-lsp-local' @@ -38,12 +38,27 @@ function fakeServer(fakeEnv: Record = {}, overrides: Partial = {}, overrides: Partial = {}): Promise { +async function mount( + fakeEnv: Record = {}, + overrides: Partial = {}, + captureProvider?: (provider: LspProvider) => void, +): Promise { const ctx = new Context() await ctx.plugin(Lsp) - await ctx.plugin(LspLocal, { - servers: { fake: fakeServer(fakeEnv, overrides) }, - }) + const register = ctx.lsp.registerProvider.bind(ctx.lsp) + const registrationSpy = captureProvider === undefined + ? undefined + : vi.spyOn(ctx.lsp, 'registerProvider').mockImplementation((provider) => { + captureProvider(provider) + return register(provider) + }) + try { + await ctx.plugin(LspLocal, { + servers: { fake: fakeServer(fakeEnv, overrides) }, + }) + } finally { + registrationSpy?.mockRestore() + } return ctx } @@ -241,10 +256,22 @@ describe('lsp-local end to end over a fake server', () => { // The first query succeeds, then the server exits before the second arrives, leaving a dead // instance in the pool. The next query must evict-and-replace it and still succeed, rather than // failing once on the closed connection first. - const ctx = await mount({ LSP_FAKE_EXIT_AFTER_REPLY: '1', LSP_FAKE_DEF: JSON.stringify(locationJson(0)) }) + let provider: LspProvider | undefined + const ctx = await mount( + { LSP_FAKE_EXIT_AFTER_REPLY: '1', LSP_FAKE_DEF: JSON.stringify(locationJson(0)) }, + {}, + (registered) => { provider = registered }, + ) expect(await ctx.lsp.query(query('goToDefinition'))).toMatchObject({ kind: 'locations' }) - // Wait past the fixture's post-reply exit so the pooled instance is observably dead. - await new Promise(resolve => setTimeout(resolve, 60)) + if (provider === undefined) throw new Error('expected lsp-local to register a provider') + // This implementation-local test reaches the private pool only to synchronize with its actual + // close state. A fixed wall-clock sleep can expire before a CPU-starved child runs its exit timer. + const instances = (provider as unknown as { + readonly instances: ReadonlyMap + }).instances + const instance = [...instances.values()][0] + if (instance === undefined) throw new Error('expected one pooled LSP instance') + await waitFor(async () => instance.dead) expect(await ctx.lsp.query(query('goToDefinition'))).toMatchObject({ kind: 'locations' }) await ctx.fiber.dispose() }) From 06f3b246b51fb81ef57d39804f3f94d8fe85859e Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 21 Jul 2026 23:03:52 +0800 Subject: [PATCH 12/90] ci: add serial cross-platform reference runs --- .../2026-07-06-parallel-github-ci-gates.md | 8 +- ...rial-cross-platform-ci-reference.i18n.yaml | 6 + ...7-21-serial-cross-platform-ci-reference.md | 39 ++++++ ...1-serial-cross-platform-ci-reference.zh.md | 39 ++++++ .github/workflows/ci.yml | 112 ++++++++++++++++-- 5 files changed, 193 insertions(+), 11 deletions(-) create mode 100644 .agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.i18n.yaml create mode 100644 .agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.md create mode 100644 .agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.zh.md diff --git a/.agents/notes/implemented/process/2026-07-06-parallel-github-ci-gates.md b/.agents/notes/implemented/process/2026-07-06-parallel-github-ci-gates.md index fbbd8c46d3..b657fd1e93 100644 --- a/.agents/notes/implemented/process/2026-07-06-parallel-github-ci-gates.md +++ b/.agents/notes/implemented/process/2026-07-06-parallel-github-ci-gates.md @@ -12,13 +12,13 @@ The artifact boundary remains load-bearing. `publint`, `verify-node-next-types`, ## Decision -[CI](../../../../.github/workflows/ci.yml) bounds every non-Windows job to one minute and every Windows job to three minutes. The timeout is an executable regression ceiling; the lane design leaves headroom below it rather than treating a timeout as normal control flow. +[CI](../../../../.github/workflows/ci.yml) treats one minute for non-Windows jobs and three minutes for Windows jobs as observed performance targets, not cancellation deadlines. Hosted-runner variance should leave complete timing evidence and useful failure logs instead of cancelling an otherwise-correct gate. The [serial cross-platform CI reference](2026-07-21-serial-cross-platform-ci-reference.md) independently runs the complete unsharded primary Node aggregate on Linux, macOS, and Windows so the optimized lane inventory is not its own completeness oracle. [scripts/run-gates.ts](../../../../scripts/run-gates.ts) remains the common bounded scheduler, but GitHub supplies explicit shard names for the expensive gate families. [scripts/static-shards.ts](../../../../scripts/static-shards.ts) partitions static gates into foundation, documentation-type, API-contract, catalog, prose, documentation-projection, and documentation-build lanes and rejects a missing or duplicate gate assignment. Lint uses disjoint package-source, package-test, and repository-complement lanes; the complement still starts from `.` so a new top-level lint target cannot disappear between shards, and it owns the single cross-file duplication run. [scripts/coverage-shards.ts](../../../../scripts/coverage-shards.ts) assigns every workspace package to exactly one source-coverage lane; its test expands the live package tree, so a new package makes CI red until it has an owner. Each coverage lane includes only its owned source files, repeats the exhaustive companion topology test, and runs without a preceding build because the complete coverage suite passes from a tree with every generated `lib/` removed. Snapshot replay uses two explicit multi-file lanes and six scenario partitions of the large ACP file. [scripts/snapshot-shards.ts](../../../../scripts/snapshot-shards.ts) owns that inventory, and its test discovers every file admitted by the snapshot config so a new file cannot land outside CI. Each snapshot job builds the shipped runtime while its Linux runner installs bubblewrap from the hosted image's existing package index, then runs only its assigned replay surface. CI explicitly retains the suite's bounded concurrency of five subprocesses instead of clamping it to the runner's two logical CPUs, because replay spends most of its time waiting on child protocol I/O. Fixture guards still inspect the complete ACP scenario table in every partition. Static, lint, coverage, and snapshot sharding changes only GitHub scheduling: the ordinary local package scripts still run their complete suites. -Cold standalone documentation typechecking rebuilds the complete project-reference graph, so a dedicated documentation-type lane builds once and checks Markdown blocks against those declarations. The Linux documentation lane uses VitePress's MPA build to retain page rendering and dead-link validation inside the one-minute budget; the blocking Windows build also produces the normal production SPA, preserving the shipped-site check under its three-minute budget. +Cold standalone documentation typechecking rebuilds the complete project-reference graph, so a dedicated documentation-type lane builds once and checks Markdown blocks against those declarations. The Linux documentation lane uses VitePress's MPA build to retain page rendering and dead-link validation within the observed non-Windows target; the blocking Windows build also produces the normal production SPA, preserving the shipped-site check within the observed Windows target. Artifacts use two lanes: one metadata lane for `publint`, NodeNext declarations, and compiled invariant loading, plus one built-bin smoke lane. Each lane produces its own build before its consumers. Repeating the short build costs runner minutes but avoids an upload/download dependency and keeps each job's critical path bounded. @@ -26,11 +26,11 @@ Artifacts use two lanes: one metadata lane for `publint`, NodeNext declarations, Compatibility lanes run the source worker and Zstandard runtime smokes on every advertised Node line. TypeScript checks the source graph once in a dedicated primary Node 24 lane; repeating the same compiler analysis in runtime compatibility jobs added time without runtime-specific signal. -The workflow caches the pnpm store, preserves native PowerShell for Windows measurements, and retains one aggregate `all checks passed` status for branch protection. Windows reuses the three exhaustive lint partitions and groups foundation/catalog/prose plus documentation-type/API-contract gates behind shared runner setups; only scheduling differs from the Linux partitions. Windows build remains blocking; the wider Windows static, lint, and artifact matrix remains observational while carrying the same three-minute ceiling. +The workflow caches the pnpm store, preserves native PowerShell for Windows measurements, and retains one aggregate `all checks passed` status for branch protection. Windows reuses the three exhaustive lint partitions and groups foundation/catalog/prose plus documentation-type/API-contract gates behind shared runner setups; only scheduling differs from the Linux partitions. Windows build remains blocking, while the wider Windows static, lint, and artifact matrix remains observational. ## Alternatives considered -- **Keep the broad lanes and raise timeouts** - minimizes workflow YAML, but it preserves the measured multi-minute feedback loop and offers no regression budget. +- **Keep the broad lanes** - minimizes workflow YAML, but it preserves the measured multi-minute feedback loop. - **Run every leaf gate as a separate GitHub job** - maximizes fan-out, but short generators and prose checks would spend more time preparing a runner than checking the repository. - **Upload one build to artifact consumers** - avoids repeated compilation, but upload/download and dependency scheduling lengthen wall time; the clean build is short enough to repeat inside bounded lanes. - **Keep package-manager packing in both publication gates** - delegates inventory selection to pnpm, but repeats more than 200 package-manager processes. The manifest structural gate plus publication-view fixtures make the optimized inventory contract explicit and fail on an on-disk but unpublished dependency. diff --git a/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.i18n.yaml b/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.i18n.yaml new file mode 100644 index 0000000000..5871fa1b07 --- /dev/null +++ b/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.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 +2026-07-21-serial-cross-platform-ci-reference.md: e2cb9dbc5f8778b5a28236cb135f6dcbb979d38b +2026-07-21-serial-cross-platform-ci-reference.zh.md: 85c3403f1acb78e60e3a811503b5b264f84242c7 diff --git a/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.md b/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.md new file mode 100644 index 0000000000..e2cb9dbc5f --- /dev/null +++ b/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.md @@ -0,0 +1,39 @@ +# Agent Note: Serial cross-platform CI reference + +Status: implemented + +English | [中文](2026-07-21-serial-cross-platform-ci-reference.zh.md) + +## Problem + +The pull-request workflow reaches its latency targets by partitioning static checks, lint, coverage, snapshot replay, and artifact validation across explicit GitHub jobs. Those partitions are exhaustively checked in code, but the optimized workflow still should not be its own only completeness oracle: a defect shared by shard selection and its inventory test could omit work while every optimized lane stays green. + +Encoding the one-minute non-Windows target and three-minute Windows target as job timeouts creates a separate failure mode. Hosted-runner startup and performance vary, so a correct gate can be cancelled at the target boundary before it emits useful diagnostics. The performance objective needs measurement against GitHub timestamps, while correctness needs enough time to finish. + +Reviewers also need a direct answer to a simpler question: what happens when the repository's complete primary Node CI aggregate runs without matrix selection, shard variables, or concurrent gates on each selected hosted operating system? + +## Decision + +[CI](../../../../.github/workflows/ci.yml) accepts `workflow_dispatch` in addition to its normal push and pull-request events. A manual dispatch skips the optimized and compatibility jobs and exposes three explicit jobs named `serial / linux`, `serial / macos`, and `serial / windows`. They intentionally duplicate their short checkout, runtime setup, and immutable install sequences instead of hiding the operating systems behind a matrix or reusable workflow. + +Each reference job runs `pnpm run check:ci` without any shard selector. `DSH_GATE_CONCURRENCY=1` makes the top-level aggregate execute one ready gate at a time; coverage, snapshot replay, built-bin smoke, and publication validation also receive worker counts of one. The three operating-system jobs may run beside one another, but each host's repository gates are serial and complete. Linux installs bubblewrap before replaying snapshots, and Windows enables Developer Mode before installing the symlinked workspace. + +Manual reference jobs are diagnostic and do not participate in the required `all checks passed` result. Pull-request and push events continue to run only the optimized lanes. The one-minute non-Windows and three-minute Windows objectives are evaluated from completed hosted-job timestamps and reported as measurements; they are not `timeout-minutes` values. + +The portable reference uses GitHub's standard `ubuntu-latest`, `macos-latest`, and `windows-2025` labels. A higher-core hosted runner remains a possible future benchmark, but it is not the default: larger runners require organization-owned labels and provisioning, while a reference oracle should remain runnable without repository-external runner configuration. Provisioning one later can change the performance experiment without changing this correctness baseline. + +## Alternatives considered + +- **Set each timeout equal to its latency target** - rejected because scheduling variance would cancel correct work and suppress the evidence needed to diagnose a regression. +- **Trust only the optimized shard inventory** - rejected because selection and validation share implementation assumptions; an unsharded aggregate is an independent completeness check. +- **Run the serial references on every pull request** - rejected because they deliberately trade wall time and runner consumption for simplicity and are not needed in the fast feedback loop. +- **Use one operating-system matrix** - rejected because three named jobs make the reference surface visible without another selection mechanism. +- **Move the fast workflow to larger runners now** - rejected as the portable default because it would couple ordinary CI to organization-specific runner capacity. It remains an opt-in experiment after such capacity has an owned label and budget. + +## Consequences + +The workflow contains duplicated setup steps and a manual reference run can take much longer than the optimized pull-request path. That duplication is deliberate: reviewers can inspect each operating system's complete command without resolving a matrix or shard inventory. + +The reference may expose platform failures that the optimized blocking set does not yet claim to support, especially on Windows. Such a failure is evidence about current cross-platform behavior rather than a reason to weaken or silently skip the aggregate. + +Removing strict duration timeouts means a latency regression is observed rather than automatically cancelled. Hosted measurements must therefore accompany performance changes, while the completed logs retain the information needed to optimize the slow lane. diff --git a/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.zh.md b/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.zh.md new file mode 100644 index 0000000000..85c3403f1a --- /dev/null +++ b/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.zh.md @@ -0,0 +1,39 @@ +# Agent Note: 跨平台串行 CI 参考流程 + +Status: implemented + +[English](2026-07-21-serial-cross-platform-ci-reference.md) | 中文 + +## 问题 + +拉取请求工作流通过把静态检查、lint、覆盖率、快照回放和产物验证拆分到显式的 GitHub 作业中来达到延迟目标。这些分区由代码穷举校验,但优化工作流仍不应成为自身唯一的完整性判定基准:如果分片选择逻辑及其清单测试存在同一缺陷,即使所有优化通道都保持绿灯,也可能漏掉部分工作。 + +将非 Windows 作业的 1 分钟目标和 Windows 作业的 3 分钟目标写成作业超时,会引入另一种失败模式。托管运行器的启动时间和性能会波动,因此即使门禁本身正确,也可能在到达目标时间边界时被取消,来不及输出有用的诊断信息。性能目标需要根据 GitHub 时间戳衡量,而正确性验证需要给门禁留足完成时间。 + +评审人还需要直接回答一个更简单的问题:在每个选定的托管操作系统上,如果仓库完整的主 Node CI 聚合流程不使用矩阵选择、分片变量或并发门禁,运行结果会怎样? + +## 决策 + +[CI](../../../../.github/workflows/ci.yml) 除正常的 push 和拉取请求事件外,也接受 `workflow_dispatch`。手动触发时会跳过优化作业和兼容性作业,只提供三个名为 `serial / linux`、`serial / macos` 和 `serial / windows` 的显式作业。这些作业有意分别重复简短的代码检出、运行时设置和依赖锁定的安装步骤,不用矩阵或可复用工作流把操作系统差异隐藏起来。 + +每个参考作业均在不设置任何分片选择器的情况下运行 `pnpm run check:ci`。`DSH_GATE_CONCURRENCY=1` 使顶层聚合每次只执行一个已经就绪的门禁;覆盖率、快照回放、built-bin 冒烟测试和发布验证的并发数也设为 1。三种操作系统的作业可以彼此并行,但每台主机上的仓库门禁都串行运行且完整执行。Linux 在回放快照前安装 bubblewrap,Windows 则在安装采用符号链接的工作区前启用开发人员模式。 + +手动参考作业仅用于诊断,不参与必需的 `all checks passed` 结果。拉取请求和 push 事件仍只运行优化通道。系统根据已完成托管作业的时间戳评估非 Windows 作业的 1 分钟目标和 Windows 作业的 3 分钟目标,并将其报告为测量结果,而不是写成 `timeout-minutes` 值。 + +可移植的参考流程使用 GitHub 标准的 `ubuntu-latest`、`macos-latest` 和 `windows-2025` 标签。仍可将更高核心数的托管运行器作为未来的基准测试,但不将其设为默认选择:更大型运行器需要组织自有的标签和预配,而参考判定基准应无需仓库外部的运行器配置即可运行。日后完成这类预配,可以改变性能实验而无需改变该正确性基线。 + +## 曾考虑的替代方案 + +- **将每个超时值设为相应延迟目标**:不予采纳,因为调度波动会中止原本正确的执行,并使诊断回归所需的证据无法产生。 +- **仅信任优化分片清单**:不予采纳,因为选择逻辑与校验逻辑共享实现假设;无分片的聚合流程是一项独立的完整性检查。 +- **在每个拉取请求上运行串行参考作业**:不予采纳,因为这些作业有意以更长的总耗时和更多运行器用量换取简单性,快速反馈循环不需要它们。 +- **使用一个操作系统矩阵**:不予采纳,因为三个具名作业无需另一套选择机制,就能让参考流程的构成清晰可见。 +- **立即把快速工作流迁移到更大型运行器**:不作为可移植的默认方案,因为这会让常规 CI 与特定组织的运行器容量耦合。等到这类容量拥有明确归属的标签和预算后,仍可将其作为一项可选实验。 + +## 后果 + +工作流包含重复的设置步骤,手动参考运行也可能比优化后的拉取请求路径耗时长得多。这些重复是有意保留的:评审人无需解析矩阵或分片清单,就能直接检查每种操作系统执行的完整命令。 + +参考流程可能暴露某些平台上的故障,而优化后的阻塞门禁集合尚未声明支持这些平台,Windows 尤其如此。这类失败反映了当前的跨平台行为,不应成为削弱或静默跳过该聚合流程的理由。 + +移除严格的时长超时后,系统会观测到延迟回归,而不是在发生回归时自动取消运行。因此,性能改动必须附带托管环境测量结果,已完成的日志则保留优化最慢通道所需的信息。 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 45b8cd5407..05427373e9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,6 +4,7 @@ on: push: branches: [main, master] pull_request: + workflow_dispatch: concurrency: group: ${{ github.workflow }}-${{ github.ref }} @@ -17,9 +18,9 @@ env: jobs: node-24: + if: github.event_name != 'workflow_dispatch' runs-on: ubuntu-latest name: node 24 / ${{ matrix.lane }} - timeout-minutes: 1 env: DSH_GATE_CONCURRENCY: ${{ matrix.gate_concurrency }} DSH_PUBLINT_CONCURRENCY: ${{ matrix.publint_concurrency }} @@ -248,9 +249,9 @@ jobs: run: ${{ matrix.command }} node-compat: + if: github.event_name != 'workflow_dispatch' runs-on: ubuntu-latest name: node ${{ matrix.node }} - timeout-minutes: 1 env: DSH_GATE_CONCURRENCY: '2' DSH_NODE_COMPAT_SKIP_TYPECHECK: ${{ matrix.skip_typecheck }} @@ -292,9 +293,9 @@ jobs: run: pnpm run check:node-compat python-sdk: + if: github.event_name != 'workflow_dispatch' runs-on: ubuntu-latest name: python 3.10 / keyless SDK - timeout-minutes: 1 steps: - uses: actions/checkout@v6 @@ -313,9 +314,9 @@ jobs: # while the broader observational gate matrix below exposes the remaining # portability work without blocking mainline merges. windows-build: + if: github.event_name != 'workflow_dispatch' runs-on: windows-2025 name: windows / build - timeout-minutes: 3 steps: - uses: actions/checkout@v6 @@ -341,10 +342,10 @@ jobs: # would change the environment being measured. This job intentionally stays # out of all-checks-passed.needs. windows-gates: + if: github.event_name != 'workflow_dispatch' continue-on-error: true runs-on: windows-2025 name: windows node 24 / ${{ matrix.lane }} - timeout-minutes: 3 env: DSH_GATE_CONCURRENCY: ${{ matrix.gate_concurrency }} DSH_PUBLINT_CONCURRENCY: ${{ matrix.publint_concurrency }} @@ -433,6 +434,104 @@ jobs: shell: pwsh run: ${{ matrix.command }} + # Manual reference runs deliberately avoid the optimized matrices above. + # Each host executes the complete, unsharded primary Node aggregate with one + # gate worker, giving reviewers a simple cross-platform oracle for completeness + # and timing. + serial-linux: + if: github.event_name == 'workflow_dispatch' + name: serial / linux + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - uses: actions/setup-node@v6 + with: + node-version: ${{ env.PRIMARY_NODE_VERSION }} + + - name: Enable corepack (pnpm) + run: corepack enable + + - name: Install (immutable) + run: pnpm install --frozen-lockfile + + - name: Install bubblewrap (unrestrict userns) + run: | + sudo apt-get update -q + sudo apt-get install -yq --no-install-recommends bubblewrap + sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 \ + || echo "apparmor userns knob absent — the functional probe decides" + + - name: Run complete unsharded primary Node CI serially + env: + DSH_COVERAGE_MAX_WORKERS: '1' + DSH_E2E_MAX_WORKERS: '1' + DSH_GATE_CONCURRENCY: '1' + DSH_PUBLINT_CONCURRENCY: '1' + DSH_SNAPSHOT_MAX_CONCURRENCY: '1' + run: pnpm run check:ci + + serial-macos: + if: github.event_name == 'workflow_dispatch' + name: serial / macos + runs-on: macos-latest + steps: + - uses: actions/checkout@v6 + + - uses: actions/setup-node@v6 + with: + node-version: ${{ env.PRIMARY_NODE_VERSION }} + + - name: Enable corepack (pnpm) + run: corepack enable + + - name: Install (immutable) + run: pnpm install --frozen-lockfile + + - name: Run complete unsharded primary Node CI serially + env: + DSH_COVERAGE_MAX_WORKERS: '1' + DSH_E2E_MAX_WORKERS: '1' + DSH_GATE_CONCURRENCY: '1' + DSH_PUBLINT_CONCURRENCY: '1' + DSH_SNAPSHOT_MAX_CONCURRENCY: '1' + run: pnpm run check:ci + + serial-windows: + if: github.event_name == 'workflow_dispatch' + name: serial / windows + runs-on: windows-2025 + steps: + - uses: actions/checkout@v6 + + - name: Enable Developer Mode (symlink support) + shell: pwsh + run: >- + reg add "HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\AppModelUnlock" + /t REG_DWORD /f /v "AllowDevelopmentWithoutDevLicense" /d "1" + + - uses: actions/setup-node@v6 + with: + node-version: ${{ env.PRIMARY_NODE_VERSION }} + + - name: Enable corepack (pnpm) + shell: pwsh + run: corepack enable + + - name: Install (immutable) + shell: pwsh + run: pnpm install --frozen-lockfile + + - name: Run complete unsharded primary Node CI serially + shell: pwsh + env: + DSH_COVERAGE_MAX_WORKERS: '1' + DSH_E2E_MAX_WORKERS: '1' + DSH_GATE_CONCURRENCY: '1' + DSH_PUBLINT_CONCURRENCY: '1' + DSH_SNAPSHOT_MAX_CONCURRENCY: '1' + run: pnpm run check:ci + # Single stable required check for branch protection: require "all checks # passed" instead of enumerating matrix legs whose names change as lanes and # node versions evolve. Every blocking job in THIS workflow must be listed in @@ -445,9 +544,8 @@ jobs: all-checks-passed: name: all checks passed runs-on: ubuntu-latest - timeout-minutes: 1 needs: [node-24, node-compat, python-sdk, windows-build] - if: always() + if: always() && github.event_name != 'workflow_dispatch' steps: - name: Fail if any needed job did not succeed if: contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') || contains(needs.*.result, 'skipped') From 9568f9ee272e32905d9593e3ee1e7dfc254eaf31 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 21 Jul 2026 23:18:49 +0800 Subject: [PATCH 13/90] ci: split Windows build and site validation --- .../2026-07-06-parallel-github-ci-gates.md | 4 +- .github/workflows/ci.yml | 58 +++++++++++-------- 2 files changed, 37 insertions(+), 25 deletions(-) diff --git a/.agents/notes/implemented/process/2026-07-06-parallel-github-ci-gates.md b/.agents/notes/implemented/process/2026-07-06-parallel-github-ci-gates.md index b657fd1e93..0e2fbf76ac 100644 --- a/.agents/notes/implemented/process/2026-07-06-parallel-github-ci-gates.md +++ b/.agents/notes/implemented/process/2026-07-06-parallel-github-ci-gates.md @@ -18,7 +18,7 @@ The artifact boundary remains load-bearing. `publint`, `verify-node-next-types`, Snapshot replay uses two explicit multi-file lanes and six scenario partitions of the large ACP file. [scripts/snapshot-shards.ts](../../../../scripts/snapshot-shards.ts) owns that inventory, and its test discovers every file admitted by the snapshot config so a new file cannot land outside CI. Each snapshot job builds the shipped runtime while its Linux runner installs bubblewrap from the hosted image's existing package index, then runs only its assigned replay surface. CI explicitly retains the suite's bounded concurrency of five subprocesses instead of clamping it to the runner's two logical CPUs, because replay spends most of its time waiting on child protocol I/O. Fixture guards still inspect the complete ACP scenario table in every partition. Static, lint, coverage, and snapshot sharding changes only GitHub scheduling: the ordinary local package scripts still run their complete suites. -Cold standalone documentation typechecking rebuilds the complete project-reference graph, so a dedicated documentation-type lane builds once and checks Markdown blocks against those declarations. The Linux documentation lane uses VitePress's MPA build to retain page rendering and dead-link validation within the observed non-Windows target; the blocking Windows build also produces the normal production SPA, preserving the shipped-site check within the observed Windows target. +Cold standalone documentation typechecking rebuilds the complete project-reference graph, so a dedicated documentation-type lane builds once and checks Markdown blocks against those declarations. The Linux documentation lane uses VitePress's MPA build to retain page rendering and dead-link validation within the observed non-Windows target; separate blocking Windows build and production-site lanes preserve the emitted-package and shipped-site checks without putting both critical paths in one job. Artifacts use two lanes: one metadata lane for `publint`, NodeNext declarations, and compiled invariant loading, plus one built-bin smoke lane. Each lane produces its own build before its consumers. Repeating the short build costs runner minutes but avoids an upload/download dependency and keeps each job's critical path bounded. @@ -26,7 +26,7 @@ Artifacts use two lanes: one metadata lane for `publint`, NodeNext declarations, Compatibility lanes run the source worker and Zstandard runtime smokes on every advertised Node line. TypeScript checks the source graph once in a dedicated primary Node 24 lane; repeating the same compiler analysis in runtime compatibility jobs added time without runtime-specific signal. -The workflow caches the pnpm store, preserves native PowerShell for Windows measurements, and retains one aggregate `all checks passed` status for branch protection. Windows reuses the three exhaustive lint partitions and groups foundation/catalog/prose plus documentation-type/API-contract gates behind shared runner setups; only scheduling differs from the Linux partitions. Windows build remains blocking, while the wider Windows static, lint, and artifact matrix remains observational. +The workflow caches the pnpm store, preserves native PowerShell for Windows measurements, and retains one aggregate `all checks passed` status for branch protection. Windows reuses the three exhaustive lint partitions and groups foundation/catalog/prose plus documentation-type/API-contract gates behind shared runner setups; only scheduling differs from the Linux partitions. Windows build and production-site validation remain blocking, while the wider Windows static, lint, and artifact matrix remains observational. ## Alternatives considered diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 05427373e9..9c5e5b0b3c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -196,12 +196,11 @@ jobs: with: node-version: ${{ env.PRIMARY_NODE_VERSION }} - - name: Enable corepack (pnpm) - run: corepack enable - - - name: Resolve pnpm store path + - name: Enable corepack and resolve pnpm store path id: pnpm-store - run: echo "path=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT" + run: | + corepack enable + echo "path=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT" - uses: actions/cache@v4 with: @@ -272,12 +271,11 @@ jobs: with: node-version: ${{ matrix.node }} - - name: Enable corepack (pnpm) - run: corepack enable - - - name: Resolve pnpm store path + - name: Enable corepack and resolve pnpm store path id: pnpm-store - run: echo "path=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT" + run: | + corepack enable + echo "path=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT" - uses: actions/cache@v4 with: @@ -310,8 +308,9 @@ jobs: - name: Run complete keyless Python suite run: uv run --python 3.10 --group test --project python/sdk pytest - # Blocking Windows build lane: keep the already-green native build protected - # while the broader observational gate matrix below exposes the remaining + # Blocking Windows build and production-site lanes keep the already-green + # native outputs protected without putting both critical paths in one job. + # The broader observational gate matrix below exposes the remaining # portability work without blocking mainline merges. windows-build: if: github.event_name != 'workflow_dispatch' @@ -324,15 +323,30 @@ jobs: with: node-version: ${{ env.PRIMARY_NODE_VERSION }} - - name: Enable corepack (pnpm) - run: corepack enable - - name: Install (immutable) - run: pnpm install --frozen-lockfile + run: | + corepack enable + pnpm install --frozen-lockfile - name: Build (tsc -b + tsdown) run: pnpm run build + windows-site: + if: github.event_name != 'workflow_dispatch' + runs-on: windows-2025 + name: windows / production site + steps: + - uses: actions/checkout@v6 + + - uses: actions/setup-node@v6 + with: + node-version: ${{ env.PRIMARY_NODE_VERSION }} + + - name: Install (immutable) + run: | + corepack enable + pnpm install --frozen-lockfile + - name: Build documentation site (production SPA) run: pnpm run docs:build @@ -402,14 +416,12 @@ jobs: with: node-version: ${{ env.PRIMARY_NODE_VERSION }} - - name: Enable corepack (pnpm) - shell: pwsh - run: corepack enable - - - name: Resolve pnpm store path + - name: Enable corepack and resolve pnpm store path id: pnpm-store shell: pwsh - run: '"path=$(pnpm store path --silent)" >> $env:GITHUB_OUTPUT' + run: | + corepack enable + "path=$(pnpm store path --silent)" >> $env:GITHUB_OUTPUT - uses: actions/cache@v4 with: @@ -544,7 +556,7 @@ jobs: all-checks-passed: name: all checks passed runs-on: ubuntu-latest - needs: [node-24, node-compat, python-sdk, windows-build] + needs: [node-24, node-compat, python-sdk, windows-build, windows-site] if: always() && github.event_name != 'workflow_dispatch' steps: - name: Fail if any needed job did not succeed From efba4c259ba46077f71016fd13eb9f9eae84e053 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 21 Jul 2026 23:25:52 +0800 Subject: [PATCH 14/90] ci: split model and integration coverage --- .github/workflows/ci.yml | 9 +++++++-- scripts/coverage-shards.ts | 8 ++++++-- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9c5e5b0b3c..a296769bb4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -92,11 +92,16 @@ jobs: gate_concurrency: '1' coverage_max_workers: '4' coverage_shard: state-session - - lane: coverage-models-integrations + - lane: coverage-models command: pnpm run check:ci:coverage gate_concurrency: '1' coverage_max_workers: '4' - coverage_shard: models-integrations + coverage_shard: models + - lane: coverage-integrations + command: pnpm run check:ci:coverage + gate_concurrency: '1' + coverage_max_workers: '4' + coverage_shard: integrations - lane: coverage-sdk-capabilities command: pnpm run check:ci:coverage gate_concurrency: '1' diff --git a/scripts/coverage-shards.ts b/scripts/coverage-shards.ts index 4bcc0277c1..dc471e33b4 100644 --- a/scripts/coverage-shards.ts +++ b/scripts/coverage-shards.ts @@ -34,8 +34,12 @@ export const coverageShards = [ ], }, { - name: 'models-integrations', - packageRoots: ['llm', 'compact', 'hooks/hook-protocol', 'lsp', 'mcp', 'hooks/hooks-claude'], + name: 'models', + packageRoots: ['llm', 'compact'], + }, + { + name: 'integrations', + packageRoots: ['hooks/hook-protocol', 'lsp', 'mcp', 'hooks/hooks-claude'], }, { name: 'sdk-capabilities', From e887c0f7c03bd5113f384d0c23cc994ddfa95199 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 21 Jul 2026 23:36:35 +0800 Subject: [PATCH 15/90] ci: assign session title coverage --- .github/workflows/ci.yml | 5 +++++ scripts/coverage-shards.ts | 4 ++++ 2 files changed, 9 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a296769bb4..d5b9d6e76f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -92,6 +92,11 @@ jobs: gate_concurrency: '1' coverage_max_workers: '4' coverage_shard: state-session + - lane: coverage-session-title + command: pnpm run check:ci:coverage + gate_concurrency: '1' + coverage_max_workers: '4' + coverage_shard: session-title - lane: coverage-models command: pnpm run check:ci:coverage gate_concurrency: '1' diff --git a/scripts/coverage-shards.ts b/scripts/coverage-shards.ts index dc471e33b4..c58665f02a 100644 --- a/scripts/coverage-shards.ts +++ b/scripts/coverage-shards.ts @@ -37,6 +37,10 @@ export const coverageShards = [ name: 'models', packageRoots: ['llm', 'compact'], }, + { + name: 'session-title', + packageRoots: ['session-title'], + }, { name: 'integrations', packageRoots: ['hooks/hook-protocol', 'lsp', 'mcp', 'hooks/hooks-claude'], From 53a9459609a7466dcf2645906804f9e6c3df09e2 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 21 Jul 2026 23:45:35 +0800 Subject: [PATCH 16/90] ci: make coverage filters path-exact --- scripts/coverage-shards.spec.ts | 5 ++++- scripts/coverage-shards.ts | 6 ++++-- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/scripts/coverage-shards.spec.ts b/scripts/coverage-shards.spec.ts index 8db6ac1aba..678011618e 100644 --- a/scripts/coverage-shards.spec.ts +++ b/scripts/coverage-shards.spec.ts @@ -28,11 +28,14 @@ describe('coverage shards', () => { it.each(coverageShards)('selects tests and source includes for $name', (shard) => { const args = coverageArgs(shard.name) for (const packageRoot of shard.packageRoots) { - expect(args).toContain(`packages/${packageRoot}`) + expect(args).toContain(`packages/${packageRoot}/`) expect(args).toContain(packageRoot.includes('/') ? `--coverage.include=packages/${packageRoot}/src/**/*.ts` : `--coverage.include=packages/${packageRoot}/*/src/**/*.ts`) } + if ('extraTestRoots' in shard) { + for (const testRoot of shard.extraTestRoots) expect(args).toContain(`${testRoot}/`) + } expect(args).toContain('scripts/test-invariants.spec.ts') expect(new Set(args).size).toBe(args.length) }) diff --git a/scripts/coverage-shards.ts b/scripts/coverage-shards.ts index c58665f02a..84f4d1a63e 100644 --- a/scripts/coverage-shards.ts +++ b/scripts/coverage-shards.ts @@ -98,9 +98,11 @@ export function coverageArgs(name: string): string[] { throw new Error(`run-gates: unknown DSH_COVERAGE_SHARD ${JSON.stringify(name)}.`) } + // Vitest positional filters are substrings; the trailing separator keeps + // prefix-named sibling packages out of each lane. const testRoots = new Set([ - ...shard.packageRoots.map(packageRoot => `packages/${packageRoot}`), - ...('extraTestRoots' in shard ? shard.extraTestRoots : []), + ...shard.packageRoots.map(packageRoot => `packages/${packageRoot}/`), + ...('extraTestRoots' in shard ? shard.extraTestRoots.map(testRoot => `${testRoot}/`) : []), 'scripts/test-invariants.spec.ts', ]) return [ From 710f062586ab12b7c077d4d0be7df2abb2f646e9 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 21 Jul 2026 23:56:53 +0800 Subject: [PATCH 17/90] ci: add snapshot lane headroom --- .../2026-07-06-parallel-github-ci-gates.md | 4 +-- .github/workflows/ci.yml | 29 +++++++++++++++---- scripts/snapshot-shards.spec.ts | 13 +++++++-- scripts/snapshot-shards.ts | 4 +-- 4 files changed, 39 insertions(+), 11 deletions(-) diff --git a/.agents/notes/implemented/process/2026-07-06-parallel-github-ci-gates.md b/.agents/notes/implemented/process/2026-07-06-parallel-github-ci-gates.md index 0e2fbf76ac..35de6cca40 100644 --- a/.agents/notes/implemented/process/2026-07-06-parallel-github-ci-gates.md +++ b/.agents/notes/implemented/process/2026-07-06-parallel-github-ci-gates.md @@ -14,9 +14,9 @@ The artifact boundary remains load-bearing. `publint`, `verify-node-next-types`, [CI](../../../../.github/workflows/ci.yml) treats one minute for non-Windows jobs and three minutes for Windows jobs as observed performance targets, not cancellation deadlines. Hosted-runner variance should leave complete timing evidence and useful failure logs instead of cancelling an otherwise-correct gate. The [serial cross-platform CI reference](2026-07-21-serial-cross-platform-ci-reference.md) independently runs the complete unsharded primary Node aggregate on Linux, macOS, and Windows so the optimized lane inventory is not its own completeness oracle. -[scripts/run-gates.ts](../../../../scripts/run-gates.ts) remains the common bounded scheduler, but GitHub supplies explicit shard names for the expensive gate families. [scripts/static-shards.ts](../../../../scripts/static-shards.ts) partitions static gates into foundation, documentation-type, API-contract, catalog, prose, documentation-projection, and documentation-build lanes and rejects a missing or duplicate gate assignment. Lint uses disjoint package-source, package-test, and repository-complement lanes; the complement still starts from `.` so a new top-level lint target cannot disappear between shards, and it owns the single cross-file duplication run. [scripts/coverage-shards.ts](../../../../scripts/coverage-shards.ts) assigns every workspace package to exactly one source-coverage lane; its test expands the live package tree, so a new package makes CI red until it has an owner. Each coverage lane includes only its owned source files, repeats the exhaustive companion topology test, and runs without a preceding build because the complete coverage suite passes from a tree with every generated `lib/` removed. +[scripts/run-gates.ts](../../../../scripts/run-gates.ts) remains the common bounded scheduler, but GitHub supplies explicit shard names for the expensive gate families. [scripts/static-shards.ts](../../../../scripts/static-shards.ts) partitions static gates into foundation, documentation-type, API-contract, catalog, prose, documentation-projection, and documentation-build lanes and rejects a missing or duplicate gate assignment. Lint uses disjoint package-source, package-test, and repository-complement lanes; the complement still starts from `.` so a new top-level lint target cannot disappear between shards, and it owns the single cross-file duplication run. [scripts/coverage-shards.ts](../../../../scripts/coverage-shards.ts) assigns every workspace package to exactly one source-coverage lane; its test expands the live package tree, so a new package makes CI red until it has an owner. Directory filters retain a trailing separator because Vitest positional filters match substrings and would otherwise admit prefix-named sibling packages. Each coverage lane includes only its owned source files, repeats the exhaustive companion topology test, and runs without a preceding build because the complete coverage suite passes from a tree with every generated `lib/` removed. -Snapshot replay uses two explicit multi-file lanes and six scenario partitions of the large ACP file. [scripts/snapshot-shards.ts](../../../../scripts/snapshot-shards.ts) owns that inventory, and its test discovers every file admitted by the snapshot config so a new file cannot land outside CI. Each snapshot job builds the shipped runtime while its Linux runner installs bubblewrap from the hosted image's existing package index, then runs only its assigned replay surface. CI explicitly retains the suite's bounded concurrency of five subprocesses instead of clamping it to the runner's two logical CPUs, because replay spends most of its time waiting on child protocol I/O. Fixture guards still inspect the complete ACP scenario table in every partition. Static, lint, coverage, and snapshot sharding changes only GitHub scheduling: the ordinary local package scripts still run their complete suites. +Snapshot replay uses two explicit multi-file lanes and eight scenario partitions of the large ACP file. [scripts/snapshot-shards.ts](../../../../scripts/snapshot-shards.ts) owns that inventory, and its test discovers every file admitted by the snapshot config so a new file cannot land outside CI. Each snapshot job installs dependencies while its Linux runner installs bubblewrap, then builds the shipped runtime and runs only its assigned replay surface. CI explicitly retains the suite's bounded concurrency of five subprocesses instead of clamping it to the runner's two logical CPUs, because replay spends most of its time waiting on child protocol I/O. Fixture guards still inspect the complete ACP scenario table in every partition. Static, lint, coverage, and snapshot sharding changes only GitHub scheduling: the ordinary local package scripts still run their complete suites. Cold standalone documentation typechecking rebuilds the complete project-reference graph, so a dedicated documentation-type lane builds once and checks Markdown blocks against those declarations. The Linux documentation lane uses VitePress's MPA build to retain page rendering and dead-link validation within the observed non-Windows target; separate blocking Windows build and production-site lanes preserve the emitted-package and shipped-site checks without putting both critical paths in one job. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d5b9d6e76f..eb946fe117 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -190,6 +190,18 @@ jobs: snapshot_lane: acp-6 snapshot_max_concurrency: '5' snapshot_prebuilt: '1' + - lane: snapshot-acp-7 + command: pnpm run check:ci:snapshot + gate_concurrency: '1' + snapshot_lane: acp-7 + snapshot_max_concurrency: '5' + snapshot_prebuilt: '1' + - lane: snapshot-acp-8 + command: pnpm run check:ci:snapshot + gate_concurrency: '1' + snapshot_lane: acp-8 + snapshot_max_concurrency: '5' + snapshot_prebuilt: '1' - lane: artifacts-metadata command: pnpm run check:ci:artifacts gate_concurrency: '3' @@ -220,6 +232,7 @@ jobs: ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm- - name: Install (immutable) + if: ${{ ! startsWith(matrix.lane, 'snapshot-') }} run: pnpm install --frozen-lockfile # The snapshot lanes REPLAY the sandbox example's recorded scenarios, @@ -227,22 +240,28 @@ jobs: # no bubblewrap preinstalled and no built Landlock launcher, so without # this the confined executions fail closed (SANDBOX_UNAVAILABLE). Same # install as sandbox.yml's bwrap leg (incl. the Ubuntu 24.04 AppArmor - # userns knob). Building does not depend on bubblewrap, so overlap them. - - name: Prepare built snapshot runtime and bubblewrap + # userns knob). Bubblewrap preparation is independent of dependency + # installation and the build, so it runs beside both. + - name: Install and prepare built snapshot runtime and bubblewrap if: startsWith(matrix.lane, 'snapshot-') run: | - pnpm run build & - build_pid=$! + pnpm install --frozen-lockfile & + install_pid=$! ( sudo apt-get install -yq --no-install-recommends bubblewrap sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 \ || echo "apparmor userns knob absent — the functional probe decides" ) & sandbox_pid=$! + install_status=0 + wait "$install_pid" || install_status=$? build_status=0 + if (( install_status == 0 )); then + pnpm run build || build_status=$? + fi sandbox_status=0 - wait "$build_pid" || build_status=$? wait "$sandbox_pid" || sandbox_status=$? + if (( install_status != 0 )); then exit "$install_status"; fi if (( build_status != 0 )); then exit "$build_status"; fi exit "$sandbox_status" diff --git a/scripts/snapshot-shards.spec.ts b/scripts/snapshot-shards.spec.ts index 7cc37fb7bd..1b8db7dc39 100644 --- a/scripts/snapshot-shards.spec.ts +++ b/scripts/snapshot-shards.spec.ts @@ -26,10 +26,19 @@ describe('snapshot lanes', () => { expect(new Set(ordinary).size).toBe(ordinary.length) expect(acp.map(lane => lane.files)).toEqual(Array.from( - { length: 6 }, + { length: 8 }, () => ['examples/acp-agent/tests/acp.snapshot.ts'], )) - expect(acp.map(lane => lane.scenarioShard)).toEqual(['1/6', '2/6', '3/6', '4/6', '5/6', '6/6']) + expect(acp.map(lane => lane.scenarioShard)).toEqual([ + '1/8', + '2/8', + '3/8', + '4/8', + '5/8', + '6/8', + '7/8', + '8/8', + ]) expect([...ordinary, 'examples/acp-agent/tests/acp.snapshot.ts'].sort()).toEqual(discovered) }) diff --git a/scripts/snapshot-shards.ts b/scripts/snapshot-shards.ts index fae8010261..5439be94fb 100644 --- a/scripts/snapshot-shards.ts +++ b/scripts/snapshot-shards.ts @@ -28,10 +28,10 @@ export const snapshotLanes: readonly SnapshotLane[] = [ 'examples/headless-agent/tests/headless.snapshot.ts', ], }, - ...Array.from({ length: 6 }, (_, offset) => ({ + ...Array.from({ length: 8 }, (_, offset) => ({ name: `acp-${offset + 1}`, files: ['examples/acp-agent/tests/acp.snapshot.ts'], - scenarioShard: `${offset + 1}/6`, + scenarioShard: `${offset + 1}/8`, })), ] From 624d71aea676dbdd62f7391a42b790c82045e138 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Wed, 22 Jul 2026 00:11:48 +0800 Subject: [PATCH 18/90] ci: harden runner preparation caches --- .../2026-07-06-parallel-github-ci-gates.md | 4 ++-- .github/workflows/ci.yml | 15 ++++++++------- 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/.agents/notes/implemented/process/2026-07-06-parallel-github-ci-gates.md b/.agents/notes/implemented/process/2026-07-06-parallel-github-ci-gates.md index 35de6cca40..1327e4dd39 100644 --- a/.agents/notes/implemented/process/2026-07-06-parallel-github-ci-gates.md +++ b/.agents/notes/implemented/process/2026-07-06-parallel-github-ci-gates.md @@ -16,7 +16,7 @@ The artifact boundary remains load-bearing. `publint`, `verify-node-next-types`, [scripts/run-gates.ts](../../../../scripts/run-gates.ts) remains the common bounded scheduler, but GitHub supplies explicit shard names for the expensive gate families. [scripts/static-shards.ts](../../../../scripts/static-shards.ts) partitions static gates into foundation, documentation-type, API-contract, catalog, prose, documentation-projection, and documentation-build lanes and rejects a missing or duplicate gate assignment. Lint uses disjoint package-source, package-test, and repository-complement lanes; the complement still starts from `.` so a new top-level lint target cannot disappear between shards, and it owns the single cross-file duplication run. [scripts/coverage-shards.ts](../../../../scripts/coverage-shards.ts) assigns every workspace package to exactly one source-coverage lane; its test expands the live package tree, so a new package makes CI red until it has an owner. Directory filters retain a trailing separator because Vitest positional filters match substrings and would otherwise admit prefix-named sibling packages. Each coverage lane includes only its owned source files, repeats the exhaustive companion topology test, and runs without a preceding build because the complete coverage suite passes from a tree with every generated `lib/` removed. -Snapshot replay uses two explicit multi-file lanes and eight scenario partitions of the large ACP file. [scripts/snapshot-shards.ts](../../../../scripts/snapshot-shards.ts) owns that inventory, and its test discovers every file admitted by the snapshot config so a new file cannot land outside CI. Each snapshot job installs dependencies while its Linux runner installs bubblewrap, then builds the shipped runtime and runs only its assigned replay surface. CI explicitly retains the suite's bounded concurrency of five subprocesses instead of clamping it to the runner's two logical CPUs, because replay spends most of its time waiting on child protocol I/O. Fixture guards still inspect the complete ACP scenario table in every partition. Static, lint, coverage, and snapshot sharding changes only GitHub scheduling: the ordinary local package scripts still run their complete suites. +Snapshot replay uses two explicit multi-file lanes and eight scenario partitions of the large ACP file. [scripts/snapshot-shards.ts](../../../../scripts/snapshot-shards.ts) owns that inventory, and its test discovers every file admitted by the snapshot config so a new file cannot land outside CI. Each snapshot job installs dependencies while its Linux runner refreshes APT indexes and installs bubblewrap, then builds the shipped runtime and runs only its assigned replay surface. CI explicitly retains the suite's bounded concurrency of five subprocesses instead of clamping it to the runner's two logical CPUs, because replay spends most of its time waiting on child protocol I/O. Fixture guards still inspect the complete ACP scenario table in every partition. Static, lint, coverage, and snapshot sharding changes only GitHub scheduling: the ordinary local package scripts still run their complete suites. Cold standalone documentation typechecking rebuilds the complete project-reference graph, so a dedicated documentation-type lane builds once and checks Markdown blocks against those declarations. The Linux documentation lane uses VitePress's MPA build to retain page rendering and dead-link validation within the observed non-Windows target; separate blocking Windows build and production-site lanes preserve the emitted-package and shipped-site checks without putting both critical paths in one job. @@ -26,7 +26,7 @@ Artifacts use two lanes: one metadata lane for `publint`, NodeNext declarations, Compatibility lanes run the source worker and Zstandard runtime smokes on every advertised Node line. TypeScript checks the source graph once in a dedicated primary Node 24 lane; repeating the same compiler analysis in runtime compatibility jobs added time without runtime-specific signal. -The workflow caches the pnpm store, preserves native PowerShell for Windows measurements, and retains one aggregate `all checks passed` status for branch protection. Windows reuses the three exhaustive lint partitions and groups foundation/catalog/prose plus documentation-type/API-contract gates behind shared runner setups; only scheduling differs from the Linux partitions. Windows build and production-site validation remain blocking, while the wider Windows static, lint, and artifact matrix remains observational. +The workflow caches the pnpm store, keys each immutable ESLint cache to its owning lint shard, preserves native PowerShell for Windows measurements, and retains one aggregate `all checks passed` status for branch protection. Windows reuses the three exhaustive lint partitions and groups foundation/catalog/prose plus documentation-type/API-contract gates behind shared runner setups; only scheduling differs from the Linux partitions. Windows build and production-site validation remain blocking, while the wider Windows static, lint, and artifact matrix remains observational. ## Alternatives considered diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index eb946fe117..fef6e6c2d9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -239,15 +239,16 @@ jobs: # re-executing their bash calls under a real runner. ubuntu-latest has # no bubblewrap preinstalled and no built Landlock launcher, so without # this the confined executions fail closed (SANDBOX_UNAVAILABLE). Same - # install as sandbox.yml's bwrap leg (incl. the Ubuntu 24.04 AppArmor - # userns knob). Bubblewrap preparation is independent of dependency - # installation and the build, so it runs beside both. + # refreshed install as sandbox.yml's bwrap leg (incl. the Ubuntu 24.04 + # AppArmor userns knob). Bubblewrap preparation is independent of + # dependency installation and the build, so it runs beside both. - name: Install and prepare built snapshot runtime and bubblewrap if: startsWith(matrix.lane, 'snapshot-') run: | pnpm install --frozen-lockfile & install_pid=$! ( + sudo apt-get update -q sudo apt-get install -yq --no-install-recommends bubblewrap sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 \ || echo "apparmor userns knob absent — the functional probe decides" @@ -269,9 +270,9 @@ jobs: if: startsWith(matrix.lane, 'lint-') with: path: .cache/eslint - key: ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-eslint-${{ hashFiles('pnpm-lock.yaml', 'eslint.config.mjs', 'tsconfig.json', 'packages/*/*/tsconfig.json', 'examples/*/tsconfig.json') }} + key: ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-eslint-${{ matrix.lint_shard }}-${{ hashFiles('pnpm-lock.yaml', 'eslint.config.mjs', 'tsconfig.json', 'packages/*/*/tsconfig.json', 'examples/*/tsconfig.json') }} restore-keys: | - ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-eslint- + ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-eslint-${{ matrix.lint_shard }}- - name: Run gates run: ${{ matrix.command }} @@ -467,9 +468,9 @@ jobs: if: startsWith(matrix.lane, 'lint-') with: path: .cache/eslint - key: ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-eslint-${{ hashFiles('pnpm-lock.yaml', 'eslint.config.mjs', 'tsconfig.json', 'packages/*/*/tsconfig.json', 'examples/*/tsconfig.json') }} + key: ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-eslint-${{ matrix.lint_shard }}-${{ hashFiles('pnpm-lock.yaml', 'eslint.config.mjs', 'tsconfig.json', 'packages/*/*/tsconfig.json', 'examples/*/tsconfig.json') }} restore-keys: | - ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-eslint- + ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-eslint-${{ matrix.lint_shard }}- - name: Run gates shell: pwsh From 070d23d6633dfcb673e5f74409cb9f41b042932d Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Wed, 22 Jul 2026 00:22:06 +0800 Subject: [PATCH 19/90] ci: keep cold-cache lanes bounded --- .../2026-07-06-parallel-github-ci-gates.md | 4 +- .github/workflows/ci.yml | 49 ++++++++++++++----- scripts/lint-shards.spec.ts | 33 +++++++++++-- scripts/lint-shards.ts | 12 +++-- 4 files changed, 74 insertions(+), 24 deletions(-) diff --git a/.agents/notes/implemented/process/2026-07-06-parallel-github-ci-gates.md b/.agents/notes/implemented/process/2026-07-06-parallel-github-ci-gates.md index 1327e4dd39..184f400a83 100644 --- a/.agents/notes/implemented/process/2026-07-06-parallel-github-ci-gates.md +++ b/.agents/notes/implemented/process/2026-07-06-parallel-github-ci-gates.md @@ -14,9 +14,9 @@ The artifact boundary remains load-bearing. `publint`, `verify-node-next-types`, [CI](../../../../.github/workflows/ci.yml) treats one minute for non-Windows jobs and three minutes for Windows jobs as observed performance targets, not cancellation deadlines. Hosted-runner variance should leave complete timing evidence and useful failure logs instead of cancelling an otherwise-correct gate. The [serial cross-platform CI reference](2026-07-21-serial-cross-platform-ci-reference.md) independently runs the complete unsharded primary Node aggregate on Linux, macOS, and Windows so the optimized lane inventory is not its own completeness oracle. -[scripts/run-gates.ts](../../../../scripts/run-gates.ts) remains the common bounded scheduler, but GitHub supplies explicit shard names for the expensive gate families. [scripts/static-shards.ts](../../../../scripts/static-shards.ts) partitions static gates into foundation, documentation-type, API-contract, catalog, prose, documentation-projection, and documentation-build lanes and rejects a missing or duplicate gate assignment. Lint uses disjoint package-source, package-test, and repository-complement lanes; the complement still starts from `.` so a new top-level lint target cannot disappear between shards, and it owns the single cross-file duplication run. [scripts/coverage-shards.ts](../../../../scripts/coverage-shards.ts) assigns every workspace package to exactly one source-coverage lane; its test expands the live package tree, so a new package makes CI red until it has an owner. Directory filters retain a trailing separator because Vitest positional filters match substrings and would otherwise admit prefix-named sibling packages. Each coverage lane includes only its owned source files, repeats the exhaustive companion topology test, and runs without a preceding build because the complete coverage suite passes from a tree with every generated `lib/` removed. +[scripts/run-gates.ts](../../../../scripts/run-gates.ts) remains the common bounded scheduler, but GitHub supplies explicit shard names for the expensive gate families. [scripts/static-shards.ts](../../../../scripts/static-shards.ts) partitions static gates into foundation, documentation-type, API-contract, catalog, prose, documentation-projection, and documentation-build lanes and rejects a missing or duplicate gate assignment. Lint uses disjoint A-M and N-Z package-source and package-test lanes plus a repository complement; the complement still starts from `.` so a new top-level lint target cannot disappear between shards, and it owns the single cross-file duplication run. [scripts/coverage-shards.ts](../../../../scripts/coverage-shards.ts) assigns every workspace package to exactly one source-coverage lane; its test expands the live package tree, so a new package makes CI red until it has an owner. Directory filters retain a trailing separator because Vitest positional filters match substrings and would otherwise admit prefix-named sibling packages. Each coverage lane includes only its owned source files, repeats the exhaustive companion topology test, and runs without a preceding build because the complete coverage suite passes from a tree with every generated `lib/` removed. -Snapshot replay uses two explicit multi-file lanes and eight scenario partitions of the large ACP file. [scripts/snapshot-shards.ts](../../../../scripts/snapshot-shards.ts) owns that inventory, and its test discovers every file admitted by the snapshot config so a new file cannot land outside CI. Each snapshot job installs dependencies while its Linux runner refreshes APT indexes and installs bubblewrap, then builds the shipped runtime and runs only its assigned replay surface. CI explicitly retains the suite's bounded concurrency of five subprocesses instead of clamping it to the runner's two logical CPUs, because replay spends most of its time waiting on child protocol I/O. Fixture guards still inspect the complete ACP scenario table in every partition. Static, lint, coverage, and snapshot sharding changes only GitHub scheduling: the ordinary local package scripts still run their complete suites. +Snapshot replay uses two explicit multi-file lanes and eight scenario partitions of the large ACP file. [scripts/snapshot-shards.ts](../../../../scripts/snapshot-shards.ts) owns that inventory, and its test discovers every file admitted by the snapshot config so a new file cannot land outside CI. Each snapshot job installs dependencies while its Linux runner installs bubblewrap, refreshing APT indexes and retrying if the hosted image's initial package install fails, then builds the shipped runtime and runs only its assigned replay surface. CI explicitly retains the suite's bounded concurrency of five subprocesses instead of clamping it to the runner's two logical CPUs, because replay spends most of its time waiting on child protocol I/O. Fixture guards still inspect the complete ACP scenario table in every partition. Static, lint, coverage, and snapshot sharding changes only GitHub scheduling: the ordinary local package scripts still run their complete suites. Cold standalone documentation typechecking rebuilds the complete project-reference graph, so a dedicated documentation-type lane builds once and checks Markdown blocks against those declarations. The Linux documentation lane uses VitePress's MPA build to retain page rendering and dead-link validation within the observed non-Windows target; separate blocking Windows build and production-site lanes preserve the emitted-package and shipped-site checks without putting both critical paths in one job. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fef6e6c2d9..a280b7316a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -67,16 +67,26 @@ jobs: static_shard: site-build - lane: typecheck command: pnpm run typecheck - - lane: lint-package-sources + - lane: lint-package-sources-a-m command: pnpm run check:ci:lint gate_concurrency: '1' eslint_cache: '1' - lint_shard: package-sources - - lane: lint-package-tests + lint_shard: package-sources-a-m + - lane: lint-package-sources-n-z command: pnpm run check:ci:lint gate_concurrency: '1' eslint_cache: '1' - lint_shard: package-tests + lint_shard: package-sources-n-z + - lane: lint-package-tests-a-m + command: pnpm run check:ci:lint + gate_concurrency: '1' + eslint_cache: '1' + lint_shard: package-tests-a-m + - lane: lint-package-tests-n-z + command: pnpm run check:ci:lint + gate_concurrency: '1' + eslint_cache: '1' + lint_shard: package-tests-n-z - lane: lint-repository command: pnpm run check:ci:lint gate_concurrency: '1' @@ -238,9 +248,9 @@ jobs: # The snapshot lanes REPLAY the sandbox example's recorded scenarios, # re-executing their bash calls under a real runner. ubuntu-latest has # no bubblewrap preinstalled and no built Landlock launcher, so without - # this the confined executions fail closed (SANDBOX_UNAVAILABLE). Same - # refreshed install as sandbox.yml's bwrap leg (incl. the Ubuntu 24.04 - # AppArmor userns knob). Bubblewrap preparation is independent of + # this the confined executions fail closed (SANDBOX_UNAVAILABLE). The + # install retries after refreshing stale indexes and applies the Ubuntu + # 24.04 AppArmor userns knob. Bubblewrap preparation is independent of # dependency installation and the build, so it runs beside both. - name: Install and prepare built snapshot runtime and bubblewrap if: startsWith(matrix.lane, 'snapshot-') @@ -248,8 +258,11 @@ jobs: pnpm install --frozen-lockfile & install_pid=$! ( - sudo apt-get update -q - sudo apt-get install -yq --no-install-recommends bubblewrap + if ! sudo apt-get install -yq --no-install-recommends bubblewrap; then + echo "initial bubblewrap install failed; refreshing APT indexes and retrying" + sudo apt-get update -q + sudo apt-get install -yq --no-install-recommends bubblewrap + fi sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 \ || echo "apparmor userns knob absent — the functional probe decides" ) & @@ -409,16 +422,26 @@ jobs: command: pnpm run check:ci:static gate_concurrency: '4' static_shard: 'doc-types,api-contracts' - - lane: lint-package-sources + - lane: lint-package-sources-a-m command: pnpm run check:ci:lint gate_concurrency: '1' eslint_cache: '1' - lint_shard: package-sources - - lane: lint-package-tests + lint_shard: package-sources-a-m + - lane: lint-package-sources-n-z command: pnpm run check:ci:lint gate_concurrency: '1' eslint_cache: '1' - lint_shard: package-tests + lint_shard: package-sources-n-z + - lane: lint-package-tests-a-m + command: pnpm run check:ci:lint + gate_concurrency: '1' + eslint_cache: '1' + lint_shard: package-tests-a-m + - lane: lint-package-tests-n-z + command: pnpm run check:ci:lint + gate_concurrency: '1' + eslint_cache: '1' + lint_shard: package-tests-n-z - lane: lint-repository command: pnpm run check:ci:lint gate_concurrency: '1' diff --git a/scripts/lint-shards.spec.ts b/scripts/lint-shards.spec.ts index df50790260..51ac21e0bd 100644 --- a/scripts/lint-shards.spec.ts +++ b/scripts/lint-shards.spec.ts @@ -1,19 +1,31 @@ +import { readdirSync } from 'node:fs' +import { resolve } from 'node:path' import { describe, expect, it } from 'vitest' import { selectLintShard } from './lint-shards.ts' +const packagesRoot = resolve(import.meta.dirname, '..', 'packages') + describe('lint gate shards', () => { it('keeps the unsharded local command complete', () => { expect(selectLintShard()).toEqual({ eslintTargets: ['.'], includeDuplication: true }) expect(selectLintShard('')).toEqual({ eslintTargets: ['.'], includeDuplication: true }) }) - it('partitions package sources, package tests, and their repository complement', () => { - expect(selectLintShard('package-sources')).toEqual({ - eslintTargets: ['packages/*/*/src/**/*.ts'], + it('partitions package sources and tests into alphabetic halves plus their repository complement', () => { + expect(selectLintShard('package-sources-a-m')).toEqual({ + eslintTargets: ['packages/[a-m]*/*/src/**/*.ts'], includeDuplication: false, }) - expect(selectLintShard('package-tests')).toEqual({ - eslintTargets: ['packages/*/*/tests/**/*.ts'], + expect(selectLintShard('package-sources-n-z')).toEqual({ + eslintTargets: ['packages/[n-z]*/*/src/**/*.ts'], + includeDuplication: false, + }) + expect(selectLintShard('package-tests-a-m')).toEqual({ + eslintTargets: ['packages/[a-m]*/*/tests/**/*.ts'], + includeDuplication: false, + }) + expect(selectLintShard('package-tests-n-z')).toEqual({ + eslintTargets: ['packages/[n-z]*/*/tests/**/*.ts'], includeDuplication: false, }) expect(selectLintShard('repository')).toEqual({ @@ -28,6 +40,17 @@ describe('lint gate shards', () => { }) }) + it('assigns every package group to one alphabetic half', () => { + const groups = readdirSync(packagesRoot, { withFileTypes: true }) + .filter(entry => entry.isDirectory()) + .map(entry => entry.name) + .sort() + const firstHalf = groups.filter(group => /^[a-m]/u.test(group)) + const secondHalf = groups.filter(group => /^[n-z]/u.test(group)) + + expect([...firstHalf, ...secondHalf].sort()).toEqual(groups) + }) + it('rejects an unknown lane', () => { expect(() => selectLintShard('missing')).toThrow('unknown DSH_LINT_SHARD') }) diff --git a/scripts/lint-shards.ts b/scripts/lint-shards.ts index ec09a7aca2..a48e9ce257 100644 --- a/scripts/lint-shards.ts +++ b/scripts/lint-shards.ts @@ -19,10 +19,14 @@ export function selectLintShard(name?: string): LintSelection { case undefined: case '': return { eslintTargets: ['.'], includeDuplication: true } - case 'package-sources': - return { eslintTargets: ['packages/*/*/src/**/*.ts'], includeDuplication: false } - case 'package-tests': - return { eslintTargets: ['packages/*/*/tests/**/*.ts'], includeDuplication: false } + case 'package-sources-a-m': + return { eslintTargets: ['packages/[a-m]*/*/src/**/*.ts'], includeDuplication: false } + case 'package-sources-n-z': + return { eslintTargets: ['packages/[n-z]*/*/src/**/*.ts'], includeDuplication: false } + case 'package-tests-a-m': + return { eslintTargets: ['packages/[a-m]*/*/tests/**/*.ts'], includeDuplication: false } + case 'package-tests-n-z': + return { eslintTargets: ['packages/[n-z]*/*/tests/**/*.ts'], includeDuplication: false } case 'repository': return { eslintTargets: [ From 109224af5d66ec3c35b6cdb4c22c6f98a48b0168 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Wed, 22 Jul 2026 00:31:25 +0800 Subject: [PATCH 20/90] ci: quarter cold-cache lint lanes --- .../2026-07-06-parallel-github-ci-gates.md | 2 +- .github/workflows/ci.yml | 72 ++++++++++++++----- scripts/lint-shards.spec.ts | 42 +++++++---- scripts/lint-shards.ts | 24 ++++--- 4 files changed, 102 insertions(+), 38 deletions(-) diff --git a/.agents/notes/implemented/process/2026-07-06-parallel-github-ci-gates.md b/.agents/notes/implemented/process/2026-07-06-parallel-github-ci-gates.md index 184f400a83..aeab7cbfbb 100644 --- a/.agents/notes/implemented/process/2026-07-06-parallel-github-ci-gates.md +++ b/.agents/notes/implemented/process/2026-07-06-parallel-github-ci-gates.md @@ -14,7 +14,7 @@ The artifact boundary remains load-bearing. `publint`, `verify-node-next-types`, [CI](../../../../.github/workflows/ci.yml) treats one minute for non-Windows jobs and three minutes for Windows jobs as observed performance targets, not cancellation deadlines. Hosted-runner variance should leave complete timing evidence and useful failure logs instead of cancelling an otherwise-correct gate. The [serial cross-platform CI reference](2026-07-21-serial-cross-platform-ci-reference.md) independently runs the complete unsharded primary Node aggregate on Linux, macOS, and Windows so the optimized lane inventory is not its own completeness oracle. -[scripts/run-gates.ts](../../../../scripts/run-gates.ts) remains the common bounded scheduler, but GitHub supplies explicit shard names for the expensive gate families. [scripts/static-shards.ts](../../../../scripts/static-shards.ts) partitions static gates into foundation, documentation-type, API-contract, catalog, prose, documentation-projection, and documentation-build lanes and rejects a missing or duplicate gate assignment. Lint uses disjoint A-M and N-Z package-source and package-test lanes plus a repository complement; the complement still starts from `.` so a new top-level lint target cannot disappear between shards, and it owns the single cross-file duplication run. [scripts/coverage-shards.ts](../../../../scripts/coverage-shards.ts) assigns every workspace package to exactly one source-coverage lane; its test expands the live package tree, so a new package makes CI red until it has an owner. Directory filters retain a trailing separator because Vitest positional filters match substrings and would otherwise admit prefix-named sibling packages. Each coverage lane includes only its owned source files, repeats the exhaustive companion topology test, and runs without a preceding build because the complete coverage suite passes from a tree with every generated `lib/` removed. +[scripts/run-gates.ts](../../../../scripts/run-gates.ts) remains the common bounded scheduler, but GitHub supplies explicit shard names for the expensive gate families. [scripts/static-shards.ts](../../../../scripts/static-shards.ts) partitions static gates into foundation, documentation-type, API-contract, catalog, prose, documentation-projection, and documentation-build lanes and rejects a missing or duplicate gate assignment. Lint uses disjoint A-C, D-M, N-S, and T-Z package-source and package-test lanes plus a repository complement; the complement still starts from `.` so a new top-level lint target cannot disappear between shards, and it owns the single cross-file duplication run. [scripts/coverage-shards.ts](../../../../scripts/coverage-shards.ts) assigns every workspace package to exactly one source-coverage lane; its test expands the live package tree, so a new package makes CI red until it has an owner. Directory filters retain a trailing separator because Vitest positional filters match substrings and would otherwise admit prefix-named sibling packages. Each coverage lane includes only its owned source files, repeats the exhaustive companion topology test, and runs without a preceding build because the complete coverage suite passes from a tree with every generated `lib/` removed. Snapshot replay uses two explicit multi-file lanes and eight scenario partitions of the large ACP file. [scripts/snapshot-shards.ts](../../../../scripts/snapshot-shards.ts) owns that inventory, and its test discovers every file admitted by the snapshot config so a new file cannot land outside CI. Each snapshot job installs dependencies while its Linux runner installs bubblewrap, refreshing APT indexes and retrying if the hosted image's initial package install fails, then builds the shipped runtime and runs only its assigned replay surface. CI explicitly retains the suite's bounded concurrency of five subprocesses instead of clamping it to the runner's two logical CPUs, because replay spends most of its time waiting on child protocol I/O. Fixture guards still inspect the complete ACP scenario table in every partition. Static, lint, coverage, and snapshot sharding changes only GitHub scheduling: the ordinary local package scripts still run their complete suites. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a280b7316a..79dcfcffcf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -67,26 +67,46 @@ jobs: static_shard: site-build - lane: typecheck command: pnpm run typecheck - - lane: lint-package-sources-a-m + - lane: lint-package-sources-a-c command: pnpm run check:ci:lint gate_concurrency: '1' eslint_cache: '1' - lint_shard: package-sources-a-m - - lane: lint-package-sources-n-z + lint_shard: package-sources-a-c + - lane: lint-package-sources-d-m command: pnpm run check:ci:lint gate_concurrency: '1' eslint_cache: '1' - lint_shard: package-sources-n-z - - lane: lint-package-tests-a-m + lint_shard: package-sources-d-m + - lane: lint-package-sources-n-s command: pnpm run check:ci:lint gate_concurrency: '1' eslint_cache: '1' - lint_shard: package-tests-a-m - - lane: lint-package-tests-n-z + lint_shard: package-sources-n-s + - lane: lint-package-sources-t-z command: pnpm run check:ci:lint gate_concurrency: '1' eslint_cache: '1' - lint_shard: package-tests-n-z + lint_shard: package-sources-t-z + - lane: lint-package-tests-a-c + command: pnpm run check:ci:lint + gate_concurrency: '1' + eslint_cache: '1' + lint_shard: package-tests-a-c + - lane: lint-package-tests-d-m + command: pnpm run check:ci:lint + gate_concurrency: '1' + eslint_cache: '1' + lint_shard: package-tests-d-m + - lane: lint-package-tests-n-s + command: pnpm run check:ci:lint + gate_concurrency: '1' + eslint_cache: '1' + lint_shard: package-tests-n-s + - lane: lint-package-tests-t-z + command: pnpm run check:ci:lint + gate_concurrency: '1' + eslint_cache: '1' + lint_shard: package-tests-t-z - lane: lint-repository command: pnpm run check:ci:lint gate_concurrency: '1' @@ -422,26 +442,46 @@ jobs: command: pnpm run check:ci:static gate_concurrency: '4' static_shard: 'doc-types,api-contracts' - - lane: lint-package-sources-a-m + - lane: lint-package-sources-a-c command: pnpm run check:ci:lint gate_concurrency: '1' eslint_cache: '1' - lint_shard: package-sources-a-m - - lane: lint-package-sources-n-z + lint_shard: package-sources-a-c + - lane: lint-package-sources-d-m command: pnpm run check:ci:lint gate_concurrency: '1' eslint_cache: '1' - lint_shard: package-sources-n-z - - lane: lint-package-tests-a-m + lint_shard: package-sources-d-m + - lane: lint-package-sources-n-s command: pnpm run check:ci:lint gate_concurrency: '1' eslint_cache: '1' - lint_shard: package-tests-a-m - - lane: lint-package-tests-n-z + lint_shard: package-sources-n-s + - lane: lint-package-sources-t-z command: pnpm run check:ci:lint gate_concurrency: '1' eslint_cache: '1' - lint_shard: package-tests-n-z + lint_shard: package-sources-t-z + - lane: lint-package-tests-a-c + command: pnpm run check:ci:lint + gate_concurrency: '1' + eslint_cache: '1' + lint_shard: package-tests-a-c + - lane: lint-package-tests-d-m + command: pnpm run check:ci:lint + gate_concurrency: '1' + eslint_cache: '1' + lint_shard: package-tests-d-m + - lane: lint-package-tests-n-s + command: pnpm run check:ci:lint + gate_concurrency: '1' + eslint_cache: '1' + lint_shard: package-tests-n-s + - lane: lint-package-tests-t-z + command: pnpm run check:ci:lint + gate_concurrency: '1' + eslint_cache: '1' + lint_shard: package-tests-t-z - lane: lint-repository command: pnpm run check:ci:lint gate_concurrency: '1' diff --git a/scripts/lint-shards.spec.ts b/scripts/lint-shards.spec.ts index 51ac21e0bd..540c4b88c4 100644 --- a/scripts/lint-shards.spec.ts +++ b/scripts/lint-shards.spec.ts @@ -11,21 +11,37 @@ describe('lint gate shards', () => { expect(selectLintShard('')).toEqual({ eslintTargets: ['.'], includeDuplication: true }) }) - it('partitions package sources and tests into alphabetic halves plus their repository complement', () => { - expect(selectLintShard('package-sources-a-m')).toEqual({ - eslintTargets: ['packages/[a-m]*/*/src/**/*.ts'], + it('partitions package sources and tests into alphabetic ranges plus their repository complement', () => { + expect(selectLintShard('package-sources-a-c')).toEqual({ + eslintTargets: ['packages/[a-c]*/*/src/**/*.ts'], includeDuplication: false, }) - expect(selectLintShard('package-sources-n-z')).toEqual({ - eslintTargets: ['packages/[n-z]*/*/src/**/*.ts'], + expect(selectLintShard('package-sources-d-m')).toEqual({ + eslintTargets: ['packages/[d-m]*/*/src/**/*.ts'], includeDuplication: false, }) - expect(selectLintShard('package-tests-a-m')).toEqual({ - eslintTargets: ['packages/[a-m]*/*/tests/**/*.ts'], + expect(selectLintShard('package-sources-n-s')).toEqual({ + eslintTargets: ['packages/[n-s]*/*/src/**/*.ts'], includeDuplication: false, }) - expect(selectLintShard('package-tests-n-z')).toEqual({ - eslintTargets: ['packages/[n-z]*/*/tests/**/*.ts'], + expect(selectLintShard('package-sources-t-z')).toEqual({ + eslintTargets: ['packages/[t-z]*/*/src/**/*.ts'], + includeDuplication: false, + }) + expect(selectLintShard('package-tests-a-c')).toEqual({ + eslintTargets: ['packages/[a-c]*/*/tests/**/*.ts'], + includeDuplication: false, + }) + expect(selectLintShard('package-tests-d-m')).toEqual({ + eslintTargets: ['packages/[d-m]*/*/tests/**/*.ts'], + includeDuplication: false, + }) + expect(selectLintShard('package-tests-n-s')).toEqual({ + eslintTargets: ['packages/[n-s]*/*/tests/**/*.ts'], + includeDuplication: false, + }) + expect(selectLintShard('package-tests-t-z')).toEqual({ + eslintTargets: ['packages/[t-z]*/*/tests/**/*.ts'], includeDuplication: false, }) expect(selectLintShard('repository')).toEqual({ @@ -40,15 +56,15 @@ describe('lint gate shards', () => { }) }) - it('assigns every package group to one alphabetic half', () => { + it('assigns every package group to one alphabetic range', () => { const groups = readdirSync(packagesRoot, { withFileTypes: true }) .filter(entry => entry.isDirectory()) .map(entry => entry.name) .sort() - const firstHalf = groups.filter(group => /^[a-m]/u.test(group)) - const secondHalf = groups.filter(group => /^[n-z]/u.test(group)) + const ranges = [/^[a-c]/u, /^[d-m]/u, /^[n-s]/u, /^[t-z]/u] + const assignments = ranges.flatMap(range => groups.filter(group => range.test(group))).sort() - expect([...firstHalf, ...secondHalf].sort()).toEqual(groups) + expect(assignments).toEqual(groups) }) it('rejects an unknown lane', () => { diff --git a/scripts/lint-shards.ts b/scripts/lint-shards.ts index a48e9ce257..e9a12c3e80 100644 --- a/scripts/lint-shards.ts +++ b/scripts/lint-shards.ts @@ -19,14 +19,22 @@ export function selectLintShard(name?: string): LintSelection { case undefined: case '': return { eslintTargets: ['.'], includeDuplication: true } - case 'package-sources-a-m': - return { eslintTargets: ['packages/[a-m]*/*/src/**/*.ts'], includeDuplication: false } - case 'package-sources-n-z': - return { eslintTargets: ['packages/[n-z]*/*/src/**/*.ts'], includeDuplication: false } - case 'package-tests-a-m': - return { eslintTargets: ['packages/[a-m]*/*/tests/**/*.ts'], includeDuplication: false } - case 'package-tests-n-z': - return { eslintTargets: ['packages/[n-z]*/*/tests/**/*.ts'], includeDuplication: false } + case 'package-sources-a-c': + return { eslintTargets: ['packages/[a-c]*/*/src/**/*.ts'], includeDuplication: false } + case 'package-sources-d-m': + return { eslintTargets: ['packages/[d-m]*/*/src/**/*.ts'], includeDuplication: false } + case 'package-sources-n-s': + return { eslintTargets: ['packages/[n-s]*/*/src/**/*.ts'], includeDuplication: false } + case 'package-sources-t-z': + return { eslintTargets: ['packages/[t-z]*/*/src/**/*.ts'], includeDuplication: false } + case 'package-tests-a-c': + return { eslintTargets: ['packages/[a-c]*/*/tests/**/*.ts'], includeDuplication: false } + case 'package-tests-d-m': + return { eslintTargets: ['packages/[d-m]*/*/tests/**/*.ts'], includeDuplication: false } + case 'package-tests-n-s': + return { eslintTargets: ['packages/[n-s]*/*/tests/**/*.ts'], includeDuplication: false } + case 'package-tests-t-z': + return { eslintTargets: ['packages/[t-z]*/*/tests/**/*.ts'], includeDuplication: false } case 'repository': return { eslintTargets: [ From 5bb594c4005dedea5d5a51414b50510556f6151e Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Wed, 22 Jul 2026 00:39:58 +0800 Subject: [PATCH 21/90] ci: avoid hosted runner setup saturation --- .../2026-07-06-parallel-github-ci-gates.md | 2 +- .github/workflows/ci.yml | 52 +++++-------------- scripts/lint-shards.spec.ts | 29 +++++++++-- scripts/lint-shards.ts | 8 +++ 4 files changed, 46 insertions(+), 45 deletions(-) diff --git a/.agents/notes/implemented/process/2026-07-06-parallel-github-ci-gates.md b/.agents/notes/implemented/process/2026-07-06-parallel-github-ci-gates.md index aeab7cbfbb..ab705d9450 100644 --- a/.agents/notes/implemented/process/2026-07-06-parallel-github-ci-gates.md +++ b/.agents/notes/implemented/process/2026-07-06-parallel-github-ci-gates.md @@ -14,7 +14,7 @@ The artifact boundary remains load-bearing. `publint`, `verify-node-next-types`, [CI](../../../../.github/workflows/ci.yml) treats one minute for non-Windows jobs and three minutes for Windows jobs as observed performance targets, not cancellation deadlines. Hosted-runner variance should leave complete timing evidence and useful failure logs instead of cancelling an otherwise-correct gate. The [serial cross-platform CI reference](2026-07-21-serial-cross-platform-ci-reference.md) independently runs the complete unsharded primary Node aggregate on Linux, macOS, and Windows so the optimized lane inventory is not its own completeness oracle. -[scripts/run-gates.ts](../../../../scripts/run-gates.ts) remains the common bounded scheduler, but GitHub supplies explicit shard names for the expensive gate families. [scripts/static-shards.ts](../../../../scripts/static-shards.ts) partitions static gates into foundation, documentation-type, API-contract, catalog, prose, documentation-projection, and documentation-build lanes and rejects a missing or duplicate gate assignment. Lint uses disjoint A-C, D-M, N-S, and T-Z package-source and package-test lanes plus a repository complement; the complement still starts from `.` so a new top-level lint target cannot disappear between shards, and it owns the single cross-file duplication run. [scripts/coverage-shards.ts](../../../../scripts/coverage-shards.ts) assigns every workspace package to exactly one source-coverage lane; its test expands the live package tree, so a new package makes CI red until it has an owner. Directory filters retain a trailing separator because Vitest positional filters match substrings and would otherwise admit prefix-named sibling packages. Each coverage lane includes only its owned source files, repeats the exhaustive companion topology test, and runs without a preceding build because the complete coverage suite passes from a tree with every generated `lib/` removed. +[scripts/run-gates.ts](../../../../scripts/run-gates.ts) remains the common bounded scheduler, but GitHub supplies explicit shard names for the expensive gate families. [scripts/static-shards.ts](../../../../scripts/static-shards.ts) partitions static gates into foundation, documentation-type, API-contract, catalog, prose, documentation-projection, and documentation-build ownership and rejects a missing or duplicate gate assignment; low-cost ownership groups may share one hosted lane to stay below the runner-provisioning concurrency wave. Linux lint uses disjoint A-C, D-M, N-S, and T-Z package-source and package-test lanes, while the longer Windows target uses A-M and N-Z halves; both topologies include a repository complement that still starts from `.` so a new top-level lint target cannot disappear between shards and owns the single cross-file duplication run. [scripts/coverage-shards.ts](../../../../scripts/coverage-shards.ts) assigns every workspace package to exactly one source-coverage lane; its test expands the live package tree, so a new package makes CI red until it has an owner. Directory filters retain a trailing separator because Vitest positional filters match substrings and would otherwise admit prefix-named sibling packages. Each coverage lane includes only its owned source files, repeats the exhaustive companion topology test, and runs without a preceding build because the complete coverage suite passes from a tree with every generated `lib/` removed. Snapshot replay uses two explicit multi-file lanes and eight scenario partitions of the large ACP file. [scripts/snapshot-shards.ts](../../../../scripts/snapshot-shards.ts) owns that inventory, and its test discovers every file admitted by the snapshot config so a new file cannot land outside CI. Each snapshot job installs dependencies while its Linux runner installs bubblewrap, refreshing APT indexes and retrying if the hosted image's initial package install fails, then builds the shipped runtime and runs only its assigned replay surface. CI explicitly retains the suite's bounded concurrency of five subprocesses instead of clamping it to the runner's two logical CPUs, because replay spends most of its time waiting on child protocol I/O. Fixture guards still inspect the complete ACP scenario table in every partition. Static, lint, coverage, and snapshot sharding changes only GitHub scheduling: the ordinary local package scripts still run their complete suites. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 79dcfcffcf..6b6ede348b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -37,30 +37,22 @@ jobs: fail-fast: false matrix: include: - - lane: static-foundation + - lane: static-foundation-projection command: pnpm run check:ci:static gate_concurrency: '4' - static_shard: foundation + static_shard: 'foundation,site-projection' - lane: static-doc-types command: pnpm run check:ci:static gate_concurrency: '1' static_shard: doc-types - - lane: static-api-contracts + - lane: static-api-catalogs command: pnpm run check:ci:static gate_concurrency: '4' - static_shard: api-contracts - - lane: static-catalogs - command: pnpm run check:ci:static - gate_concurrency: '4' - static_shard: catalogs + static_shard: 'api-contracts,catalogs' - lane: static-prose command: pnpm run check:ci:static gate_concurrency: '4' static_shard: prose - - lane: static-site-projection - command: pnpm run check:ci:static - gate_concurrency: '1' - static_shard: site-projection - lane: static-site-mpa command: pnpm run check:ci:static gate_concurrency: '1' @@ -442,46 +434,26 @@ jobs: command: pnpm run check:ci:static gate_concurrency: '4' static_shard: 'doc-types,api-contracts' - - lane: lint-package-sources-a-c + - lane: lint-package-sources command: pnpm run check:ci:lint gate_concurrency: '1' eslint_cache: '1' - lint_shard: package-sources-a-c - - lane: lint-package-sources-d-m + lint_shard: package-sources-a-m + - lane: lint-package-sources-n-z command: pnpm run check:ci:lint gate_concurrency: '1' eslint_cache: '1' - lint_shard: package-sources-d-m - - lane: lint-package-sources-n-s + lint_shard: package-sources-n-z + - lane: lint-package-tests command: pnpm run check:ci:lint gate_concurrency: '1' eslint_cache: '1' - lint_shard: package-sources-n-s - - lane: lint-package-sources-t-z + lint_shard: package-tests-a-m + - lane: lint-package-tests-n-z command: pnpm run check:ci:lint gate_concurrency: '1' eslint_cache: '1' - lint_shard: package-sources-t-z - - lane: lint-package-tests-a-c - command: pnpm run check:ci:lint - gate_concurrency: '1' - eslint_cache: '1' - lint_shard: package-tests-a-c - - lane: lint-package-tests-d-m - command: pnpm run check:ci:lint - gate_concurrency: '1' - eslint_cache: '1' - lint_shard: package-tests-d-m - - lane: lint-package-tests-n-s - command: pnpm run check:ci:lint - gate_concurrency: '1' - eslint_cache: '1' - lint_shard: package-tests-n-s - - lane: lint-package-tests-t-z - command: pnpm run check:ci:lint - gate_concurrency: '1' - eslint_cache: '1' - lint_shard: package-tests-t-z + lint_shard: package-tests-n-z - lane: lint-repository command: pnpm run check:ci:lint gate_concurrency: '1' diff --git a/scripts/lint-shards.spec.ts b/scripts/lint-shards.spec.ts index 540c4b88c4..e14a15b9ad 100644 --- a/scripts/lint-shards.spec.ts +++ b/scripts/lint-shards.spec.ts @@ -44,6 +44,22 @@ describe('lint gate shards', () => { eslintTargets: ['packages/[t-z]*/*/tests/**/*.ts'], includeDuplication: false, }) + expect(selectLintShard('package-sources-a-m')).toEqual({ + eslintTargets: ['packages/[a-m]*/*/src/**/*.ts'], + includeDuplication: false, + }) + expect(selectLintShard('package-sources-n-z')).toEqual({ + eslintTargets: ['packages/[n-z]*/*/src/**/*.ts'], + includeDuplication: false, + }) + expect(selectLintShard('package-tests-a-m')).toEqual({ + eslintTargets: ['packages/[a-m]*/*/tests/**/*.ts'], + includeDuplication: false, + }) + expect(selectLintShard('package-tests-n-z')).toEqual({ + eslintTargets: ['packages/[n-z]*/*/tests/**/*.ts'], + includeDuplication: false, + }) expect(selectLintShard('repository')).toEqual({ eslintTargets: [ '.', @@ -56,15 +72,20 @@ describe('lint gate shards', () => { }) }) - it('assigns every package group to one alphabetic range', () => { + it('assigns every package group once in the Linux and Windows topologies', () => { const groups = readdirSync(packagesRoot, { withFileTypes: true }) .filter(entry => entry.isDirectory()) .map(entry => entry.name) .sort() - const ranges = [/^[a-c]/u, /^[d-m]/u, /^[n-s]/u, /^[t-z]/u] - const assignments = ranges.flatMap(range => groups.filter(group => range.test(group))).sort() + const topologies = [ + [/^[a-c]/u, /^[d-m]/u, /^[n-s]/u, /^[t-z]/u], + [/^[a-m]/u, /^[n-z]/u], + ] - expect(assignments).toEqual(groups) + for (const ranges of topologies) { + const assignments = ranges.flatMap(range => groups.filter(group => range.test(group))).sort() + expect(assignments).toEqual(groups) + } }) it('rejects an unknown lane', () => { diff --git a/scripts/lint-shards.ts b/scripts/lint-shards.ts index e9a12c3e80..3f1ee9dd54 100644 --- a/scripts/lint-shards.ts +++ b/scripts/lint-shards.ts @@ -27,6 +27,10 @@ export function selectLintShard(name?: string): LintSelection { return { eslintTargets: ['packages/[n-s]*/*/src/**/*.ts'], includeDuplication: false } case 'package-sources-t-z': return { eslintTargets: ['packages/[t-z]*/*/src/**/*.ts'], includeDuplication: false } + case 'package-sources-a-m': + return { eslintTargets: ['packages/[a-m]*/*/src/**/*.ts'], includeDuplication: false } + case 'package-sources-n-z': + return { eslintTargets: ['packages/[n-z]*/*/src/**/*.ts'], includeDuplication: false } case 'package-tests-a-c': return { eslintTargets: ['packages/[a-c]*/*/tests/**/*.ts'], includeDuplication: false } case 'package-tests-d-m': @@ -35,6 +39,10 @@ export function selectLintShard(name?: string): LintSelection { return { eslintTargets: ['packages/[n-s]*/*/tests/**/*.ts'], includeDuplication: false } case 'package-tests-t-z': return { eslintTargets: ['packages/[t-z]*/*/tests/**/*.ts'], includeDuplication: false } + case 'package-tests-a-m': + return { eslintTargets: ['packages/[a-m]*/*/tests/**/*.ts'], includeDuplication: false } + case 'package-tests-n-z': + return { eslintTargets: ['packages/[n-z]*/*/tests/**/*.ts'], includeDuplication: false } case 'repository': return { eslintTargets: [ From ab1c24301a423a81eb909c4e65d2710c134d2c7c Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Wed, 22 Jul 2026 00:44:48 +0800 Subject: [PATCH 22/90] ci: rebalance hosted concurrency budget --- .../2026-07-06-parallel-github-ci-gates.md | 2 +- .github/workflows/ci.yml | 22 +++++--------- scripts/lint-shards.spec.ts | 29 +++++-------------- scripts/lint-shards.ts | 12 +++----- 4 files changed, 21 insertions(+), 44 deletions(-) diff --git a/.agents/notes/implemented/process/2026-07-06-parallel-github-ci-gates.md b/.agents/notes/implemented/process/2026-07-06-parallel-github-ci-gates.md index ab705d9450..156e3691d1 100644 --- a/.agents/notes/implemented/process/2026-07-06-parallel-github-ci-gates.md +++ b/.agents/notes/implemented/process/2026-07-06-parallel-github-ci-gates.md @@ -14,7 +14,7 @@ The artifact boundary remains load-bearing. `publint`, `verify-node-next-types`, [CI](../../../../.github/workflows/ci.yml) treats one minute for non-Windows jobs and three minutes for Windows jobs as observed performance targets, not cancellation deadlines. Hosted-runner variance should leave complete timing evidence and useful failure logs instead of cancelling an otherwise-correct gate. The [serial cross-platform CI reference](2026-07-21-serial-cross-platform-ci-reference.md) independently runs the complete unsharded primary Node aggregate on Linux, macOS, and Windows so the optimized lane inventory is not its own completeness oracle. -[scripts/run-gates.ts](../../../../scripts/run-gates.ts) remains the common bounded scheduler, but GitHub supplies explicit shard names for the expensive gate families. [scripts/static-shards.ts](../../../../scripts/static-shards.ts) partitions static gates into foundation, documentation-type, API-contract, catalog, prose, documentation-projection, and documentation-build ownership and rejects a missing or duplicate gate assignment; low-cost ownership groups may share one hosted lane to stay below the runner-provisioning concurrency wave. Linux lint uses disjoint A-C, D-M, N-S, and T-Z package-source and package-test lanes, while the longer Windows target uses A-M and N-Z halves; both topologies include a repository complement that still starts from `.` so a new top-level lint target cannot disappear between shards and owns the single cross-file duplication run. [scripts/coverage-shards.ts](../../../../scripts/coverage-shards.ts) assigns every workspace package to exactly one source-coverage lane; its test expands the live package tree, so a new package makes CI red until it has an owner. Directory filters retain a trailing separator because Vitest positional filters match substrings and would otherwise admit prefix-named sibling packages. Each coverage lane includes only its owned source files, repeats the exhaustive companion topology test, and runs without a preceding build because the complete coverage suite passes from a tree with every generated `lib/` removed. +[scripts/run-gates.ts](../../../../scripts/run-gates.ts) remains the common bounded scheduler, but GitHub supplies explicit shard names for the expensive gate families. [scripts/static-shards.ts](../../../../scripts/static-shards.ts) partitions static gates into foundation, documentation-type, API-contract, catalog, prose, documentation-projection, and documentation-build ownership and rejects a missing or duplicate gate assignment; low-cost ownership groups may share one hosted lane to stay below the runner-provisioning concurrency wave. Linux lint uses disjoint A-C, D-M, N-S, and T-Z package-source and package-test lanes, while the longer Windows target uses one complete package-source lane and one complete package-test lane; both topologies include a repository complement that still starts from `.` so a new top-level lint target cannot disappear between shards and owns the single cross-file duplication run. [scripts/coverage-shards.ts](../../../../scripts/coverage-shards.ts) assigns every workspace package to exactly one source-coverage lane; its test expands the live package tree, so a new package makes CI red until it has an owner. Directory filters retain a trailing separator because Vitest positional filters match substrings and would otherwise admit prefix-named sibling packages. Each coverage lane includes only its owned source files, repeats the exhaustive companion topology test, and runs without a preceding build because the complete coverage suite passes from a tree with every generated `lib/` removed. Snapshot replay uses two explicit multi-file lanes and eight scenario partitions of the large ACP file. [scripts/snapshot-shards.ts](../../../../scripts/snapshot-shards.ts) owns that inventory, and its test discovers every file admitted by the snapshot config so a new file cannot land outside CI. Each snapshot job installs dependencies while its Linux runner installs bubblewrap, refreshing APT indexes and retrying if the hosted image's initial package install fails, then builds the shipped runtime and runs only its assigned replay surface. CI explicitly retains the suite's bounded concurrency of five subprocesses instead of clamping it to the runner's two logical CPUs, because replay spends most of its time waiting on child protocol I/O. Fixture guards still inspect the complete ACP scenario table in every partition. Static, lint, coverage, and snapshot sharding changes only GitHub scheduling: the ordinary local package scripts still run their complete suites. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6b6ede348b..0e4a895b85 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -45,10 +45,14 @@ jobs: command: pnpm run check:ci:static gate_concurrency: '1' static_shard: doc-types - - lane: static-api-catalogs + - lane: static-api-contracts command: pnpm run check:ci:static gate_concurrency: '4' - static_shard: 'api-contracts,catalogs' + static_shard: api-contracts + - lane: static-catalogs + command: pnpm run check:ci:static + gate_concurrency: '4' + static_shard: catalogs - lane: static-prose command: pnpm run check:ci:static gate_concurrency: '4' @@ -438,22 +442,12 @@ jobs: command: pnpm run check:ci:lint gate_concurrency: '1' eslint_cache: '1' - lint_shard: package-sources-a-m - - lane: lint-package-sources-n-z - command: pnpm run check:ci:lint - gate_concurrency: '1' - eslint_cache: '1' - lint_shard: package-sources-n-z + lint_shard: package-sources - lane: lint-package-tests command: pnpm run check:ci:lint gate_concurrency: '1' eslint_cache: '1' - lint_shard: package-tests-a-m - - lane: lint-package-tests-n-z - command: pnpm run check:ci:lint - gate_concurrency: '1' - eslint_cache: '1' - lint_shard: package-tests-n-z + lint_shard: package-tests - lane: lint-repository command: pnpm run check:ci:lint gate_concurrency: '1' diff --git a/scripts/lint-shards.spec.ts b/scripts/lint-shards.spec.ts index e14a15b9ad..d3020fa6b2 100644 --- a/scripts/lint-shards.spec.ts +++ b/scripts/lint-shards.spec.ts @@ -44,20 +44,12 @@ describe('lint gate shards', () => { eslintTargets: ['packages/[t-z]*/*/tests/**/*.ts'], includeDuplication: false, }) - expect(selectLintShard('package-sources-a-m')).toEqual({ - eslintTargets: ['packages/[a-m]*/*/src/**/*.ts'], + expect(selectLintShard('package-sources')).toEqual({ + eslintTargets: ['packages/*/*/src/**/*.ts'], includeDuplication: false, }) - expect(selectLintShard('package-sources-n-z')).toEqual({ - eslintTargets: ['packages/[n-z]*/*/src/**/*.ts'], - includeDuplication: false, - }) - expect(selectLintShard('package-tests-a-m')).toEqual({ - eslintTargets: ['packages/[a-m]*/*/tests/**/*.ts'], - includeDuplication: false, - }) - expect(selectLintShard('package-tests-n-z')).toEqual({ - eslintTargets: ['packages/[n-z]*/*/tests/**/*.ts'], + expect(selectLintShard('package-tests')).toEqual({ + eslintTargets: ['packages/*/*/tests/**/*.ts'], includeDuplication: false, }) expect(selectLintShard('repository')).toEqual({ @@ -72,20 +64,15 @@ describe('lint gate shards', () => { }) }) - it('assigns every package group once in the Linux and Windows topologies', () => { + it('assigns every package group once in the Linux topology', () => { const groups = readdirSync(packagesRoot, { withFileTypes: true }) .filter(entry => entry.isDirectory()) .map(entry => entry.name) .sort() - const topologies = [ - [/^[a-c]/u, /^[d-m]/u, /^[n-s]/u, /^[t-z]/u], - [/^[a-m]/u, /^[n-z]/u], - ] + const ranges = [/^[a-c]/u, /^[d-m]/u, /^[n-s]/u, /^[t-z]/u] + const assignments = ranges.flatMap(range => groups.filter(group => range.test(group))).sort() - for (const ranges of topologies) { - const assignments = ranges.flatMap(range => groups.filter(group => range.test(group))).sort() - expect(assignments).toEqual(groups) - } + expect(assignments).toEqual(groups) }) it('rejects an unknown lane', () => { diff --git a/scripts/lint-shards.ts b/scripts/lint-shards.ts index 3f1ee9dd54..05c806c49f 100644 --- a/scripts/lint-shards.ts +++ b/scripts/lint-shards.ts @@ -27,10 +27,8 @@ export function selectLintShard(name?: string): LintSelection { return { eslintTargets: ['packages/[n-s]*/*/src/**/*.ts'], includeDuplication: false } case 'package-sources-t-z': return { eslintTargets: ['packages/[t-z]*/*/src/**/*.ts'], includeDuplication: false } - case 'package-sources-a-m': - return { eslintTargets: ['packages/[a-m]*/*/src/**/*.ts'], includeDuplication: false } - case 'package-sources-n-z': - return { eslintTargets: ['packages/[n-z]*/*/src/**/*.ts'], includeDuplication: false } + case 'package-sources': + return { eslintTargets: ['packages/*/*/src/**/*.ts'], includeDuplication: false } case 'package-tests-a-c': return { eslintTargets: ['packages/[a-c]*/*/tests/**/*.ts'], includeDuplication: false } case 'package-tests-d-m': @@ -39,10 +37,8 @@ export function selectLintShard(name?: string): LintSelection { return { eslintTargets: ['packages/[n-s]*/*/tests/**/*.ts'], includeDuplication: false } case 'package-tests-t-z': return { eslintTargets: ['packages/[t-z]*/*/tests/**/*.ts'], includeDuplication: false } - case 'package-tests-a-m': - return { eslintTargets: ['packages/[a-m]*/*/tests/**/*.ts'], includeDuplication: false } - case 'package-tests-n-z': - return { eslintTargets: ['packages/[n-z]*/*/tests/**/*.ts'], includeDuplication: false } + case 'package-tests': + return { eslintTargets: ['packages/*/*/tests/**/*.ts'], includeDuplication: false } case 'repository': return { eslintTargets: [ From 753d168bb1db705323ee7e58bda29569e47db0cc Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Wed, 22 Jul 2026 13:55:51 +0800 Subject: [PATCH 23/90] ci: benchmark larger hosted runners --- .github/workflows/ci.yml | 115 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 112 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0e4a895b85..574685cd45 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,6 +5,15 @@ on: branches: [main, master] pull_request: workflow_dispatch: + inputs: + suite: + description: Manual CI suite to run + required: true + default: serial-reference + type: choice + options: + - serial-reference + - larger-runner-benchmark concurrency: group: ${{ github.workflow }}-${{ github.ref }} @@ -510,7 +519,7 @@ jobs: # gate worker, giving reviewers a simple cross-platform oracle for completeness # and timing. serial-linux: - if: github.event_name == 'workflow_dispatch' + if: github.event_name == 'workflow_dispatch' && inputs.suite == 'serial-reference' name: serial / linux runs-on: ubuntu-latest steps: @@ -543,7 +552,7 @@ jobs: run: pnpm run check:ci serial-macos: - if: github.event_name == 'workflow_dispatch' + if: github.event_name == 'workflow_dispatch' && inputs.suite == 'serial-reference' name: serial / macos runs-on: macos-latest steps: @@ -569,7 +578,7 @@ jobs: run: pnpm run check:ci serial-windows: - if: github.event_name == 'workflow_dispatch' + if: github.event_name == 'workflow_dispatch' && inputs.suite == 'serial-reference' name: serial / windows runs-on: windows-2025 steps: @@ -603,6 +612,106 @@ jobs: DSH_SNAPSHOT_MAX_CONCURRENCY: '1' run: pnpm run check:ci + # Manual, bounded comparison of the actual critical Linux and Windows lanes. + # The named pools are restricted at the organization level to this repository. + larger-runner-benchmark: + if: github.event_name == 'workflow_dispatch' && inputs.suite == 'larger-runner-benchmark' + name: larger runner / ${{ matrix.platform }} / ${{ matrix.cores }} cores / ${{ matrix.workload }} + runs-on: ${{ matrix.runner }} + timeout-minutes: 15 + strategy: + fail-fast: false + max-parallel: 12 + matrix: + include: + - platform: linux + cores: '4' + runner: dsh-ubuntu-24-04-4core + workload: typecheck + - platform: linux + cores: '8' + runner: dsh-ubuntu-24-04-8core + workload: typecheck + - platform: linux + cores: '16' + runner: dsh-ubuntu-24-04-16core + workload: typecheck + - platform: linux + cores: '32' + runner: dsh-ubuntu-24-04-32core + workload: typecheck + - platform: linux + cores: '64' + runner: dsh-ubuntu-24-04-64core + workload: typecheck + - platform: linux + cores: '96' + runner: dsh-ubuntu-24-04-96core + workload: typecheck + - platform: windows + cores: '4' + runner: dsh-windows-2025-4core + workload: production-site + - platform: windows + cores: '8' + runner: dsh-windows-2025-8core + workload: production-site + - platform: windows + cores: '16' + runner: dsh-windows-2025-16core + workload: production-site + - platform: windows + cores: '32' + runner: dsh-windows-2025-32core + workload: production-site + - platform: windows + cores: '64' + runner: dsh-windows-2025-64core + workload: production-site + - platform: windows + cores: '96' + runner: dsh-windows-2025-96core + workload: production-site + steps: + - uses: actions/checkout@v6 + + - uses: actions/setup-node@v6 + with: + node-version: ${{ env.PRIMARY_NODE_VERSION }} + + - name: Report runner capacity + run: >- + node -e "const os = require('node:os'); + console.log(JSON.stringify({ arch: process.arch, cpus: os.cpus().length, + memoryGiB: Math.round(os.totalmem() / 2 ** 30) }))" + + - name: Enable corepack (pnpm) + run: corepack enable + + - name: Resolve pnpm store path + if: matrix.platform == 'linux' + id: pnpm-store + run: echo "path=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT" + + - uses: actions/cache@v4 + if: matrix.platform == 'linux' + with: + path: ${{ steps.pnpm-store.outputs.path }} + key: ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm-${{ hashFiles('pnpm-lock.yaml') }} + restore-keys: | + ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm- + + - name: Install (immutable) + run: pnpm install --frozen-lockfile + + - name: Run critical Linux typecheck lane + if: matrix.platform == 'linux' + run: pnpm run typecheck + + - name: Run critical Windows production-site lane + if: matrix.platform == 'windows' + run: pnpm run docs:build + # Single stable required check for branch protection: require "all checks # passed" instead of enumerating matrix legs whose names change as lanes and # node versions evolve. Every blocking job in THIS workflow must be listed in From da1bee87c79ba64ef5b30b6f11a34da66377257d Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Wed, 22 Jul 2026 14:13:49 +0800 Subject: [PATCH 24/90] ci: use four-core hosted runners --- ...ence-based-larger-hosted-runners.i18n.yaml | 6 +++ ...22-evidence-based-larger-hosted-runners.md | 44 +++++++++++++++++++ ...evidence-based-larger-hosted-runners.zh.md | 44 +++++++++++++++++++ .github/workflows/ci.yml | 23 +++++----- 4 files changed, 106 insertions(+), 11 deletions(-) create mode 100644 .agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.i18n.yaml create mode 100644 .agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.md create mode 100644 .agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.zh.md diff --git a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.i18n.yaml b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.i18n.yaml new file mode 100644 index 0000000000..303a021a38 --- /dev/null +++ b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.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 +2026-07-22-evidence-based-larger-hosted-runners.md: 992a622f0d7a2662cc65353d6c4cd1350e1f3f5f +2026-07-22-evidence-based-larger-hosted-runners.zh.md: 622911af8400fbe159038b69b440c0e97fb1f2cc diff --git a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.md b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.md new file mode 100644 index 0000000000..992a622f0d --- /dev/null +++ b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.md @@ -0,0 +1,44 @@ +# Agent Note: Evidence-based larger hosted runners + +Status: implemented + +English | [中文](2026-07-22-evidence-based-larger-hosted-runners.zh.md) + +## Problem + +The sharded primary CI fits its latency targets on standard GitHub-hosted runners, but the margin depends on cold setup and install variance. Larger runners may add useful headroom, yet their per-minute price rises much faster than these short lanes can use extra cores. Selecting a size from machine specifications or a synthetic benchmark would spend more without proving that repository CI becomes faster. + +## Decision + +The organization keeps twelve x64 larger-runner pools in the repo-restricted `dsh-larger-ci` group: Ubuntu 24.04 and Windows 2025 at 4, 8, 16, 32, 64, and 96 cores. Public IPs are disabled. Each pool has an autoscaling ceiling of 256, while the repository bounds actual demand through its workflow matrices; an idle ceiling does not allocate machines. + +The `CI` workflow exposes `suite=larger-runner-benchmark` only through manual dispatch. Its six Linux legs run the critical typecheck lane, and its six Windows legs run the critical production-site lane. Every leg reports the observed CPU and memory, has a 15-minute timeout, and uses the same setup and caching policy as the production lane it represents. Push and pull-request events skip this benchmark. + +The [twelve-size benchmark](https://github.com/deepseek-harness/deepseek-harness/actions/runs/29895295659) used a workflow-only commit on top of the standard-runner [baseline](https://github.com/deepseek-harness/deepseek-harness/actions/runs/29850033610), so the code, lockfile, and critical commands were identical: + +| Critical job | Standard | 4 cores | 8 cores | 16 cores | 32 cores | 64 cores | 96 cores | +|---|---:|---:|---:|---:|---:|---:|---:| +| Linux typecheck | 56 s | 38 s | 35 s | 40 s | 35 s | 44 s | 40 s | +| Windows production site | 160 s | 117 s | 103 s | 113 s | 75 s | 105 s | 108 s | + +The repository therefore uses the 4-core pools for the primary Node matrix and all Windows jobs. Linux 4-core finished within three seconds of the fastest measured size at the lowest larger-runner rate. Windows 4-core stayed below two minutes; the isolated 32-core result was faster, but adjacent larger sizes regressed and the production-site command itself varied only from 28 to 36 seconds, so setup and install noise—not scalable compute—created most of the spread. Node compatibility, Python, and the final aggregator remain on standard runners because their baseline jobs already finish well below one minute. + +The workflow also exposes `suite=optimized-larger-runners` through manual dispatch. That path runs the production matrices against the branch ref itself, providing an exact-head timing check when a pull request cannot form a merge commit. + +## Alternatives considered + +**Keep every job on standard runners.** This meets the threshold but gives the critical lanes no cold-run margin and leaves the larger-runner suggestion untested. + +**Select 8 or 32 cores from the fastest individual result.** The small differences were not monotonic, while billing grows sharply with size. Treating one noisy minimum as scaling evidence would make recurring CI substantially more expensive. + +**Move every job to a larger runner.** Compatibility, Python, and aggregation were already short; paying the larger-runner premium there cannot improve the critical path enough to justify the dependency or cost. + +**Use a synthetic CPU benchmark.** A microbenchmark would not include checkout, action startup, package installation, cache restore, or the repository command mix that dominates these jobs. + +## Consequences + +The benchmark incurred $2.936 across dedicated larger-runner SKUs, as recorded by organization billing immediately after the run. The existing zero-dollar Actions budget did not block those jobs, so the repo-only runner group, manual benchmark trigger, matrix width, and timeout are the observed cost controls; the budget is not treated as an execution guard. + +Production CI now depends on the organization-owned runner names in this note and in `.github/workflows/ci.yml`. Missing or renamed pools leave jobs queued instead of silently falling back to standard capacity. The 256 autoscaling ceiling permits future concurrency but does not override the bounded matrices in the current workflow. + +The selected pools buy latency headroom at a recurring per-job premium. The manual benchmark retains all sizes so a future image, dependency graph, or workload change can be measured before changing the production labels again. diff --git a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.zh.md b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.zh.md new file mode 100644 index 0000000000..622911af84 --- /dev/null +++ b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.zh.md @@ -0,0 +1,44 @@ +# Agent Note: 基于实证选用 GitHub 托管大型运行器 + +Status: implemented + +[English](2026-07-22-evidence-based-larger-hosted-runners.md) | 中文 + +## 问题 + +分片后的主 CI 在标准 GitHub 托管运行器上能够达到延迟目标,但余量会受冷启动设置和安装耗时波动影响。大型运行器或许能提供更多有效余量,但随着核心数增加,其每分钟单价增长得远比这些短通道能够利用额外核心的速度快。仅根据机器规格或合成基准测试选择配置,只会增加支出,无法证明仓库 CI 确实变快。 + +## 决策 + +组织在仅限本仓库使用的 `dsh-larger-ci` 运行器组中保留 12 个 x64 大型运行器池:Ubuntu 24.04 和 Windows 2025 各设 4、8、16、32、64、96 核规格。公网 IP 已禁用。每个池的自动扩缩容上限为 256,仓库则通过工作流矩阵限制实际需求;空闲状态下的上限不会分配机器。 + +`CI` 工作流仅在手动触发时提供 `suite=larger-runner-benchmark`。其中 6 个 Linux 矩阵分支运行关键类型检查通道,6 个 Windows 矩阵分支运行生产网站关键通道。每个矩阵分支都会报告实测 CPU 和内存,超时限制为 15 分钟,并采用与其所代表的生产通道相同的设置和缓存策略。push 和拉取请求事件会跳过这项基准测试。 + +这项[涵盖 12 种规格的基准测试](https://github.com/deepseek-harness/deepseek-harness/actions/runs/29895295659)以标准运行器[基线](https://github.com/deepseek-harness/deepseek-harness/actions/runs/29850033610)为基础,只叠加了一个仅修改工作流的提交,因此代码、锁文件和关键命令完全相同: + +| 关键作业 | 标准 | 4 核 | 8 核 | 16 核 | 32 核 | 64 核 | 96 核 | +|---|---:|---:|---:|---:|---:|---:|---:| +| Linux 类型检查 | 56 秒 | 38 秒 | 35 秒 | 40 秒 | 35 秒 | 44 秒 | 40 秒 | +| Windows 生产网站 | 160 秒 | 117 秒 | 103 秒 | 113 秒 | 75 秒 | 105 秒 | 108 秒 | + +因此,仓库让主 Node 矩阵和所有 Windows 作业使用 4 核池。Linux 4 核规格比实测最快规格至多慢 3 秒,同时采用大型运行器中的最低费率。Windows 4 核规格保持在 2 分钟以内;单次测得的 32 核结果虽然更快,但相邻的大型运行器规格反而耗时回升,而生产网站命令本身的耗时仅在 28 至 36 秒之间波动,因此大部分差异来自设置和安装噪声,而非可随核心数扩展的计算负载。Node 兼容性、Python 和最终聚合作业仍使用标准运行器,因为它们的基线耗时本就远低于 1 分钟。 + +工作流还在手动触发时提供 `suite=optimized-larger-runners`。这一路径直接以分支引用本身运行生产矩阵;当拉取请求无法生成合并提交时,它仍能针对该分支实际的头部提交提供精确计时检查。 + +## 曾考虑的替代方案 + +**所有作业均保留在标准运行器上。** 此方案能达到时长目标,但关键通道面对冷启动时没有余量,也无法验证大型运行器方案。 + +**根据单次最快结果选择 8 核或 32 核。** 各规格间的微小差异并不呈单调变化,计费却随规格急剧增长。把一次带有噪声的最低值当作扩展性证据,会大幅提高 CI 的经常性成本。 + +**将所有作业迁移到大型运行器。** 兼容性、Python 和聚合作业原本就很短;为这些作业支付大型运行器溢价,无法充分缩短关键路径,因而不足以证明这种依赖或成本合理。 + +**使用合成 CPU 基准测试。** 微基准测试不会包含代码检出、操作启动、包安装、缓存恢复或主导这些作业耗时的仓库命令组合。 + +## 后果 + +这次基准测试使用专用大型运行器 SKU,共产生 $2.936 费用,组织计费系统在运行结束后立即记录了这笔费用。现有的零美元 Actions 预算并未阻止这些作业,因此经实测有效的成本控制机制是仅限本仓库的运行器组、手动基准测试触发、矩阵宽度和超时限制;该预算不被视为执行防护措施。 + +生产 CI 目前依赖本 Agent Note 和 `.github/workflows/ci.yml` 中由组织持有的运行器名称。池缺失或改名会让作业一直排队,不会静默回退到标准容量。256 的自动扩缩容上限允许未来提高并发量,但不会突破当前工作流对矩阵规模的限制。 + +选定的运行器池以每次作业都需支付的溢价换取延迟余量。手动基准测试保留了所有规格,以便未来映像、依赖图或工作负载发生变化时,先完成测量,再次调整生产标签。 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 574685cd45..060078cc1f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,6 +14,7 @@ on: options: - serial-reference - larger-runner-benchmark + - optimized-larger-runners concurrency: group: ${{ github.workflow }}-${{ github.ref }} @@ -27,8 +28,8 @@ env: jobs: node-24: - if: github.event_name != 'workflow_dispatch' - runs-on: ubuntu-latest + if: github.event_name != 'workflow_dispatch' || inputs.suite == 'optimized-larger-runners' + runs-on: dsh-ubuntu-24-04-4core name: node 24 / ${{ matrix.lane }} env: DSH_GATE_CONCURRENCY: ${{ matrix.gate_concurrency }} @@ -316,7 +317,7 @@ jobs: run: ${{ matrix.command }} node-compat: - if: github.event_name != 'workflow_dispatch' + if: github.event_name != 'workflow_dispatch' || inputs.suite == 'optimized-larger-runners' runs-on: ubuntu-latest name: node ${{ matrix.node }} env: @@ -359,7 +360,7 @@ jobs: run: pnpm run check:node-compat python-sdk: - if: github.event_name != 'workflow_dispatch' + if: github.event_name != 'workflow_dispatch' || inputs.suite == 'optimized-larger-runners' runs-on: ubuntu-latest name: python 3.10 / keyless SDK steps: @@ -381,8 +382,8 @@ jobs: # The broader observational gate matrix below exposes the remaining # portability work without blocking mainline merges. windows-build: - if: github.event_name != 'workflow_dispatch' - runs-on: windows-2025 + if: github.event_name != 'workflow_dispatch' || inputs.suite == 'optimized-larger-runners' + runs-on: dsh-windows-2025-4core name: windows / build steps: - uses: actions/checkout@v6 @@ -400,8 +401,8 @@ jobs: run: pnpm run build windows-site: - if: github.event_name != 'workflow_dispatch' - runs-on: windows-2025 + if: github.event_name != 'workflow_dispatch' || inputs.suite == 'optimized-larger-runners' + runs-on: dsh-windows-2025-4core name: windows / production site steps: - uses: actions/checkout@v6 @@ -424,9 +425,9 @@ jobs: # would change the environment being measured. This job intentionally stays # out of all-checks-passed.needs. windows-gates: - if: github.event_name != 'workflow_dispatch' + if: github.event_name != 'workflow_dispatch' || inputs.suite == 'optimized-larger-runners' continue-on-error: true - runs-on: windows-2025 + runs-on: dsh-windows-2025-4core name: windows node 24 / ${{ matrix.lane }} env: DSH_GATE_CONCURRENCY: ${{ matrix.gate_concurrency }} @@ -725,7 +726,7 @@ jobs: name: all checks passed runs-on: ubuntu-latest needs: [node-24, node-compat, python-sdk, windows-build, windows-site] - if: always() && github.event_name != 'workflow_dispatch' + if: always() && (github.event_name != 'workflow_dispatch' || inputs.suite == 'optimized-larger-runners') steps: - name: Fail if any needed job did not succeed if: contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') || contains(needs.*.result, 'skipped') From 95abe24cb79b680692cfddc488b8e7440047af3e Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Wed, 22 Jul 2026 14:28:32 +0800 Subject: [PATCH 25/90] ci: give snapshot lanes eight cores --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 060078cc1f..f601bc1a2c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -29,7 +29,7 @@ env: jobs: node-24: if: github.event_name != 'workflow_dispatch' || inputs.suite == 'optimized-larger-runners' - runs-on: dsh-ubuntu-24-04-4core + runs-on: ${{ startsWith(matrix.lane, 'snapshot-') && 'dsh-ubuntu-24-04-8core' || 'dsh-ubuntu-24-04-4core' }} name: node 24 / ${{ matrix.lane }} env: DSH_GATE_CONCURRENCY: ${{ matrix.gate_concurrency }} From 0c9a4d7c28d5c1a9ac7f58c2a448da89b659a0a5 Mon Sep 17 00:00:00 2001 From: Turtle Date: Wed, 22 Jul 2026 10:55:16 +0800 Subject: [PATCH 26/90] Retire the readline front door and the repl-agent example Delete packages/ui/stdio and examples/repl-agent; rename stdio-demo to @deepseek-ai/dsh-tui-demo (TUI-only, refuses pipes before Loader boot). tui-agent owns the coding composition inline; echo-agent and the CI demo smoke move to the one-shot cli-demo bin, which gains -p/--prompt. The UI-independent with-key e2es move verbatim to tui-agent. SDK wizard's 'stdio' interface becomes 'tui'. PTY testing stays confined to TUI surfaces; all other subprocess tests ride pipes. See .agents/notes/implemented/simplification/2026-07-20-retire-readline-front-door.md --- .../feature/2026-06-24-workspace-context.md | 4 +- ...07-20-retire-readline-front-door.i18n.yaml | 6 + .../2026-07-20-retire-readline-front-door.md | 46 + ...026-07-20-retire-readline-front-door.zh.md | 46 + AGENTS.md | 2 +- README.i18n.yaml | 4 +- README.md | 10 + README.zh.md | 10 + eslint.config.mjs | 1 - .../code-mode-workspace-context.cordis.yml | 12 +- examples/acp-agent/cordis.yml | 7 +- examples/acp-agent/fs.cordis.snapshot.yml | 13 +- examples/acp-agent/tests/acp.snapshot.ts | 7 +- .../tests/snapshots/bash-spill/session.jsonl | 43 +- .../code-mode-workspace-context/session.jsonl | 375 ++++---- .../escalation-approved/session.jsonl | 367 ++++---- .../escalation-rejected/session.jsonl | 421 +++++---- .../fs-escalation-approved/session.jsonl | 245 +++--- .../hook-cc-pretool-ask/session.jsonl | 227 +++-- .../snapshots/workspace-context/session.jsonl | 45 +- .../acp-agent/workspace-context.cordis.yml | 9 + examples/cordis-agent/cordis.yml | 7 +- examples/tui-agent/README.md | 68 +- examples/tui-agent/code-mode.cordis.yml | 3 +- examples/tui-agent/cordis.yml | 55 +- .../tests/fixtures/tui-scripted.cordis.yml | 1 + .../tui-agent/tests/tui-keyless-smoke.e2e.ts | 383 ++++++++- knip.json | 5 +- package.json | 3 +- packages/context/workspace-context/README.md | 2 +- .../context/workspace-context/src/index.ts | 1 + .../context/workspace-context/src/render.ts | 6 - .../context/workspace-context/src/state.ts | 5 +- .../tests/workspace-context.spec.ts | 10 +- packages/examples/cli-demo/src/cli.ts | 16 +- .../examples/cli-demo/tests/built-bin.e2e.ts | 39 +- packages/examples/cli-demo/tests/cli.spec.ts | 6 +- packages/examples/tui-demo/src/index.ts | 16 +- .../examples/tui-demo/tests/built-bin.e2e.ts | 98 +++ packages/support/loader-smoke/src/index.ts | 12 +- .../loader-smoke/tests/loader-smoke.spec.ts | 5 +- packages/ui/app-boot/README.md | 21 +- packages/ui/app-boot/src/index.ts | 150 +++- packages/ui/tui/README.md | 33 +- packages/ui/tui/src/index.ts | 806 ++++++++++++++++-- packages/ui/tui/tests/tui.spec.ts | 4 +- pnpm-lock.yaml | 28 + python/sdk-runtime/package.json | 1 - scripts/gen-tool-catalog.ts | 51 +- .../verify-package-readme-model-experience.ts | 2 - tsconfig.build.json | 2 - tsconfig.json | 2 - 52 files changed, 2613 insertions(+), 1128 deletions(-) create mode 100644 .agents/notes/implemented/simplification/2026-07-20-retire-readline-front-door.i18n.yaml create mode 100644 .agents/notes/implemented/simplification/2026-07-20-retire-readline-front-door.md create mode 100644 .agents/notes/implemented/simplification/2026-07-20-retire-readline-front-door.zh.md create mode 100644 packages/examples/tui-demo/tests/built-bin.e2e.ts diff --git a/.agents/notes/implemented/feature/2026-06-24-workspace-context.md b/.agents/notes/implemented/feature/2026-06-24-workspace-context.md index a3eec63b8f..dc21fb919d 100644 --- a/.agents/notes/implemented/feature/2026-06-24-workspace-context.md +++ b/.agents/notes/implemented/feature/2026-06-24-workspace-context.md @@ -40,7 +40,7 @@ After a successful first-party `read`, `write`, or `edit` call, the `tools/post- A content edit appends `Updated instructions from: `, states that the new content replaces the previous content, and includes the complete current file. If precedence changes from one candidate to another, the message also names the previous path and says it no longer applies. If no candidate remains, the plugin appends `Instructions removed: ` and states that the previously loaded instructions no longer apply. -Dynamic messages carry their complete system-reminder framing in `content`, and every `context/message` reaches the model verbatim as a user-role message (there is no core wrapper to opt out of). `context/message.meta` carries opaque JSON state that is persisted but never rendered to the model. +Dynamic messages use a raw `context/message` envelope because the plugin owns the complete system-reminder framing. Core context injection therefore supports `envelope: 'raw'`; callers that omit it retain the canonical `` wrapper. `context/message.meta` carries opaque JSON state that is persisted but never rendered to the model. Shell commands are not discovery triggers. Local bash calls start fresh shells, and inferring reached paths from arbitrary command strings would require shell semantics the prompt plugin does not own. @@ -76,7 +76,7 @@ There is intentionally no watcher. Detection occurs at the next successful struc ## Consequences -Workspace guidance is isolated per session and shared by both product front doors and every tool presentation mode. Initial instructions benefit from stable prefix caching, while nested and changed content remains durable and replayable. The generic session/agent context contract carries JSON metadata propagated through prompt-submit and post-tool `additionalContexts` arrays without flattening entries. +Workspace guidance is isolated per session and shared by both product front doors and every tool presentation mode. Initial instructions benefit from stable prefix caching, while nested and changed content remains durable and replayable. The generic session/agent context contract includes optional raw framing and JSON metadata, both propagated through prompt-submit and post-tool `additionalContexts` arrays without flattening entries. Repository text remains untrusted input. Lower-authority user-role framing, explicit precedence language, delimiter escaping, and symlink rejection reduce risk but do not eliminate prompt injection. Permission and sandbox layers treat workspace files as data rather than authority. diff --git a/.agents/notes/implemented/simplification/2026-07-20-retire-readline-front-door.i18n.yaml b/.agents/notes/implemented/simplification/2026-07-20-retire-readline-front-door.i18n.yaml new file mode 100644 index 0000000000..232fec495b --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-07-20-retire-readline-front-door.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 +2026-07-20-retire-readline-front-door.md: 7ebcfdc246bdf6971418609c61acbd4019aa90cb +2026-07-20-retire-readline-front-door.zh.md: cf4d03594ed3a0cf31bed96eb2133bd37959084a diff --git a/.agents/notes/implemented/simplification/2026-07-20-retire-readline-front-door.md b/.agents/notes/implemented/simplification/2026-07-20-retire-readline-front-door.md new file mode 100644 index 0000000000..7ebcfdc246 --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-07-20-retire-readline-front-door.md @@ -0,0 +1,46 @@ +# Agent Note: Retire the readline front door and the repl-agent example + +Status: implemented + +English | [中文](2026-07-20-retire-readline-front-door.zh.md) + +## Problem + +The repo shipped two interactive terminal front doors: the line-oriented readline channel (`@deepseek-ai/dsh-stdio`) and the full-screen [`@deepseek-ai/dsh-tui`](../feature/2026-07-17-dedicated-full-screen-tui-front-door.md). After the TUI landed, readline's interactive role was redundant — `demo:tui` superseded `demo:repl` as the coding-agent experience — while its remaining real role, pipes and automation, was already served better by the one-shot `@deepseek-ai/dsh-cli-demo` app (task in, DSH-native `text`/`json`/`stream-json` out, durable persistence, signal handling). + +The duplication was structural, not just cosmetic: `dsh-stdio-demo` carried a `TerminalMode` (`auto`/`readline`/`tui`) selection seam, ~1,000 lines of readline unit tests, a readline transcript grammar (`[tool call] …` lines) that the CI demo smoke and two built-bin e2es grepped, and an inverted example composition where the flagship `tui-agent` leaf was defined as an include-patch over the `repl-agent` leaf it superseded. + +## Decision + +Delete the readline front door and the repl-agent example; keep exactly three front-door archetypes: **interactive TUI** (TTY-only, fails loud on pipes), **one-shot CLI** (`-p`/positional task, pipes and automation), and **servers** (ACP / JSON-RPC). + +- `packages/ui/stdio` and `examples/repl-agent` are gone. `packages/examples/stdio-demo` is renamed `@deepseek-ai/dsh-tui-demo` (`packages/examples/tui-demo`) and always mounts `dsh-tui`; the `TerminalMode`/`resolveTerminalMode`/`ui.mode` seam is deleted. The bin refuses non-TTY streams **before booting the Loader** (a compose-time throw inside a Loader tree is logged per-entry, not rethrown, so a piped launch would otherwise settle into an idle UI-less process instead of exiting nonzero). +- `examples/tui-agent/cordis.yml` now owns the coding composition inline (the include-patch inversion is gone); its Code Mode overlay includes its own base. `examples/cordis-agent` moved to the TUI app. +- `examples/echo-agent` moved to the one-shot `dsh-cli-demo` app; `dsh-cli-demo` gained `-p/--prompt` as the flag form of the single task (mutually exclusive with the positional). +- The UI-independent with-key coding e2es (`full-loop`, `coding-task`, `resume`, `compaction`, `todo-write`, `code-mode` and their shared harness) moved verbatim from `examples/repl-agent/tests/` to `examples/tui-agent/tests/` — they assemble the stack programmatically and never touched a UI. +- The SDK wizard's `stdio` run interface became `tui` (`RunInterface = 'acp' | 'tui' | 'embed'`), contributing a `dsh-tui` entry instead of `dsh-stdio`; the generated `index.ts` guards TTY before `startSDK` for the same pre-boot fail-loud reason as the tui-demo bin. + +### Testing policy: PTY only for the TUI + +Pipes remain the default test medium. PTY-driven subprocess tests are sanctioned **only** where the subject is the TUI itself: `examples/tui-agent/tests/tui-keyless-smoke.e2e.ts` (which gained the Code Mode overlay boot scenario, replacing repl-agent's pipe smoke as the overlay's keyless composition proof) and the minimal PTY boot smoke in `examples/cordis-agent` (whose front door IS the TUI). Everything else moved to pipes over the one-shot bin: + +- `examples/echo-agent/tests/echo.e2e.ts` proves the Loader boot + mock-model tool round-trip through `stream-json` records instead of readline transcript lines. +- The CI demo-smoke gate (`scripts/run-gates.ts`, AGENTS.md) runs `demo:echo --output-format stream-json -p "echo ci smoke"` and parses the records structurally. +- `packages/examples/tui-demo/tests/built-bin.e2e.ts` proves the built bin's piped-launch refusal (nonzero exit + pointer at `dsh-cli-demo`); the echo-round-trip-under-plain-Node and missing-config fail-loud proofs live in `cli-demo`'s built-bin suite. +- `packages/context/time-context/tests/time-context.e2e.ts` runs one one-shot turn; multi-turn elapsed rendering stays unit-covered in its spec. + +## Accepted losses + +- **Piped multi-turn in one process** — the readline channel could script several turns over stdin; the one-shot bin runs one task per process. Multi-turn continuity is covered by `RESUME_SESSION_ID`/resume e2es and the TUI's scripted PTY conversation. +- **Non-TTY `ask_user_question`** — the readline provider was the only non-TTY terminal implementation of `ctx.userInteraction`. A headless run whose model calls `ask_user_question` now fails that tool call (no provider); the ACP bridge remains the non-terminal provider. A future headless deployment that needs it composes its own provider. + +## Alternatives considered + +- **Keep `dsh-stdio` as a pipe/automation channel without the repl demo** — rejected: its automation role duplicated `dsh-cli-demo` with a weaker contract (unstructured transcript, EOF-exit heuristics vs. one durable turn ending and format-pure output). +- **Rewrite the piped smokes as PTY drivers** — rejected: PTY is the flakier, more complex medium and is reserved for the one surface pipes cannot prove (real TTY takeover/restore). + +## Consequences + +- One interactive front door (TUI), one automation front door (one-shot CLI), two servers; no mode-selection seam in the terminal app. +- ~1,000 lines of readline unit tests deleted with their behavior; the readline transcript grammar is gone from all gates. +- This supersedes the packaging half of [fold the stdio UI helper](2026-07-04-fold-stdio-ui-helper.md) (the folded package is now deleted) and amends the composition described in [the TUI front-door note](../feature/2026-07-17-dedicated-full-screen-tui-front-door.md) (no `auto` selection; `tui-agent` owns the coding composition). diff --git a/.agents/notes/implemented/simplification/2026-07-20-retire-readline-front-door.zh.md b/.agents/notes/implemented/simplification/2026-07-20-retire-readline-front-door.zh.md new file mode 100644 index 0000000000..cf4d03594e --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-07-20-retire-readline-front-door.zh.md @@ -0,0 +1,46 @@ +# Agent Note: 退役 readline 前端与 repl-agent 示例 + +Status: implemented + +[English](2026-07-20-retire-readline-front-door.md) | 中文 + +## 问题 + +仓库同时提供两个交互式终端前端:面向行的 readline 通道(`@deepseek-ai/dsh-stdio`)和全屏的 [`@deepseek-ai/dsh-tui`](../feature/2026-07-17-dedicated-full-screen-tui-front-door.md)。TUI 落地之后,readline 的交互角色已经冗余——`demo:tui` 作为编码 agent 体验取代了 `demo:repl`——而它剩下的真实角色(管道与自动化)已由单次任务的 `@deepseek-ai/dsh-cli-demo` 应用以更好的方式承担(任务输入、DSH 原生 `text`/`json`/`stream-json` 输出、持久化、信号处理)。 + +这种重复是结构性的,不只是表面问题:`dsh-stdio-demo` 携带一个 `TerminalMode`(`auto`/`readline`/`tui`)选择接缝、约 1,000 行 readline 单元测试、一套被 CI 演示冒烟测试和两个 built-bin e2e 用 grep 匹配的 readline 文本记录语法(`[tool call] …` 行),以及一个倒置的示例组合:旗舰 `tui-agent` 叶节点被定义为对它所取代的 `repl-agent` 叶节点的 include patch。 + +## 决定 + +删除 readline 前端和 repl-agent 示例;只保留三类前端原型:**交互式 TUI**(仅 TTY,管道下快速失败)、**单次任务 CLI**(`-p`/位置参数任务,服务管道与自动化)以及**服务器**(ACP / JSON-RPC)。 + +- `packages/ui/stdio` 与 `examples/repl-agent` 已删除。`packages/examples/stdio-demo` 更名为 `@deepseek-ai/dsh-tui-demo`(`packages/examples/tui-demo`)并始终挂载 `dsh-tui`;`TerminalMode`/`resolveTerminalMode`/`ui.mode` 接缝随之删除。bin 在**启动 loader 之前**就拒绝非 TTY 流(Loader 树内组合期抛出的异常按条目记录日志而不会重新抛出,管道启动否则会沉降为一个空闲的无 UI 进程而不是以非零码退出)。 +- `examples/tui-agent/cordis.yml` 现在内联拥有编码组合(include patch 倒置消失);其 Code Mode 覆盖层 include 自己的基础配置。`examples/cordis-agent` 迁移到 TUI 应用。 +- `examples/echo-agent` 迁移到单次任务的 `dsh-cli-demo` 应用;`dsh-cli-demo` 新增 `-p/--prompt` 作为单个任务的旗标形式(与位置参数互斥)。 +- 与 UI 无关的带密钥编码 e2e(`full-loop`、`coding-task`、`resume`、`compaction`、`todo-write`、`code-mode` 及其共享 harness)原样从 `examples/repl-agent/tests/` 移入 `examples/tui-agent/tests/`——它们以编程方式组装整个栈,从不接触任何 UI。 +- SDK 向导的 `stdio` 运行接口改为 `tui`(`RunInterface = 'acp' | 'tui' | 'embed'`),贡献 `dsh-tui` 配置项而不是 `dsh-stdio`;生成的 `index.ts` 在 `startSDK` 之前检查 TTY,理由与 tui-demo bin 的启动前快速失败相同。 + +### 测试策略:PTY 仅用于 TUI + +管道仍是默认测试介质。PTY 驱动的子进程测试**仅**在被测对象就是 TUI 本身时获准使用:`examples/tui-agent/tests/tui-keyless-smoke.e2e.ts`(新增 Code Mode 覆盖层启动场景,取代 repl-agent 的管道冒烟测试成为该覆盖层的无密钥组合证明)和 `examples/cordis-agent` 中最小的 PTY 启动冒烟测试(其前端就是 TUI)。其余全部改为通过单次任务 bin 走管道: + +- `examples/echo-agent/tests/echo.e2e.ts` 通过 `stream-json` 记录证明 Loader 启动 + mock 模型的工具往返,而不是匹配 readline 文本记录行。 +- CI 演示冒烟门禁(`scripts/run-gates.ts`、AGENTS.md)运行 `demo:echo --output-format stream-json -p "echo ci smoke"` 并结构化解析记录。 +- `packages/examples/tui-demo/tests/built-bin.e2e.ts` 证明构建产物 bin 对管道启动的拒绝(非零退出 + 指向 `dsh-cli-demo` 的提示);纯 Node 下的 echo 往返证明与缺失配置的快速失败证明位于 `cli-demo` 的 built-bin 套件。 +- `packages/context/time-context/tests/time-context.e2e.ts` 运行一个单次任务轮次;多轮 elapsed 渲染仍由其单元测试覆盖。 + +## 接受的损失 + +- **单进程内的管道多轮对话**——readline 通道可以通过 stdin 脚本化多个轮次;单次任务 bin 每个进程只运行一个任务。多轮连续性由 `RESUME_SESSION_ID`/resume e2e 和 TUI 的脚本化 PTY 对话覆盖。 +- **非 TTY 的 `ask_user_question`**——readline 提供方是 `ctx.userInteraction` 唯一的非 TTY 终端实现。模型调用 `ask_user_question` 的 headless 运行现在会让该工具调用失败(没有提供方);ACP 桥接仍是非终端提供方。未来需要它的 headless 部署自行组合提供方。 + +## 曾考虑的替代方案 + +- **保留 `dsh-stdio` 作为纯管道/自动化通道而只删 repl 演示**——不予采纳:它的自动化角色以更弱的契约重复了 `dsh-cli-demo`(非结构化文本记录、EOF 退出的启发式判断,对比后者的一次持久轮次结束和格式纯净输出)。 +- **把管道冒烟测试改写为 PTY 驱动**——不予采纳:PTY 是更易波动、更复杂的介质,仅保留给管道无法证明的那一个表面(真实 TTY 的接管/恢复)。 + +## 后果 + +- 一个交互式前端(TUI)、一个自动化前端(单次任务 CLI)、两个服务器;终端应用不再有模式选择接缝。 +- 约 1,000 行 readline 单元测试随其行为一起删除;readline 文本记录语法从所有门禁中消失。 +- 本决定取代 [fold the stdio UI helper](2026-07-04-fold-stdio-ui-helper.md) 的打包部分(被折叠的包现已删除),并修订 [TUI 前端 Agent Note](../feature/2026-07-17-dedicated-full-screen-tui-front-door.md) 描述的组合(不再有 `auto` 选择;`tui-agent` 拥有编码组合)。 diff --git a/AGENTS.md b/AGENTS.md index cf1763d3d1..a4a71dbc0e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -85,7 +85,7 @@ pnpm run website:build pnpm run verify-module-graph pnpm run build pnpm run hygiene -DSH_EXAMPLE_MODE=lib pnpm exec vitest run --config vitest.e2e.config.ts examples/headless-agent/tests/keyless-smoke.e2e.ts examples/tui-agent/tests/tui-keyless-smoke.e2e.ts packages/examples/cli-demo/tests/built-bin.e2e.ts packages/examples/acp-demo/tests/built-bin.e2e.ts packages/ui/jsonrpc/tests/built-scope-carrier.e2e.ts packages/workflow/workflow-workerthread/tests/built-worker.e2e.ts packages/code-runtime/code-runtime-worker/tests/built-lib.e2e.ts +DSH_EXAMPLE_MODE=lib pnpm exec vitest run --config vitest.e2e.config.ts examples/headless-agent/tests/keyless-smoke.e2e.ts examples/tui-agent/tests/tui-keyless-smoke.e2e.ts packages/examples/tui-demo/tests/built-bin.e2e.ts packages/examples/cli-demo/tests/built-bin.e2e.ts packages/examples/acp-demo/tests/built-bin.e2e.ts packages/ui/jsonrpc/tests/built-scope-carrier.e2e.ts packages/workflow/workflow-workerthread/tests/built-worker.e2e.ts packages/code-runtime/code-runtime-worker/tests/built-lib.e2e.ts ``` `test:coverage`, not `test`, is the gate ([why](docs/testing.md)); report only commands actually run. diff --git a/README.i18n.yaml b/README.i18n.yaml index d78213a292..576106cf32 100644 --- a/README.i18n.yaml +++ b/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 -README.md: 32958db0e74bd14d6d41e8d7886b8d3257fe0f59 -README.zh.md: b28b175a8296347a7bed05b4e53c0d75dc51efed +README.md: 3c6488f08e9ba487bf04c932aa6cc5e5ea7eac06 +README.zh.md: afbf5b9ef5dea065be4954c8699596851fc6e6ed diff --git a/README.md b/README.md index 32958db0e7..8b34d61778 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,16 @@ English | [中文](README.zh.md) The **DeepSeek Harness SDK** is a plugin-based SDK for building agent harnesses. +## Install + +Install the `dsh` coding agent with one line — it needs `git` and Node `^22.19 || >=24`, and offers to install `pnpm` if it is missing: + +```sh +curl -fsSL https://raw.githubusercontent.com/deepseek-harness/deepseek-harness/master/scripts/install.sh | sh +``` + +It clones the harness to `~/.dsh/source`, runs `pnpm install`, symlinks `dsh` into `~/.local/bin` (offering to add it to your PATH), prompts once for your `DEEPSEEK_API_KEY`, and launches `dsh`; re-running it updates an existing checkout. Run from inside a checkout (`sh scripts/install.sh`) it reuses that checkout and skips the clone. The overridable `DSH_*` variables are documented in [`scripts/install.sh`](scripts/install.sh). + ## Development This monorepo is built on the [Cordis](https://github.com/cordiverse/cordis) framework (vendored as source under `vendor/`), microkernel-style: everything is a plugin. diff --git a/README.zh.md b/README.zh.md index b28b175a82..cf029cc0bb 100644 --- a/README.zh.md +++ b/README.zh.md @@ -4,6 +4,16 @@ **DeepSeek Harness SDK** 是用于构建 agent harness(智能体框架)的 SDK,采取基于插件的设计。 +## 安装 + +一行命令即可安装 `dsh` 编码智能体——需要 `git` 和 Node `^22.19 || >=24`,缺少 `pnpm` 时会询问是否代为安装: + +```sh +curl -fsSL https://raw.githubusercontent.com/deepseek-harness/deepseek-harness/master/scripts/install.sh | sh +``` + +脚本会把 harness 克隆到 `~/.dsh/source`,运行 `pnpm install`,把 `dsh` 软链接到 `~/.local/bin`(并询问是否加入 PATH),提示输入一次 `DEEPSEEK_API_KEY`,随后启动 `dsh`;再次运行会更新已有的检出。若在检出目录内运行(`sh scripts/install.sh`),脚本会复用当前检出并跳过克隆。可覆盖的 `DSH_*` 变量见 [`scripts/install.sh`](scripts/install.sh)。 + ## 开发 本 monorepo 基于 [Cordis](https://github.com/cordiverse/cordis) 框架构建(以源码形式收录在 `vendor/` 下),采用微内核风格:所有功能都以插件形式提供。 diff --git a/eslint.config.mjs b/eslint.config.mjs index 03dcf7edac..dd40c005d7 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -14,7 +14,6 @@ export default tseslint.config( '**/.doc-typecheck-*/**', 'website/.generated/**', 'vendor/**', // vendored source keeps upstream style and idioms - 'native/**', // imported landlock-run subtree: self-contained workspace with its own gates (native/README.md) '**/*.js', '**/*.mjs', '*.config.ts', // root tool configs (vitest, tsdown) — no project service diff --git a/examples/acp-agent/code-mode-workspace-context.cordis.yml b/examples/acp-agent/code-mode-workspace-context.cordis.yml index 807de9b3c3..f2f24ca3a5 100644 --- a/examples/acp-agent/code-mode-workspace-context.cordis.yml +++ b/examples/acp-agent/code-mode-workspace-context.cordis.yml @@ -1,5 +1,5 @@ -# Code Mode workspace-context snapshot recording overlay. The default filesystem -# tools trigger nested instruction discovery after a read. +# Code Mode workspace-context snapshot recording overlay. The scenario needs +# filesystem tools to trigger nested instruction discovery after a read. - id: base name: '@cordisjs/plugin-include' config: @@ -21,5 +21,13 @@ Verify your work by running the code or tests. Keep answers brief and factual. - insert: + - id: fs-local + name: '@deepseek-ai/dsh-fs-local' + config: + cwd: !!js process.cwd() + - id: fs-policy + name: '@deepseek-ai/dsh-fs-policy' + - id: tool-fs + name: '@deepseek-ai/dsh-tool-fs' - id: code-runtime name: '@deepseek-ai/dsh-code-runtime-worker' diff --git a/examples/acp-agent/cordis.yml b/examples/acp-agent/cordis.yml index b0a6c7b8e1..4488b3ce23 100644 --- a/examples/acp-agent/cordis.yml +++ b/examples/acp-agent/cordis.yml @@ -3,12 +3,15 @@ # log. The bin loads the gitignored root `.env` before this config. This tree has # no stdout logger or HMR because stdout carries ACP JSON-RPC. -# The DeepSeek adapter. +# The DeepSeek adapter. Shipped default: full thinking at max effort on every +# request (wire-only defaults; they never enter the request header). - id: llm-deepseek name: '@deepseek-ai/dsh-llm-deepseek' config: apiKey: !!js process.env.DEEPSEEK_API_KEY baseURL: !!js process.env.DEEPSEEK_BASE_URL + thinking: enabled + reasoningEffort: max models: - id: deepseek-v4-flash contextWindow: 256000 @@ -51,7 +54,7 @@ name: '@deepseek-ai/dsh-acp-demo' config: provider: deepseek - model: deepseek-v4-flash + model: deepseek-v4-pro persistenceRoot: !!js process.env.DSH_SNAPSHOT_SESSIONS_ROOT ?? './.sessions' persistenceCompression: !!js "process.env.DSH_SNAPSHOT === undefined ? 'zstd' : 'none'" workspaceContext: diff --git a/examples/acp-agent/fs.cordis.snapshot.yml b/examples/acp-agent/fs.cordis.snapshot.yml index d55521a8dc..da0b2ca59b 100644 --- a/examples/acp-agent/fs.cordis.snapshot.yml +++ b/examples/acp-agent/fs.cordis.snapshot.yml @@ -1,6 +1,5 @@ -# Keyless filesystem snapshots apply the spill and replay overlays directly -# because include patches cannot target entries behind a nested include. The -# sandboxed filesystem stack already lives in the base cordis.yml. +# Keyless filesystem snapshots apply the filesystem and replay overlays directly +# because include patches cannot target entries behind a nested include. - id: base name: '@cordisjs/plugin-include' config: @@ -10,6 +9,14 @@ name: '@deepseek-ai/dsh-llm-deepseek' disabled: true - insert: + - id: fs-local + name: '@deepseek-ai/dsh-fs-local' + config: + cwd: !!js process.cwd() + - id: fs-policy + name: '@deepseek-ai/dsh-fs-policy' + - id: tool-fs + name: '@deepseek-ai/dsh-tool-fs' - id: spill-local name: '@deepseek-ai/dsh-spill-local' config: diff --git a/examples/acp-agent/tests/acp.snapshot.ts b/examples/acp-agent/tests/acp.snapshot.ts index 65df474f97..aab6aff2fa 100644 --- a/examples/acp-agent/tests/acp.snapshot.ts +++ b/examples/acp-agent/tests/acp.snapshot.ts @@ -102,8 +102,11 @@ const SCENARIOS: Scenario[] = [ { name: 'repeat-tool-guard', hasModelTurn: true, recorded: false }, // Authored replay: a root AGENTS.md pins the session prefix, then a read in // nested/ discovers its narrower AGENTS.md as a raw, metadata-bearing - // context/message. The scenario-specific config keeps home/root discovery - // hermetic, and the resulting prefix needs its own pinned header class. + // context/message. Both AGENTS.md fixtures are symlinks to a sibling + // AGENTS.canonical.md, so this scenario also guards that discovery follows a + // symlinked instruction file to its target's content. The scenario-specific + // config keeps home/root discovery hermetic, and the resulting prefix needs + // its own pinned header class. { name: 'workspace-context', hasModelTurn: true, diff --git a/examples/acp-agent/tests/snapshots/bash-spill/session.jsonl b/examples/acp-agent/tests/snapshots/bash-spill/session.jsonl index eb4488858e..f6e57ac89a 100644 --- a/examples/acp-agent/tests/snapshots/bash-spill/session.jsonl +++ b/examples/acp-agent/tests/snapshots/bash-spill/session.jsonl @@ -1,24 +1,23 @@ -{"type":"session","version":0,"id":"{{sessionId}}","createdAt":0,"cwd":"{{cwd}}","delegationDepth":0} +{"type":"session","version":0,"id":"{{sessionId}}","createdAt":0,"cwd":"{{cwd}}"} {"type":"turn/start","seq":0,"time":0,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":0,"data":{"content":[{"type":"text","text":"Use the bash tool to print a large deterministic output, then reply DONE."}],"source":{"kind":"user"}},"surfaceOp":"append"} -{"type":"session/title","seq":2,"time":0,"data":{"title":"Use the bash tool to","messageSeqs":[1],"source":{"kind":"fallback"}}} -{"type":"step/start","seq":3,"time":0,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":4,"time":0,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} -{"type":"assistant/chunk","seq":5,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":6,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"call_spill","name":"bash","argumentsDelta":"{\"command\":\"node -e \\\"process.stdout.write('SPILL_START-' + 'x'.repeat(2000) + '-SPILL_END')\\\"\",\"description\":\"Print large deterministic output\"}"}}} -{"type":"assistant/chunk","seq":7,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_spill","name":"bash","arguments":"{\"command\":\"node -e \\\"process.stdout.write('SPILL_START-' + 'x'.repeat(2000) + '-SPILL_END')\\\"\",\"description\":\"Print large deterministic output\"}"}}}} -{"type":"assistant/chunk","seq":8,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} -{"type":"assistant/chunk","seq":9,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":10,"time":0,"data":{"turn":1,"step":1,"content":[{"type":"tool-call","id":"call_spill","name":"bash","arguments":"{\"command\":\"node -e \\\"process.stdout.write('SPILL_START-' + 'x'.repeat(2000) + '-SPILL_END')\\\"\",\"description\":\"Print large deterministic output\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[5,6,7,8,9],"surfaceOp":"append"} -{"type":"tool/call","seq":11,"time":0,"data":{"turn":1,"step":1,"callId":"call_spill","name":"bash","arguments":"{\"command\":\"node -e \\\"process.stdout.write('SPILL_START-' + 'x'.repeat(2000) + '-SPILL_END')\\\"\",\"description\":\"Print large deterministic output\"}"}} -{"type":"tool/result","seq":12,"time":0,"data":{"turn":1,"step":1,"callId":"call_spill","content":[{"type":"text","text":"SPILL_START-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx-SPILL_END\n\n(Omitted 1417 bytes. Full formatted result stored at: /tmp/dsh-acp-snap-ee77dff02/session-fbfcf2f560a0/1bddd2b64176-bash.txt. Use read with offset/limit, or grep this path to search within it.)"}],"isError":false},"sourceEventSeqs":[11],"surfaceOp":"append"} -{"type":"step/end","seq":13,"time":0,"data":{"turn":1,"step":1}} -{"type":"step/start","seq":14,"time":0,"data":{"turn":1,"step":2}} -{"type":"assistant/chunk","seq":15,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} -{"type":"assistant/chunk","seq":16,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":0,"text":"DONE"}}} -{"type":"assistant/chunk","seq":17,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DONE"}}}} -{"type":"assistant/chunk","seq":18,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":2}}}} -{"type":"assistant/chunk","seq":19,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":20,"time":0,"data":{"turn":1,"step":2,"content":[{"type":"text","text":"DONE"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":10,"outputTokens":2}},"sourceEventSeqs":[15,16,17,18,19],"surfaceOp":"append"} -{"type":"step/end","seq":21,"time":0,"data":{"turn":1,"step":2}} -{"type":"turn/end","seq":22,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"step/start","seq":2,"time":0,"data":{"turn":1,"step":1}} +{"type":"request/header","seq":3,"time":0,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"assistant/chunk","seq":4,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":5,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"call_spill","name":"bash","argumentsDelta":"{\"command\":\"node -e \\\"process.stdout.write('SPILL_START-' + 'x'.repeat(2000) + '-SPILL_END')\\\"\",\"description\":\"Print large deterministic output\"}"}}} +{"type":"assistant/chunk","seq":6,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_spill","name":"bash","arguments":"{\"command\":\"node -e \\\"process.stdout.write('SPILL_START-' + 'x'.repeat(2000) + '-SPILL_END')\\\"\",\"description\":\"Print large deterministic output\"}"}}}} +{"type":"assistant/chunk","seq":7,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} +{"type":"assistant/chunk","seq":8,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":9,"time":0,"data":{"turn":1,"step":1,"content":[{"type":"tool-call","id":"call_spill","name":"bash","arguments":"{\"command\":\"node -e \\\"process.stdout.write('SPILL_START-' + 'x'.repeat(2000) + '-SPILL_END')\\\"\",\"description\":\"Print large deterministic output\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[4,5,6,7,8],"surfaceOp":"append"} +{"type":"tool/call","seq":10,"time":0,"data":{"turn":1,"step":1,"callId":"call_spill","name":"bash","arguments":"{\"command\":\"node -e \\\"process.stdout.write('SPILL_START-' + 'x'.repeat(2000) + '-SPILL_END')\\\"\",\"description\":\"Print large deterministic output\"}"}} +{"type":"tool/result","seq":11,"time":0,"data":{"turn":1,"step":1,"callId":"call_spill","content":[{"type":"text","text":"SPILL_START-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx-SPILL_END\n\n(Omitted 1417 bytes. Full formatted result stored at: /tmp/dsh-acp-snapshot-spill/session-422e58e9a24d/2510ac97876a-bash.txt. Use read with offset/limit, or grep this path to search within it.)"}],"isError":false},"sourceEventSeqs":[10],"surfaceOp":"append"} +{"type":"step/end","seq":12,"time":0,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":13,"time":0,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":14,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":15,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":0,"text":"DONE"}}} +{"type":"assistant/chunk","seq":16,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DONE"}}}} +{"type":"assistant/chunk","seq":17,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":2}}}} +{"type":"assistant/chunk","seq":18,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":19,"time":0,"data":{"turn":1,"step":2,"content":[{"type":"text","text":"DONE"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":10,"outputTokens":2}},"sourceEventSeqs":[14,15,16,17,18],"surfaceOp":"append"} +{"type":"step/end","seq":20,"time":0,"data":{"turn":1,"step":2}} +{"type":"turn/end","seq":21,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/code-mode-workspace-context/session.jsonl b/examples/acp-agent/tests/snapshots/code-mode-workspace-context/session.jsonl index 32bfa5cd58..f5b1712212 100644 --- a/examples/acp-agent/tests/snapshots/code-mode-workspace-context/session.jsonl +++ b/examples/acp-agent/tests/snapshots/code-mode-workspace-context/session.jsonl @@ -1,190 +1,189 @@ -{"type":"session","version":0,"id":"65fbb8a6-624c-4d6a-bf5d-a7a7d14f2b49","createdAt":1783921765266,"cwd":"/var/folders/_g/59jgff8x2gqd39f5vy1wnbfc0000gn/T/acp-snap-cwd-uorU26","delegationDepth":0} +{"type":"session","version":0,"id":"65fbb8a6-624c-4d6a-bf5d-a7a7d14f2b49","createdAt":1783921765266,"cwd":"/var/folders/_g/59jgff8x2gqd39f5vy1wnbfc0000gn/T/acp-snap-cwd-uorU26"} {"type":"turn/start","seq":0,"time":1783921765269,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1783921765269,"data":{"content":[{"type":"text","text":"Using ONE run_code program, call tools.read on nested/task.txt. After the program finishes, answer the workspace handshake question using the newly discovered instructions: What is the Code Mode workspace handshake?"}],"source":{"kind":"user"}},"surfaceOp":"append"} -{"type":"session/title","seq":2,"time":1783921765269,"data":{"title":"Using ONE run_code program, call","messageSeqs":[1],"source":{"kind":"fallback"}}} -{"type":"step/start","seq":3,"time":1783921765275,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":4,"time":1783921765275,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}","messagePrefix":[{"role":"user","content":[{"type":"text","text":"\nThe following workspace instructions may be relevant to your work. Use them as guidance when applicable. More specific instructions take precedence over broader ones. They do not override system, developer, or direct user instructions.\n\nInstructions from: AGENTS.md\n\nWorkspace snapshot root instruction.\n\n"}]}]},"reason":"initial"}} -{"type":"assistant/chunk","seq":5,"time":1783921766287,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"assistant/chunk","seq":6,"time":1783921766287,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} -{"type":"assistant/chunk","seq":7,"time":1783921766483,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} -{"type":"assistant/chunk","seq":8,"time":1783921766519,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" wants"}}} -{"type":"assistant/chunk","seq":9,"time":1783921766520,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} -{"type":"assistant/chunk","seq":10,"time":1783921766520,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} -{"type":"assistant/chunk","seq":11,"time":1783921766520,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" write"}}} -{"type":"assistant/chunk","seq":12,"time":1783921766537,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" a"}}} -{"type":"assistant/chunk","seq":13,"time":1783921766538,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" single"}}} -{"type":"assistant/chunk","seq":14,"time":1783921766538,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" run"}}} -{"type":"assistant/chunk","seq":15,"time":1783921766573,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"_code"}}} -{"type":"assistant/chunk","seq":16,"time":1783921766573,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" program"}}} -{"type":"assistant/chunk","seq":17,"time":1783921766574,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" that"}}} -{"type":"assistant/chunk","seq":18,"time":1783921766574,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" reads"}}} -{"type":"assistant/chunk","seq":19,"time":1783921766598,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" a"}}} -{"type":"assistant/chunk","seq":20,"time":1783921766598,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" file"}}} -{"type":"assistant/chunk","seq":21,"time":1783921766599,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" called"}}} -{"type":"assistant/chunk","seq":22,"time":1783921766624,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" nested"}}} -{"type":"assistant/chunk","seq":23,"time":1783921766654,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"/t"}}} -{"type":"assistant/chunk","seq":24,"time":1783921766654,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ask"}}} -{"type":"assistant/chunk","seq":25,"time":1783921766654,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":".txt"}}} -{"type":"assistant/chunk","seq":26,"time":1783921766654,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} -{"type":"assistant/chunk","seq":27,"time":1783921766655,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" then"}}} -{"type":"assistant/chunk","seq":28,"time":1783921766684,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" answer"}}} -{"type":"assistant/chunk","seq":29,"time":1783921766717,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} -{"type":"assistant/chunk","seq":30,"time":1783921766717,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" hand"}}} -{"type":"assistant/chunk","seq":31,"time":1783921766717,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"shake"}}} -{"type":"assistant/chunk","seq":32,"time":1783921766743,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" question"}}} -{"type":"assistant/chunk","seq":33,"time":1783921766743,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" based"}}} -{"type":"assistant/chunk","seq":34,"time":1783921766743,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" on"}}} -{"type":"assistant/chunk","seq":35,"time":1783921766743,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" what"}}} -{"type":"assistant/chunk","seq":36,"time":1783921766776,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"'s"}}} -{"type":"assistant/chunk","seq":37,"time":1783921766776,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" in"}}} -{"type":"assistant/chunk","seq":38,"time":1783921766798,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" that"}}} -{"type":"assistant/chunk","seq":39,"time":1783921766799,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" file"}}} -{"type":"assistant/chunk","seq":40,"time":1783921766799,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} -{"type":"assistant/chunk","seq":41,"time":1783921766799,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" Let"}}} -{"type":"assistant/chunk","seq":42,"time":1783921766799,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} -{"type":"assistant/chunk","seq":43,"time":1783921766799,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" start"}}} -{"type":"assistant/chunk","seq":44,"time":1783921766845,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" by"}}} -{"type":"assistant/chunk","seq":45,"time":1783921766845,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" reading"}}} -{"type":"assistant/chunk","seq":46,"time":1783921766845,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} -{"type":"assistant/chunk","seq":47,"time":1783921766845,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" file"}}} -{"type":"assistant/chunk","seq":48,"time":1783921766845,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} -{"type":"assistant/chunk","seq":49,"time":1783921766926,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":50,"time":1783921766926,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6APApmaKLRDlXKMdIcWL5139","name":"run_code","argumentsDelta":""}}} -{"type":"assistant/chunk","seq":51,"time":1783921766952,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6APApmaKLRDlXKMdIcWL5139","name":"run_code","argumentsDelta":"{"}}} -{"type":"assistant/chunk","seq":52,"time":1783921766952,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6APApmaKLRDlXKMdIcWL5139","name":"run_code","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":53,"time":1783921766952,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6APApmaKLRDlXKMdIcWL5139","name":"run_code","argumentsDelta":"code"}}} -{"type":"assistant/chunk","seq":54,"time":1783921766952,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6APApmaKLRDlXKMdIcWL5139","name":"run_code","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":55,"time":1783921766952,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6APApmaKLRDlXKMdIcWL5139","name":"run_code","argumentsDelta":": "}}} -{"type":"assistant/chunk","seq":56,"time":1783921766978,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6APApmaKLRDlXKMdIcWL5139","name":"run_code","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":57,"time":1783921766978,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6APApmaKLRDlXKMdIcWL5139","name":"run_code","argumentsDelta":"const"}}} -{"type":"assistant/chunk","seq":58,"time":1783921766978,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6APApmaKLRDlXKMdIcWL5139","name":"run_code","argumentsDelta":" content"}}} -{"type":"assistant/chunk","seq":59,"time":1783921767011,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6APApmaKLRDlXKMdIcWL5139","name":"run_code","argumentsDelta":" ="}}} -{"type":"assistant/chunk","seq":60,"time":1783921767011,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6APApmaKLRDlXKMdIcWL5139","name":"run_code","argumentsDelta":" await"}}} -{"type":"assistant/chunk","seq":61,"time":1783921767011,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6APApmaKLRDlXKMdIcWL5139","name":"run_code","argumentsDelta":" tools"}}} -{"type":"assistant/chunk","seq":62,"time":1783921767011,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6APApmaKLRDlXKMdIcWL5139","name":"run_code","argumentsDelta":".read"}}} -{"type":"assistant/chunk","seq":63,"time":1783921767011,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6APApmaKLRDlXKMdIcWL5139","name":"run_code","argumentsDelta":"({"}}} -{"type":"assistant/chunk","seq":64,"time":1783921767050,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6APApmaKLRDlXKMdIcWL5139","name":"run_code","argumentsDelta":" file"}}} -{"type":"assistant/chunk","seq":65,"time":1783921767050,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6APApmaKLRDlXKMdIcWL5139","name":"run_code","argumentsDelta":"_path"}}} -{"type":"assistant/chunk","seq":66,"time":1783921767050,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6APApmaKLRDlXKMdIcWL5139","name":"run_code","argumentsDelta":":"}}} -{"type":"assistant/chunk","seq":67,"time":1783921767050,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6APApmaKLRDlXKMdIcWL5139","name":"run_code","argumentsDelta":" \\\""}}} -{"type":"assistant/chunk","seq":68,"time":1783921767050,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6APApmaKLRDlXKMdIcWL5139","name":"run_code","argumentsDelta":"n"}}} -{"type":"assistant/chunk","seq":69,"time":1783921767050,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6APApmaKLRDlXKMdIcWL5139","name":"run_code","argumentsDelta":"ested"}}} -{"type":"assistant/chunk","seq":70,"time":1783921767068,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6APApmaKLRDlXKMdIcWL5139","name":"run_code","argumentsDelta":"/t"}}} -{"type":"assistant/chunk","seq":71,"time":1783921767068,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6APApmaKLRDlXKMdIcWL5139","name":"run_code","argumentsDelta":"ask"}}} -{"type":"assistant/chunk","seq":72,"time":1783921767068,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6APApmaKLRDlXKMdIcWL5139","name":"run_code","argumentsDelta":".txt"}}} -{"type":"assistant/chunk","seq":73,"time":1783921767068,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6APApmaKLRDlXKMdIcWL5139","name":"run_code","argumentsDelta":"\\\""}}} -{"type":"assistant/chunk","seq":74,"time":1783921767068,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6APApmaKLRDlXKMdIcWL5139","name":"run_code","argumentsDelta":" });\\n"}}} -{"type":"assistant/chunk","seq":75,"time":1783921767068,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6APApmaKLRDlXKMdIcWL5139","name":"run_code","argumentsDelta":"return"}}} -{"type":"assistant/chunk","seq":76,"time":1783921767095,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6APApmaKLRDlXKMdIcWL5139","name":"run_code","argumentsDelta":" content"}}} -{"type":"assistant/chunk","seq":77,"time":1783921767095,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6APApmaKLRDlXKMdIcWL5139","name":"run_code","argumentsDelta":";"}}} -{"type":"assistant/chunk","seq":78,"time":1783921767095,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6APApmaKLRDlXKMdIcWL5139","name":"run_code","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":79,"time":1783921767121,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6APApmaKLRDlXKMdIcWL5139","name":"run_code","argumentsDelta":"}"}}} -{"type":"assistant/chunk","seq":80,"time":1783921767205,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to write a single run_code program that reads a file called nested/task.txt and then answer the handshake question based on what's in that file. Let me start by reading the file."}}}} -{"type":"assistant/chunk","seq":81,"time":1783921767205,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_6APApmaKLRDlXKMdIcWL5139","name":"run_code","arguments":"{\"code\": \"const content = await tools.read({ file_path: \\\"nested/task.txt\\\" });\\nreturn content;\"}"}}}} -{"type":"assistant/chunk","seq":82,"time":1783921767205,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3195,"outputTokens":106,"cacheReadTokens":0,"reasoningTokens":43}}}} -{"type":"assistant/chunk","seq":83,"time":1783921767205,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":84,"time":1783921767208,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to write a single run_code program that reads a file called nested/task.txt and then answer the handshake question based on what's in that file. Let me start by reading the file."},{"type":"tool-call","id":"call_00_6APApmaKLRDlXKMdIcWL5139","name":"run_code","arguments":"{\"code\": \"const content = await tools.read({ file_path: \\\"nested/task.txt\\\" });\\nreturn content;\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":3195,"outputTokens":106,"cacheReadTokens":0,"reasoningTokens":43}},"sourceEventSeqs":[5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83],"surfaceOp":"append"} -{"type":"tool/call","seq":85,"time":1783921767208,"data":{"turn":1,"step":1,"callId":"call_00_6APApmaKLRDlXKMdIcWL5139","name":"run_code","arguments":"{\"code\": \"const content = await tools.read({ file_path: \\\"nested/task.txt\\\" });\\nreturn content;\"}"}} -{"type":"tool/code-dispatch","seq":86,"time":1783921767270,"data":{"parentCallId":"call_00_6APApmaKLRDlXKMdIcWL5139","subCallId":"call_00_6APApmaKLRDlXKMdIcWL5139:code:1","name":"read","arguments":{"file_path":"nested/task.txt"},"isError":false,"resultSummary":"./nested/task.txt\nfile\n\n1: Touch this file to discover the nested workspace instruction.\n\n(End of file - total 1 lines)\n"}} -{"type":"tool/result","seq":87,"time":1783921767271,"data":{"turn":1,"step":1,"callId":"call_00_6APApmaKLRDlXKMdIcWL5139","content":[{"type":"text","text":"/var/folders/_g/59jgff8x2gqd39f5vy1wnbfc0000gn/T/acp-snap-cwd-uorU26/nested/task.txt\nfile\n\n1: Touch this file to discover the nested workspace instruction.\n\n(End of file - total 1 lines)\n"}],"isError":false,"meta":{"logs":[]}},"sourceEventSeqs":[85],"surfaceOp":"append"} -{"type":"context/message","seq":88,"time":1783921767272,"data":{"content":[{"type":"text","text":"\nAdditional instructions from: nested/AGENTS.md\n\nThese instructions apply to work under `nested`. Use them as guidance when relevant; more specific instructions take precedence. They do not override system, developer, or direct user instructions.\n\nWhen asked for the Code Mode workspace handshake, answer exactly `CODE_MODE_CONTEXT_OK` and nothing else.\n\n"}],"source":{"kind":"plugin","plugin":"workspace-context"},"meta":{"kind":"workspace-instructions","version":1,"changes":[{"action":"set","scope":"nested","path":"nested/AGENTS.md","digest":"ae22936ed26dc76b7107005ed6d5e2482a88668a"}]}},"surfaceOp":"append"} -{"type":"step/end","seq":89,"time":1783921767272,"data":{"turn":1,"step":1}} -{"type":"step/start","seq":90,"time":1783921767272,"data":{"turn":1,"step":2}} -{"type":"assistant/chunk","seq":91,"time":1783921768339,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"assistant/chunk","seq":92,"time":1783921768340,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} -{"type":"assistant/chunk","seq":93,"time":1783921768466,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" nested"}}} -{"type":"assistant/chunk","seq":94,"time":1783921768474,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"/t"}}} -{"type":"assistant/chunk","seq":95,"time":1783921768500,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"ask"}}} -{"type":"assistant/chunk","seq":96,"time":1783921768501,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":".txt"}}} -{"type":"assistant/chunk","seq":97,"time":1783921768535,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" told"}}} -{"type":"assistant/chunk","seq":98,"time":1783921768535,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} -{"type":"assistant/chunk","seq":99,"time":1783921768535,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} -{"type":"assistant/chunk","seq":100,"time":1783921768535,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} -{"type":"assistant/chunk","seq":101,"time":1783921768564,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"Touch"}}} -{"type":"assistant/chunk","seq":102,"time":1783921768565,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" this"}}} -{"type":"assistant/chunk","seq":103,"time":1783921768565,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" file"}}} -{"type":"assistant/chunk","seq":104,"time":1783921768565,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} -{"type":"assistant/chunk","seq":105,"time":1783921768565,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" discover"}}} -{"type":"assistant/chunk","seq":106,"time":1783921768565,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} -{"type":"assistant/chunk","seq":107,"time":1783921768595,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" nested"}}} -{"type":"assistant/chunk","seq":108,"time":1783921768595,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" workspace"}}} -{"type":"assistant/chunk","seq":109,"time":1783921768595,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" instruction"}}} -{"type":"assistant/chunk","seq":110,"time":1783921768595,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":".\""}}} -{"type":"assistant/chunk","seq":111,"time":1783921768595,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} -{"type":"assistant/chunk","seq":112,"time":1783921768595,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} -{"type":"assistant/chunk","seq":113,"time":1783921768621,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" nested"}}} -{"type":"assistant/chunk","seq":114,"time":1783921768621,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"/"}}} -{"type":"assistant/chunk","seq":115,"time":1783921768647,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"AG"}}} -{"type":"assistant/chunk","seq":116,"time":1783921768648,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"ENTS"}}} -{"type":"assistant/chunk","seq":117,"time":1783921768648,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":".md"}}} -{"type":"assistant/chunk","seq":118,"time":1783921768648,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" instructions"}}} -{"type":"assistant/chunk","seq":119,"time":1783921768688,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" say"}}} -{"type":"assistant/chunk","seq":120,"time":1783921768703,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":":"}}} -{"type":"assistant/chunk","seq":121,"time":1783921768740,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} -{"type":"assistant/chunk","seq":122,"time":1783921768740,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"When"}}} -{"type":"assistant/chunk","seq":123,"time":1783921768741,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" asked"}}} -{"type":"assistant/chunk","seq":124,"time":1783921768741,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" for"}}} -{"type":"assistant/chunk","seq":125,"time":1783921768741,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} -{"type":"assistant/chunk","seq":126,"time":1783921768741,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" Code"}}} -{"type":"assistant/chunk","seq":127,"time":1783921768773,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" Mode"}}} -{"type":"assistant/chunk","seq":128,"time":1783921768773,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" workspace"}}} -{"type":"assistant/chunk","seq":129,"time":1783921768773,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" hand"}}} -{"type":"assistant/chunk","seq":130,"time":1783921768773,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"shake"}}} -{"type":"assistant/chunk","seq":131,"time":1783921768773,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":","}}} -{"type":"assistant/chunk","seq":132,"time":1783921768773,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" answer"}}} -{"type":"assistant/chunk","seq":133,"time":1783921768790,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" exactly"}}} -{"type":"assistant/chunk","seq":134,"time":1783921768790,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" `"}}} -{"type":"assistant/chunk","seq":135,"time":1783921768790,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"CODE"}}} -{"type":"assistant/chunk","seq":136,"time":1783921768790,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"_M"}}} -{"type":"assistant/chunk","seq":137,"time":1783921768790,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"ODE"}}} -{"type":"assistant/chunk","seq":138,"time":1783921768790,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"_CONT"}}} -{"type":"assistant/chunk","seq":139,"time":1783921768824,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"EXT"}}} -{"type":"assistant/chunk","seq":140,"time":1783921768825,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"_OK"}}} -{"type":"assistant/chunk","seq":141,"time":1783921768825,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"`"}}} -{"type":"assistant/chunk","seq":142,"time":1783921768825,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} -{"type":"assistant/chunk","seq":143,"time":1783921768825,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" nothing"}}} -{"type":"assistant/chunk","seq":144,"time":1783921768825,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" else"}}} -{"type":"assistant/chunk","seq":145,"time":1783921768846,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":".\"\n\n"}}} -{"type":"assistant/chunk","seq":146,"time":1783921768846,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"So"}}} -{"type":"assistant/chunk","seq":147,"time":1783921768847,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} -{"type":"assistant/chunk","seq":148,"time":1783921768847,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" answer"}}} -{"type":"assistant/chunk","seq":149,"time":1783921768847,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} -{"type":"assistant/chunk","seq":150,"time":1783921768873,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} -{"type":"assistant/chunk","seq":151,"time":1783921768874,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" hand"}}} -{"type":"assistant/chunk","seq":152,"time":1783921768903,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"shake"}}} -{"type":"assistant/chunk","seq":153,"time":1783921768903,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" question"}}} -{"type":"assistant/chunk","seq":154,"time":1783921768903,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" is"}}} -{"type":"assistant/chunk","seq":155,"time":1783921768903,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" `"}}} -{"type":"assistant/chunk","seq":156,"time":1783921768903,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"CODE"}}} -{"type":"assistant/chunk","seq":157,"time":1783921768929,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"_M"}}} -{"type":"assistant/chunk","seq":158,"time":1783921768930,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"ODE"}}} -{"type":"assistant/chunk","seq":159,"time":1783921768930,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"_CONT"}}} -{"type":"assistant/chunk","seq":160,"time":1783921768930,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"EXT"}}} -{"type":"assistant/chunk","seq":161,"time":1783921768930,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"_OK"}}} -{"type":"assistant/chunk","seq":162,"time":1783921768963,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"`."}}} -{"type":"assistant/chunk","seq":163,"time":1783921768963,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} -{"type":"assistant/chunk","seq":164,"time":1783921768963,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"The"}}} -{"type":"assistant/chunk","seq":165,"time":1783921768963,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" **"}}} -{"type":"assistant/chunk","seq":166,"time":1783921769024,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"Code"}}} -{"type":"assistant/chunk","seq":167,"time":1783921769024,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" Mode"}}} -{"type":"assistant/chunk","seq":168,"time":1783921769024,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" workspace"}}} -{"type":"assistant/chunk","seq":169,"time":1783921769024,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" hand"}}} -{"type":"assistant/chunk","seq":170,"time":1783921769024,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"shake"}}} -{"type":"assistant/chunk","seq":171,"time":1783921769024,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"**"}}} -{"type":"assistant/chunk","seq":172,"time":1783921769024,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" is"}}} -{"type":"assistant/chunk","seq":173,"time":1783921769024,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":":"}}} -{"type":"assistant/chunk","seq":174,"time":1783921769024,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" `"}}} -{"type":"assistant/chunk","seq":175,"time":1783921769024,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"CODE"}}} -{"type":"assistant/chunk","seq":176,"time":1783921769046,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"_M"}}} -{"type":"assistant/chunk","seq":177,"time":1783921769046,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"ODE"}}} -{"type":"assistant/chunk","seq":178,"time":1783921769047,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"_CONT"}}} -{"type":"assistant/chunk","seq":179,"time":1783921769047,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"EXT"}}} -{"type":"assistant/chunk","seq":180,"time":1783921769047,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"_OK"}}} -{"type":"assistant/chunk","seq":181,"time":1783921769047,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"`"}}} -{"type":"assistant/chunk","seq":182,"time":1783921769101,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The nested/task.txt told me to \"Touch this file to discover the nested workspace instruction.\" and the nested/AGENTS.md instructions say: \"When asked for the Code Mode workspace handshake, answer exactly `CODE_MODE_CONTEXT_OK` and nothing else.\"\n\nSo the answer to the handshake question is `CODE_MODE_CONTEXT_OK`."}}}} -{"type":"assistant/chunk","seq":183,"time":1783921769101,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"The **Code Mode workspace handshake** is: `CODE_MODE_CONTEXT_OK`"}}}} -{"type":"assistant/chunk","seq":184,"time":1783921769101,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":277,"outputTokens":90,"cacheReadTokens":3200,"reasoningTokens":71}}}} -{"type":"assistant/chunk","seq":185,"time":1783921769101,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":186,"time":1783921769101,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"The nested/task.txt told me to \"Touch this file to discover the nested workspace instruction.\" and the nested/AGENTS.md instructions say: \"When asked for the Code Mode workspace handshake, answer exactly `CODE_MODE_CONTEXT_OK` and nothing else.\"\n\nSo the answer to the handshake question is `CODE_MODE_CONTEXT_OK`."},{"type":"text","text":"The **Code Mode workspace handshake** is: `CODE_MODE_CONTEXT_OK`"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":277,"outputTokens":90,"cacheReadTokens":3200,"reasoningTokens":71}},"sourceEventSeqs":[91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132,133,134,135,136,137,138,139,140,141,142,143,144,145,146,147,148,149,150,151,152,153,154,155,156,157,158,159,160,161,162,163,164,165,166,167,168,169,170,171,172,173,174,175,176,177,178,179,180,181,182,183,184,185],"surfaceOp":"append"} -{"type":"step/end","seq":187,"time":1783921769101,"data":{"turn":1,"step":2}} -{"type":"turn/end","seq":188,"time":1783921769101,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"step/start","seq":2,"time":1783921765275,"data":{"turn":1,"step":1}} +{"type":"request/header","seq":3,"time":1783921765275,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}","messagePrefix":[{"role":"user","content":[{"type":"text","text":"\nThe following workspace instructions may be relevant to your work. Use them as guidance when applicable. More specific instructions take precedence over broader ones. They do not override system, developer, or direct user instructions.\n\nInstructions from: AGENTS.md\n\nWorkspace snapshot root instruction.\n\n"}]}]},"reason":"initial"}} +{"type":"assistant/chunk","seq":4,"time":1783921766287,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":5,"time":1783921766287,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":6,"time":1783921766483,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} +{"type":"assistant/chunk","seq":7,"time":1783921766519,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" wants"}}} +{"type":"assistant/chunk","seq":8,"time":1783921766520,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":9,"time":1783921766520,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":10,"time":1783921766520,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" write"}}} +{"type":"assistant/chunk","seq":11,"time":1783921766537,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" a"}}} +{"type":"assistant/chunk","seq":12,"time":1783921766538,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" single"}}} +{"type":"assistant/chunk","seq":13,"time":1783921766538,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" run"}}} +{"type":"assistant/chunk","seq":14,"time":1783921766573,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"_code"}}} +{"type":"assistant/chunk","seq":15,"time":1783921766573,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" program"}}} +{"type":"assistant/chunk","seq":16,"time":1783921766574,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" that"}}} +{"type":"assistant/chunk","seq":17,"time":1783921766574,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" reads"}}} +{"type":"assistant/chunk","seq":18,"time":1783921766598,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" a"}}} +{"type":"assistant/chunk","seq":19,"time":1783921766598,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" file"}}} +{"type":"assistant/chunk","seq":20,"time":1783921766599,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" called"}}} +{"type":"assistant/chunk","seq":21,"time":1783921766624,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" nested"}}} +{"type":"assistant/chunk","seq":22,"time":1783921766654,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"/t"}}} +{"type":"assistant/chunk","seq":23,"time":1783921766654,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ask"}}} +{"type":"assistant/chunk","seq":24,"time":1783921766654,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":".txt"}}} +{"type":"assistant/chunk","seq":25,"time":1783921766654,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} +{"type":"assistant/chunk","seq":26,"time":1783921766655,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" then"}}} +{"type":"assistant/chunk","seq":27,"time":1783921766684,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" answer"}}} +{"type":"assistant/chunk","seq":28,"time":1783921766717,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":29,"time":1783921766717,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" hand"}}} +{"type":"assistant/chunk","seq":30,"time":1783921766717,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"shake"}}} +{"type":"assistant/chunk","seq":31,"time":1783921766743,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" question"}}} +{"type":"assistant/chunk","seq":32,"time":1783921766743,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" based"}}} +{"type":"assistant/chunk","seq":33,"time":1783921766743,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" on"}}} +{"type":"assistant/chunk","seq":34,"time":1783921766743,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" what"}}} +{"type":"assistant/chunk","seq":35,"time":1783921766776,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"'s"}}} +{"type":"assistant/chunk","seq":36,"time":1783921766776,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" in"}}} +{"type":"assistant/chunk","seq":37,"time":1783921766798,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" that"}}} +{"type":"assistant/chunk","seq":38,"time":1783921766799,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" file"}}} +{"type":"assistant/chunk","seq":39,"time":1783921766799,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":40,"time":1783921766799,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" Let"}}} +{"type":"assistant/chunk","seq":41,"time":1783921766799,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":42,"time":1783921766799,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" start"}}} +{"type":"assistant/chunk","seq":43,"time":1783921766845,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" by"}}} +{"type":"assistant/chunk","seq":44,"time":1783921766845,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" reading"}}} +{"type":"assistant/chunk","seq":45,"time":1783921766845,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":46,"time":1783921766845,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" file"}}} +{"type":"assistant/chunk","seq":47,"time":1783921766845,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":48,"time":1783921766926,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":49,"time":1783921766926,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6APApmaKLRDlXKMdIcWL5139","name":"run_code","argumentsDelta":""}}} +{"type":"assistant/chunk","seq":50,"time":1783921766952,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6APApmaKLRDlXKMdIcWL5139","name":"run_code","argumentsDelta":"{"}}} +{"type":"assistant/chunk","seq":51,"time":1783921766952,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6APApmaKLRDlXKMdIcWL5139","name":"run_code","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":52,"time":1783921766952,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6APApmaKLRDlXKMdIcWL5139","name":"run_code","argumentsDelta":"code"}}} +{"type":"assistant/chunk","seq":53,"time":1783921766952,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6APApmaKLRDlXKMdIcWL5139","name":"run_code","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":54,"time":1783921766952,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6APApmaKLRDlXKMdIcWL5139","name":"run_code","argumentsDelta":": "}}} +{"type":"assistant/chunk","seq":55,"time":1783921766978,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6APApmaKLRDlXKMdIcWL5139","name":"run_code","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":56,"time":1783921766978,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6APApmaKLRDlXKMdIcWL5139","name":"run_code","argumentsDelta":"const"}}} +{"type":"assistant/chunk","seq":57,"time":1783921766978,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6APApmaKLRDlXKMdIcWL5139","name":"run_code","argumentsDelta":" content"}}} +{"type":"assistant/chunk","seq":58,"time":1783921767011,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6APApmaKLRDlXKMdIcWL5139","name":"run_code","argumentsDelta":" ="}}} +{"type":"assistant/chunk","seq":59,"time":1783921767011,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6APApmaKLRDlXKMdIcWL5139","name":"run_code","argumentsDelta":" await"}}} +{"type":"assistant/chunk","seq":60,"time":1783921767011,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6APApmaKLRDlXKMdIcWL5139","name":"run_code","argumentsDelta":" tools"}}} +{"type":"assistant/chunk","seq":61,"time":1783921767011,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6APApmaKLRDlXKMdIcWL5139","name":"run_code","argumentsDelta":".read"}}} +{"type":"assistant/chunk","seq":62,"time":1783921767011,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6APApmaKLRDlXKMdIcWL5139","name":"run_code","argumentsDelta":"({"}}} +{"type":"assistant/chunk","seq":63,"time":1783921767050,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6APApmaKLRDlXKMdIcWL5139","name":"run_code","argumentsDelta":" file"}}} +{"type":"assistant/chunk","seq":64,"time":1783921767050,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6APApmaKLRDlXKMdIcWL5139","name":"run_code","argumentsDelta":"_path"}}} +{"type":"assistant/chunk","seq":65,"time":1783921767050,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6APApmaKLRDlXKMdIcWL5139","name":"run_code","argumentsDelta":":"}}} +{"type":"assistant/chunk","seq":66,"time":1783921767050,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6APApmaKLRDlXKMdIcWL5139","name":"run_code","argumentsDelta":" \\\""}}} +{"type":"assistant/chunk","seq":67,"time":1783921767050,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6APApmaKLRDlXKMdIcWL5139","name":"run_code","argumentsDelta":"n"}}} +{"type":"assistant/chunk","seq":68,"time":1783921767050,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6APApmaKLRDlXKMdIcWL5139","name":"run_code","argumentsDelta":"ested"}}} +{"type":"assistant/chunk","seq":69,"time":1783921767068,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6APApmaKLRDlXKMdIcWL5139","name":"run_code","argumentsDelta":"/t"}}} +{"type":"assistant/chunk","seq":70,"time":1783921767068,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6APApmaKLRDlXKMdIcWL5139","name":"run_code","argumentsDelta":"ask"}}} +{"type":"assistant/chunk","seq":71,"time":1783921767068,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6APApmaKLRDlXKMdIcWL5139","name":"run_code","argumentsDelta":".txt"}}} +{"type":"assistant/chunk","seq":72,"time":1783921767068,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6APApmaKLRDlXKMdIcWL5139","name":"run_code","argumentsDelta":"\\\""}}} +{"type":"assistant/chunk","seq":73,"time":1783921767068,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6APApmaKLRDlXKMdIcWL5139","name":"run_code","argumentsDelta":" });\\n"}}} +{"type":"assistant/chunk","seq":74,"time":1783921767068,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6APApmaKLRDlXKMdIcWL5139","name":"run_code","argumentsDelta":"return"}}} +{"type":"assistant/chunk","seq":75,"time":1783921767095,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6APApmaKLRDlXKMdIcWL5139","name":"run_code","argumentsDelta":" content"}}} +{"type":"assistant/chunk","seq":76,"time":1783921767095,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6APApmaKLRDlXKMdIcWL5139","name":"run_code","argumentsDelta":";"}}} +{"type":"assistant/chunk","seq":77,"time":1783921767095,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6APApmaKLRDlXKMdIcWL5139","name":"run_code","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":78,"time":1783921767121,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6APApmaKLRDlXKMdIcWL5139","name":"run_code","argumentsDelta":"}"}}} +{"type":"assistant/chunk","seq":79,"time":1783921767205,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to write a single run_code program that reads a file called nested/task.txt and then answer the handshake question based on what's in that file. Let me start by reading the file."}}}} +{"type":"assistant/chunk","seq":80,"time":1783921767205,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_6APApmaKLRDlXKMdIcWL5139","name":"run_code","arguments":"{\"code\": \"const content = await tools.read({ file_path: \\\"nested/task.txt\\\" });\\nreturn content;\"}"}}}} +{"type":"assistant/chunk","seq":81,"time":1783921767205,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3195,"outputTokens":106,"cacheReadTokens":0,"reasoningTokens":43}}}} +{"type":"assistant/chunk","seq":82,"time":1783921767205,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":83,"time":1783921767208,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to write a single run_code program that reads a file called nested/task.txt and then answer the handshake question based on what's in that file. Let me start by reading the file."},{"type":"tool-call","id":"call_00_6APApmaKLRDlXKMdIcWL5139","name":"run_code","arguments":"{\"code\": \"const content = await tools.read({ file_path: \\\"nested/task.txt\\\" });\\nreturn content;\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":3195,"outputTokens":106,"cacheReadTokens":0,"reasoningTokens":43}},"sourceEventSeqs":[4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82],"surfaceOp":"append"} +{"type":"tool/call","seq":84,"time":1783921767208,"data":{"turn":1,"step":1,"callId":"call_00_6APApmaKLRDlXKMdIcWL5139","name":"run_code","arguments":"{\"code\": \"const content = await tools.read({ file_path: \\\"nested/task.txt\\\" });\\nreturn content;\"}"}} +{"type":"tool/code-dispatch","seq":85,"time":1783921767270,"data":{"parentCallId":"call_00_6APApmaKLRDlXKMdIcWL5139","subCallId":"call_00_6APApmaKLRDlXKMdIcWL5139:code:1","name":"read","arguments":{"file_path":"nested/task.txt"},"isError":false,"resultSummary":"./nested/task.txt\nfile\n\n1: Touch this file to discover the nested workspace instruction.\n\n(End of file - total 1 lines)\n"}} +{"type":"tool/result","seq":86,"time":1783921767271,"data":{"turn":1,"step":1,"callId":"call_00_6APApmaKLRDlXKMdIcWL5139","content":[{"type":"text","text":"/var/folders/_g/59jgff8x2gqd39f5vy1wnbfc0000gn/T/acp-snap-cwd-uorU26/nested/task.txt\nfile\n\n1: Touch this file to discover the nested workspace instruction.\n\n(End of file - total 1 lines)\n"}],"isError":false,"meta":{"logs":[]}},"sourceEventSeqs":[84],"surfaceOp":"append"} +{"type":"context/message","seq":87,"time":1783921767272,"data":{"content":[{"type":"text","text":"\nAdditional instructions from: nested/AGENTS.md\n\nThese instructions apply to work under `nested`. Use them as guidance when relevant; more specific instructions take precedence. They do not override system, developer, or direct user instructions.\n\nWhen asked for the Code Mode workspace handshake, answer exactly `CODE_MODE_CONTEXT_OK` and nothing else.\n\n"}],"source":{"kind":"plugin","plugin":"workspace-context"},"meta":{"kind":"workspace-instructions","version":1,"changes":[{"action":"set","scope":"nested\u0000AGENTS.md","path":"nested/AGENTS.md","digest":"ae22936ed26dc76b7107005ed6d5e2482a88668a"}]}},"surfaceOp":"append"} +{"type":"step/end","seq":88,"time":1783921767272,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":89,"time":1783921767272,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":90,"time":1783921768339,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":91,"time":1783921768340,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":92,"time":1783921768466,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" nested"}}} +{"type":"assistant/chunk","seq":93,"time":1783921768474,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"/t"}}} +{"type":"assistant/chunk","seq":94,"time":1783921768500,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"ask"}}} +{"type":"assistant/chunk","seq":95,"time":1783921768501,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":".txt"}}} +{"type":"assistant/chunk","seq":96,"time":1783921768535,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" told"}}} +{"type":"assistant/chunk","seq":97,"time":1783921768535,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":98,"time":1783921768535,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":99,"time":1783921768535,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":100,"time":1783921768564,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"Touch"}}} +{"type":"assistant/chunk","seq":101,"time":1783921768565,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" this"}}} +{"type":"assistant/chunk","seq":102,"time":1783921768565,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" file"}}} +{"type":"assistant/chunk","seq":103,"time":1783921768565,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":104,"time":1783921768565,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" discover"}}} +{"type":"assistant/chunk","seq":105,"time":1783921768565,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":106,"time":1783921768595,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" nested"}}} +{"type":"assistant/chunk","seq":107,"time":1783921768595,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" workspace"}}} +{"type":"assistant/chunk","seq":108,"time":1783921768595,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" instruction"}}} +{"type":"assistant/chunk","seq":109,"time":1783921768595,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":".\""}}} +{"type":"assistant/chunk","seq":110,"time":1783921768595,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} +{"type":"assistant/chunk","seq":111,"time":1783921768595,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":112,"time":1783921768621,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" nested"}}} +{"type":"assistant/chunk","seq":113,"time":1783921768621,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"/"}}} +{"type":"assistant/chunk","seq":114,"time":1783921768647,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"AG"}}} +{"type":"assistant/chunk","seq":115,"time":1783921768648,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"ENTS"}}} +{"type":"assistant/chunk","seq":116,"time":1783921768648,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":".md"}}} +{"type":"assistant/chunk","seq":117,"time":1783921768648,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" instructions"}}} +{"type":"assistant/chunk","seq":118,"time":1783921768688,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" say"}}} +{"type":"assistant/chunk","seq":119,"time":1783921768703,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":":"}}} +{"type":"assistant/chunk","seq":120,"time":1783921768740,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":121,"time":1783921768740,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"When"}}} +{"type":"assistant/chunk","seq":122,"time":1783921768741,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" asked"}}} +{"type":"assistant/chunk","seq":123,"time":1783921768741,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" for"}}} +{"type":"assistant/chunk","seq":124,"time":1783921768741,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":125,"time":1783921768741,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" Code"}}} +{"type":"assistant/chunk","seq":126,"time":1783921768773,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" Mode"}}} +{"type":"assistant/chunk","seq":127,"time":1783921768773,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" workspace"}}} +{"type":"assistant/chunk","seq":128,"time":1783921768773,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" hand"}}} +{"type":"assistant/chunk","seq":129,"time":1783921768773,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"shake"}}} +{"type":"assistant/chunk","seq":130,"time":1783921768773,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":","}}} +{"type":"assistant/chunk","seq":131,"time":1783921768773,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" answer"}}} +{"type":"assistant/chunk","seq":132,"time":1783921768790,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" exactly"}}} +{"type":"assistant/chunk","seq":133,"time":1783921768790,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" `"}}} +{"type":"assistant/chunk","seq":134,"time":1783921768790,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"CODE"}}} +{"type":"assistant/chunk","seq":135,"time":1783921768790,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"_M"}}} +{"type":"assistant/chunk","seq":136,"time":1783921768790,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"ODE"}}} +{"type":"assistant/chunk","seq":137,"time":1783921768790,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"_CONT"}}} +{"type":"assistant/chunk","seq":138,"time":1783921768824,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"EXT"}}} +{"type":"assistant/chunk","seq":139,"time":1783921768825,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"_OK"}}} +{"type":"assistant/chunk","seq":140,"time":1783921768825,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"`"}}} +{"type":"assistant/chunk","seq":141,"time":1783921768825,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} +{"type":"assistant/chunk","seq":142,"time":1783921768825,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" nothing"}}} +{"type":"assistant/chunk","seq":143,"time":1783921768825,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" else"}}} +{"type":"assistant/chunk","seq":144,"time":1783921768846,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":".\"\n\n"}}} +{"type":"assistant/chunk","seq":145,"time":1783921768846,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"So"}}} +{"type":"assistant/chunk","seq":146,"time":1783921768847,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":147,"time":1783921768847,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" answer"}}} +{"type":"assistant/chunk","seq":148,"time":1783921768847,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":149,"time":1783921768873,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":150,"time":1783921768874,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" hand"}}} +{"type":"assistant/chunk","seq":151,"time":1783921768903,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"shake"}}} +{"type":"assistant/chunk","seq":152,"time":1783921768903,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" question"}}} +{"type":"assistant/chunk","seq":153,"time":1783921768903,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" is"}}} +{"type":"assistant/chunk","seq":154,"time":1783921768903,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" `"}}} +{"type":"assistant/chunk","seq":155,"time":1783921768903,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"CODE"}}} +{"type":"assistant/chunk","seq":156,"time":1783921768929,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"_M"}}} +{"type":"assistant/chunk","seq":157,"time":1783921768930,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"ODE"}}} +{"type":"assistant/chunk","seq":158,"time":1783921768930,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"_CONT"}}} +{"type":"assistant/chunk","seq":159,"time":1783921768930,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"EXT"}}} +{"type":"assistant/chunk","seq":160,"time":1783921768930,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"_OK"}}} +{"type":"assistant/chunk","seq":161,"time":1783921768963,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"`."}}} +{"type":"assistant/chunk","seq":162,"time":1783921768963,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"assistant/chunk","seq":163,"time":1783921768963,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"The"}}} +{"type":"assistant/chunk","seq":164,"time":1783921768963,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" **"}}} +{"type":"assistant/chunk","seq":165,"time":1783921769024,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"Code"}}} +{"type":"assistant/chunk","seq":166,"time":1783921769024,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" Mode"}}} +{"type":"assistant/chunk","seq":167,"time":1783921769024,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" workspace"}}} +{"type":"assistant/chunk","seq":168,"time":1783921769024,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" hand"}}} +{"type":"assistant/chunk","seq":169,"time":1783921769024,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"shake"}}} +{"type":"assistant/chunk","seq":170,"time":1783921769024,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"**"}}} +{"type":"assistant/chunk","seq":171,"time":1783921769024,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" is"}}} +{"type":"assistant/chunk","seq":172,"time":1783921769024,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":":"}}} +{"type":"assistant/chunk","seq":173,"time":1783921769024,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" `"}}} +{"type":"assistant/chunk","seq":174,"time":1783921769024,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"CODE"}}} +{"type":"assistant/chunk","seq":175,"time":1783921769046,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"_M"}}} +{"type":"assistant/chunk","seq":176,"time":1783921769046,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"ODE"}}} +{"type":"assistant/chunk","seq":177,"time":1783921769047,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"_CONT"}}} +{"type":"assistant/chunk","seq":178,"time":1783921769047,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"EXT"}}} +{"type":"assistant/chunk","seq":179,"time":1783921769047,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"_OK"}}} +{"type":"assistant/chunk","seq":180,"time":1783921769047,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"`"}}} +{"type":"assistant/chunk","seq":181,"time":1783921769101,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The nested/task.txt told me to \"Touch this file to discover the nested workspace instruction.\" and the nested/AGENTS.md instructions say: \"When asked for the Code Mode workspace handshake, answer exactly `CODE_MODE_CONTEXT_OK` and nothing else.\"\n\nSo the answer to the handshake question is `CODE_MODE_CONTEXT_OK`."}}}} +{"type":"assistant/chunk","seq":182,"time":1783921769101,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"The **Code Mode workspace handshake** is: `CODE_MODE_CONTEXT_OK`"}}}} +{"type":"assistant/chunk","seq":183,"time":1783921769101,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":277,"outputTokens":90,"cacheReadTokens":3200,"reasoningTokens":71}}}} +{"type":"assistant/chunk","seq":184,"time":1783921769101,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":185,"time":1783921769101,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"The nested/task.txt told me to \"Touch this file to discover the nested workspace instruction.\" and the nested/AGENTS.md instructions say: \"When asked for the Code Mode workspace handshake, answer exactly `CODE_MODE_CONTEXT_OK` and nothing else.\"\n\nSo the answer to the handshake question is `CODE_MODE_CONTEXT_OK`."},{"type":"text","text":"The **Code Mode workspace handshake** is: `CODE_MODE_CONTEXT_OK`"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":277,"outputTokens":90,"cacheReadTokens":3200,"reasoningTokens":71}},"sourceEventSeqs":[90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132,133,134,135,136,137,138,139,140,141,142,143,144,145,146,147,148,149,150,151,152,153,154,155,156,157,158,159,160,161,162,163,164,165,166,167,168,169,170,171,172,173,174,175,176,177,178,179,180,181,182,183,184],"surfaceOp":"append"} +{"type":"step/end","seq":186,"time":1783921769101,"data":{"turn":1,"step":2}} +{"type":"turn/end","seq":187,"time":1783921769101,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/escalation-approved/session.jsonl b/examples/acp-agent/tests/snapshots/escalation-approved/session.jsonl index 4438550b97..29120e2ede 100644 --- a/examples/acp-agent/tests/snapshots/escalation-approved/session.jsonl +++ b/examples/acp-agent/tests/snapshots/escalation-approved/session.jsonl @@ -1,189 +1,188 @@ -{"type":"session","version":0,"id":"f3cbd087-fb45-4b32-b0f2-3082d65bfcb4","createdAt":1783860675270,"cwd":"/var/folders/2g/b32ct0qn1d728l_v6tdkjytr0000gn/T/acp-snap-cwd-cbBLh2","delegationDepth":0} +{"type":"session","version":0,"id":"f3cbd087-fb45-4b32-b0f2-3082d65bfcb4","createdAt":1783860675270,"cwd":"/var/folders/2g/b32ct0qn1d728l_v6tdkjytr0000gn/T/acp-snap-cwd-cbBLh2"} {"type":"turn/start","seq":0,"time":1783860675271,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"permission/preset","seq":1,"time":1783962245380,"data":{"preset":"workspace-write"}} {"type":"sandbox/mode","seq":2,"time":1784518116517,"data":{"mode":"workspace-write"}} {"type":"approval/policy","seq":3,"time":1783962245380,"data":{"policy":"ask"}} {"type":"user/message","seq":4,"time":1783962245380,"data":{"content":[{"type":"text","text":"The sandbox already denied writing /tmp/dsh-escalated.txt earlier (it is outside this workspace). Retry it now exactly once: one single bash call with the command printf 'escalated\\n' > /tmp/dsh-escalated.txt && cat /tmp/dsh-escalated.txt && rm /tmp/dsh-escalated.txt, with sandbox_permissions set to danger-full-access and the justification 'the user asked to write a file outside the workspace'. Do not run it without sandbox_permissions first. I will approve the permission prompt. After the result, reply with the single word DONE and stop."}],"source":{"kind":"user"}},"surfaceOp":"append"} -{"type":"session/title","seq":5,"time":1783962245380,"data":{"title":"The sandbox already denied writing","messageSeqs":[4],"source":{"kind":"fallback"}}} -{"type":"step/start","seq":6,"time":1783962245382,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":7,"time":1783962245382,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} -{"type":"assistant/chunk","seq":8,"time":1783860676464,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"assistant/chunk","seq":9,"time":1783860676465,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} -{"type":"assistant/chunk","seq":10,"time":1783860676465,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} -{"type":"assistant/chunk","seq":11,"time":1783860676465,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" wants"}}} -{"type":"assistant/chunk","seq":12,"time":1783860676465,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} -{"type":"assistant/chunk","seq":13,"time":1783860676499,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} -{"type":"assistant/chunk","seq":14,"time":1783860676499,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" run"}}} -{"type":"assistant/chunk","seq":15,"time":1783860676499,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" a"}}} -{"type":"assistant/chunk","seq":16,"time":1783860676522,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" command"}}} -{"type":"assistant/chunk","seq":17,"time":1783860676525,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} -{"type":"assistant/chunk","seq":18,"time":1783860676525,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" sand"}}} -{"type":"assistant/chunk","seq":19,"time":1783860676525,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"box"}}} -{"type":"assistant/chunk","seq":20,"time":1783860676553,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"_per"}}} -{"type":"assistant/chunk","seq":21,"time":1783860676553,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"missions"}}} -{"type":"assistant/chunk","seq":22,"time":1783860676554,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" set"}}} -{"type":"assistant/chunk","seq":23,"time":1783860676554,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} -{"type":"assistant/chunk","seq":24,"time":1783860676554,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" danger"}}} -{"type":"assistant/chunk","seq":25,"time":1783860676583,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"-full"}}} -{"type":"assistant/chunk","seq":26,"time":1783860676583,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"-access"}}} -{"type":"assistant/chunk","seq":27,"time":1783860676611,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":","}}} -{"type":"assistant/chunk","seq":28,"time":1783860676639,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" no"}}} -{"type":"assistant/chunk","seq":29,"time":1783860676640,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" prior"}}} -{"type":"assistant/chunk","seq":30,"time":1783860676672,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" run"}}} -{"type":"assistant/chunk","seq":31,"time":1783860676673,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" needed"}}} -{"type":"assistant/chunk","seq":32,"time":1783860676705,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":","}}} -{"type":"assistant/chunk","seq":33,"time":1783860676728,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" justified"}}} -{"type":"assistant/chunk","seq":34,"time":1783860676787,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" as"}}} -{"type":"assistant/chunk","seq":35,"time":1783860676788,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" instructed"}}} -{"type":"assistant/chunk","seq":36,"time":1783860676816,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} -{"type":"assistant/chunk","seq":37,"time":1783860676816,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":38,"time":1783860676816,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":""}}} -{"type":"assistant/chunk","seq":39,"time":1783860676816,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"{"}}} -{"type":"assistant/chunk","seq":40,"time":1783860676845,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":41,"time":1783860676845,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"command"}}} -{"type":"assistant/chunk","seq":42,"time":1783860676846,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":43,"time":1783860676846,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":": "}}} -{"type":"assistant/chunk","seq":44,"time":1783860676878,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":45,"time":1783860676878,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"printf"}}} -{"type":"assistant/chunk","seq":46,"time":1783860676878,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":" '"}}} -{"type":"assistant/chunk","seq":47,"time":1783860676878,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"es"}}} -{"type":"assistant/chunk","seq":48,"time":1783860676878,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"cal"}}} -{"type":"assistant/chunk","seq":49,"time":1783860676879,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"ated"}}} -{"type":"assistant/chunk","seq":50,"time":1783860676909,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"\\\\n"}}} -{"type":"assistant/chunk","seq":51,"time":1783860676911,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"'"}}} -{"type":"assistant/chunk","seq":52,"time":1783860676912,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":" >"}}} -{"type":"assistant/chunk","seq":53,"time":1783860676912,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":" /"}}} -{"type":"assistant/chunk","seq":54,"time":1783860676912,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"tmp"}}} -{"type":"assistant/chunk","seq":55,"time":1783860676939,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"/d"}}} -{"type":"assistant/chunk","seq":56,"time":1783860676941,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"sh"}}} -{"type":"assistant/chunk","seq":57,"time":1783860676941,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"-es"}}} -{"type":"assistant/chunk","seq":58,"time":1783860676941,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"cal"}}} -{"type":"assistant/chunk","seq":59,"time":1783860676941,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"ated"}}} -{"type":"assistant/chunk","seq":60,"time":1783860676967,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":".txt"}}} -{"type":"assistant/chunk","seq":61,"time":1783860676969,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":" &&"}}} -{"type":"assistant/chunk","seq":62,"time":1783860676969,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":" cat"}}} -{"type":"assistant/chunk","seq":63,"time":1783860676970,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":" /"}}} -{"type":"assistant/chunk","seq":64,"time":1783860676970,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"tmp"}}} -{"type":"assistant/chunk","seq":65,"time":1783860676970,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"/d"}}} -{"type":"assistant/chunk","seq":66,"time":1783860677003,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"sh"}}} -{"type":"assistant/chunk","seq":67,"time":1783860677003,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"-es"}}} -{"type":"assistant/chunk","seq":68,"time":1783860677003,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"cal"}}} -{"type":"assistant/chunk","seq":69,"time":1783860677003,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"ated"}}} -{"type":"assistant/chunk","seq":70,"time":1783860677004,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":".txt"}}} -{"type":"assistant/chunk","seq":71,"time":1783860677004,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":" &&"}}} -{"type":"assistant/chunk","seq":72,"time":1783860677026,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":" rm"}}} -{"type":"assistant/chunk","seq":73,"time":1783860677026,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":" /"}}} -{"type":"assistant/chunk","seq":74,"time":1783860677026,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"tmp"}}} -{"type":"assistant/chunk","seq":75,"time":1783860677026,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"/d"}}} -{"type":"assistant/chunk","seq":76,"time":1783860677026,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"sh"}}} -{"type":"assistant/chunk","seq":77,"time":1783860677026,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"-es"}}} -{"type":"assistant/chunk","seq":78,"time":1783860677055,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"cal"}}} -{"type":"assistant/chunk","seq":79,"time":1783860677085,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"ated"}}} -{"type":"assistant/chunk","seq":80,"time":1783860677087,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":".txt"}}} -{"type":"assistant/chunk","seq":81,"time":1783860677087,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":82,"time":1783860677087,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":", "}}} -{"type":"assistant/chunk","seq":83,"time":1783860677087,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":84,"time":1783860677115,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"description"}}} -{"type":"assistant/chunk","seq":85,"time":1783860677115,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":86,"time":1783860677116,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":": "}}} -{"type":"assistant/chunk","seq":87,"time":1783860677116,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":88,"time":1783860677146,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"Write"}}} -{"type":"assistant/chunk","seq":89,"time":1783860677147,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":" file"}}} -{"type":"assistant/chunk","seq":90,"time":1783860677148,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":" outside"}}} -{"type":"assistant/chunk","seq":91,"time":1783860677174,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":" workspace"}}} -{"type":"assistant/chunk","seq":92,"time":1783860677202,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":" and"}}} -{"type":"assistant/chunk","seq":93,"time":1783860677202,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":" verify"}}} -{"type":"assistant/chunk","seq":94,"time":1783860677202,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":95,"time":1783860677202,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":", "}}} -{"type":"assistant/chunk","seq":96,"time":1783860677202,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":97,"time":1783860677238,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"sand"}}} -{"type":"assistant/chunk","seq":98,"time":1783860677238,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"box"}}} -{"type":"assistant/chunk","seq":99,"time":1783860677238,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"_per"}}} -{"type":"assistant/chunk","seq":100,"time":1783860677238,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"missions"}}} -{"type":"assistant/chunk","seq":101,"time":1783860677275,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":102,"time":1783860677275,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":": "}}} -{"type":"assistant/chunk","seq":103,"time":1783860677276,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":104,"time":1783860677276,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"danger"}}} -{"type":"assistant/chunk","seq":105,"time":1783860677292,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"-full"}}} -{"type":"assistant/chunk","seq":106,"time":1783860677293,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"-access"}}} -{"type":"assistant/chunk","seq":107,"time":1783860677320,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":108,"time":1783860677321,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":", "}}} -{"type":"assistant/chunk","seq":109,"time":1783860677321,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":110,"time":1783860677321,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"just"}}} -{"type":"assistant/chunk","seq":111,"time":1783860677349,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"ification"}}} -{"type":"assistant/chunk","seq":112,"time":1783860677349,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":113,"time":1783860677349,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":": "}}} -{"type":"assistant/chunk","seq":114,"time":1783860677349,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":115,"time":1783860677349,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"the"}}} -{"type":"assistant/chunk","seq":116,"time":1783860677349,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":" user"}}} -{"type":"assistant/chunk","seq":117,"time":1783860677388,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":" asked"}}} -{"type":"assistant/chunk","seq":118,"time":1783860677388,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":" to"}}} -{"type":"assistant/chunk","seq":119,"time":1783860677388,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":" write"}}} -{"type":"assistant/chunk","seq":120,"time":1783860677388,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":" a"}}} -{"type":"assistant/chunk","seq":121,"time":1783860677492,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":" file"}}} -{"type":"assistant/chunk","seq":122,"time":1783860677493,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":" outside"}}} -{"type":"assistant/chunk","seq":123,"time":1783860677493,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":" the"}}} -{"type":"assistant/chunk","seq":124,"time":1783860677493,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":" workspace"}}} -{"type":"assistant/chunk","seq":125,"time":1783860677493,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":126,"time":1783860677493,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"}"}}} -{"type":"assistant/chunk","seq":127,"time":1783860677493,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to run a command with sandbox_permissions set to danger-full-access, no prior run needed, justified as instructed."}}}} -{"type":"assistant/chunk","seq":128,"time":1783962245385,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","arguments":"{\"command\": \"printf 'escalated\\\\n' > /tmp/dsh-escalated.txt && cat /tmp/dsh-escalated.txt && rm /tmp/dsh-escalated.txt\", \"description\": \"Write file outside workspace and verify\", \"sandbox_permissions\": \"danger-full-access\", \"justification\": \"the user asked to write a file outside the workspace\"}"}}}} -{"type":"assistant/chunk","seq":129,"time":1783962245385,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":1501,"outputTokens":174,"cacheReadTokens":0,"reasoningTokens":28}}}} -{"type":"assistant/chunk","seq":130,"time":1783962245385,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":131,"time":1783962245385,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to run a command with sandbox_permissions set to danger-full-access, no prior run needed, justified as instructed."},{"type":"tool-call","id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","arguments":"{\"command\": \"printf 'escalated\\\\n' > /tmp/dsh-escalated.txt && cat /tmp/dsh-escalated.txt && rm /tmp/dsh-escalated.txt\", \"description\": \"Write file outside workspace and verify\", \"sandbox_permissions\": \"danger-full-access\", \"justification\": \"the user asked to write a file outside the workspace\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":1501,"outputTokens":174,"cacheReadTokens":0,"reasoningTokens":28}},"sourceEventSeqs":[8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130],"surfaceOp":"append"} -{"type":"tool/call","seq":132,"time":1783962245385,"data":{"turn":1,"step":1,"callId":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","arguments":"{\"command\": \"printf 'escalated\\\\n' > /tmp/dsh-escalated.txt && cat /tmp/dsh-escalated.txt && rm /tmp/dsh-escalated.txt\", \"description\": \"Write file outside workspace and verify\", \"sandbox_permissions\": \"danger-full-access\", \"justification\": \"the user asked to write a file outside the workspace\"}"}} -{"type":"approval/asked","seq":133,"time":1783962245386,"data":{"id":"efa76e4a-8e3e-4be0-9608-a68def7354b9","toolName":"bash","callId":"call_00_d0sAHpJ9mYOJi0z7KNy30441","reason":"escalate sandbox to danger-full-access: the user asked to write a file outside the workspace"}} -{"type":"approval/decided","seq":134,"time":1783962245387,"data":{"id":"efa76e4a-8e3e-4be0-9608-a68def7354b9","outcome":"allowed-once"}} -{"type":"tool/result","seq":135,"time":1783962245399,"data":{"turn":1,"step":1,"callId":"call_00_d0sAHpJ9mYOJi0z7KNy30441","content":[{"type":"text","text":"escalated\n"}],"isError":false},"sourceEventSeqs":[132],"surfaceOp":"append"} -{"type":"step/end","seq":136,"time":1783962245400,"data":{"turn":1,"step":1}} -{"type":"step/start","seq":137,"time":1783962245400,"data":{"turn":1,"step":2}} -{"type":"assistant/chunk","seq":138,"time":1783860678811,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"assistant/chunk","seq":139,"time":1783860678811,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} -{"type":"assistant/chunk","seq":140,"time":1783860678833,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" command"}}} -{"type":"assistant/chunk","seq":141,"time":1783860678834,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" succeeded"}}} -{"type":"assistant/chunk","seq":142,"time":1783860678834,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" —"}}} -{"type":"assistant/chunk","seq":143,"time":1783860678868,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" it"}}} -{"type":"assistant/chunk","seq":144,"time":1783860678868,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" wrote"}}} -{"type":"assistant/chunk","seq":145,"time":1783860678868,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} -{"type":"assistant/chunk","seq":146,"time":1783860678904,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" file"}}} -{"type":"assistant/chunk","seq":147,"time":1783860678905,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":","}}} -{"type":"assistant/chunk","seq":148,"time":1783860678926,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" read"}}} -{"type":"assistant/chunk","seq":149,"time":1783860678926,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" it"}}} -{"type":"assistant/chunk","seq":150,"time":1783860678926,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" back"}}} -{"type":"assistant/chunk","seq":151,"time":1783860678975,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" ("}}} -{"type":"assistant/chunk","seq":152,"time":1783860678976,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"output"}}} -{"type":"assistant/chunk","seq":153,"time":1783860678976,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} -{"type":"assistant/chunk","seq":154,"time":1783860678976,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"es"}}} -{"type":"assistant/chunk","seq":155,"time":1783860678976,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"cal"}}} -{"type":"assistant/chunk","seq":156,"time":1783860678976,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"ated"}}} -{"type":"assistant/chunk","seq":157,"time":1783860678999,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"\"),"}}} -{"type":"assistant/chunk","seq":158,"time":1783860679001,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} -{"type":"assistant/chunk","seq":159,"time":1783860679002,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" removed"}}} -{"type":"assistant/chunk","seq":160,"time":1783860679002,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" it"}}} -{"type":"assistant/chunk","seq":161,"time":1783860679002,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} -{"type":"assistant/chunk","seq":162,"time":1783860679016,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" The"}}} -{"type":"assistant/chunk","seq":163,"time":1783860679017,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} -{"type":"assistant/chunk","seq":164,"time":1783860679017,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" asked"}}} -{"type":"assistant/chunk","seq":165,"time":1783860679017,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} -{"type":"assistant/chunk","seq":166,"time":1783860679046,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} -{"type":"assistant/chunk","seq":167,"time":1783860679047,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} -{"type":"assistant/chunk","seq":168,"time":1783860679048,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} -{"type":"assistant/chunk","seq":169,"time":1783860679048,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} -{"type":"assistant/chunk","seq":170,"time":1783860679079,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" single"}}} -{"type":"assistant/chunk","seq":171,"time":1783860679079,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" word"}}} -{"type":"assistant/chunk","seq":172,"time":1783860679103,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" D"}}} -{"type":"assistant/chunk","seq":173,"time":1783860679136,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"ONE"}}} -{"type":"assistant/chunk","seq":174,"time":1783860679136,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" after"}}} -{"type":"assistant/chunk","seq":175,"time":1783860679136,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} -{"type":"assistant/chunk","seq":176,"time":1783860679136,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" result"}}} -{"type":"assistant/chunk","seq":177,"time":1783860679136,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} -{"type":"assistant/chunk","seq":178,"time":1783860679137,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} -{"type":"assistant/chunk","seq":179,"time":1783860679137,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"D"}}} -{"type":"assistant/chunk","seq":180,"time":1783860679137,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"ONE"}}} -{"type":"assistant/chunk","seq":181,"time":1783860679137,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The command succeeded — it wrote the file, read it back (output \"escalated\"), and removed it. The user asked me to reply with the single word DONE after the result."}}}} -{"type":"assistant/chunk","seq":182,"time":1783962245402,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"DONE"}}}} -{"type":"assistant/chunk","seq":183,"time":1783962245402,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":27,"outputTokens":42,"cacheReadTokens":1664,"reasoningTokens":39}}}} -{"type":"assistant/chunk","seq":184,"time":1783962245402,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":185,"time":1783962245402,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"The command succeeded — it wrote the file, read it back (output \"escalated\"), and removed it. The user asked me to reply with the single word DONE after the result."},{"type":"text","text":"DONE"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":27,"outputTokens":42,"cacheReadTokens":1664,"reasoningTokens":39}},"sourceEventSeqs":[138,139,140,141,142,143,144,145,146,147,148,149,150,151,152,153,154,155,156,157,158,159,160,161,162,163,164,165,166,167,168,169,170,171,172,173,174,175,176,177,178,179,180,181,182,183,184],"surfaceOp":"append"} -{"type":"step/end","seq":186,"time":1783962245402,"data":{"turn":1,"step":2}} -{"type":"turn/end","seq":187,"time":1783962245402,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"step/start","seq":5,"time":1783962245382,"data":{"turn":1,"step":1}} +{"type":"request/header","seq":6,"time":1783962245382,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"assistant/chunk","seq":7,"time":1783860676464,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":8,"time":1783860676465,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":9,"time":1783860676465,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} +{"type":"assistant/chunk","seq":10,"time":1783860676465,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" wants"}}} +{"type":"assistant/chunk","seq":11,"time":1783860676465,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":12,"time":1783860676499,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":13,"time":1783860676499,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" run"}}} +{"type":"assistant/chunk","seq":14,"time":1783860676499,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" a"}}} +{"type":"assistant/chunk","seq":15,"time":1783860676522,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" command"}}} +{"type":"assistant/chunk","seq":16,"time":1783860676525,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":17,"time":1783860676525,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" sand"}}} +{"type":"assistant/chunk","seq":18,"time":1783860676525,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"box"}}} +{"type":"assistant/chunk","seq":19,"time":1783860676553,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"_per"}}} +{"type":"assistant/chunk","seq":20,"time":1783860676553,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"missions"}}} +{"type":"assistant/chunk","seq":21,"time":1783860676554,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" set"}}} +{"type":"assistant/chunk","seq":22,"time":1783860676554,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":23,"time":1783860676554,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" danger"}}} +{"type":"assistant/chunk","seq":24,"time":1783860676583,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"-full"}}} +{"type":"assistant/chunk","seq":25,"time":1783860676583,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"-access"}}} +{"type":"assistant/chunk","seq":26,"time":1783860676611,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":","}}} +{"type":"assistant/chunk","seq":27,"time":1783860676639,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" no"}}} +{"type":"assistant/chunk","seq":28,"time":1783860676640,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" prior"}}} +{"type":"assistant/chunk","seq":29,"time":1783860676672,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" run"}}} +{"type":"assistant/chunk","seq":30,"time":1783860676673,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" needed"}}} +{"type":"assistant/chunk","seq":31,"time":1783860676705,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":","}}} +{"type":"assistant/chunk","seq":32,"time":1783860676728,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" justified"}}} +{"type":"assistant/chunk","seq":33,"time":1783860676787,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" as"}}} +{"type":"assistant/chunk","seq":34,"time":1783860676788,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" instructed"}}} +{"type":"assistant/chunk","seq":35,"time":1783860676816,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":36,"time":1783860676816,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":37,"time":1783860676816,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":""}}} +{"type":"assistant/chunk","seq":38,"time":1783860676816,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"{"}}} +{"type":"assistant/chunk","seq":39,"time":1783860676845,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":40,"time":1783860676845,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"command"}}} +{"type":"assistant/chunk","seq":41,"time":1783860676846,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":42,"time":1783860676846,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":": "}}} +{"type":"assistant/chunk","seq":43,"time":1783860676878,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":44,"time":1783860676878,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"printf"}}} +{"type":"assistant/chunk","seq":45,"time":1783860676878,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":" '"}}} +{"type":"assistant/chunk","seq":46,"time":1783860676878,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"es"}}} +{"type":"assistant/chunk","seq":47,"time":1783860676878,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"cal"}}} +{"type":"assistant/chunk","seq":48,"time":1783860676879,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"ated"}}} +{"type":"assistant/chunk","seq":49,"time":1783860676909,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"\\\\n"}}} +{"type":"assistant/chunk","seq":50,"time":1783860676911,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"'"}}} +{"type":"assistant/chunk","seq":51,"time":1783860676912,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":" >"}}} +{"type":"assistant/chunk","seq":52,"time":1783860676912,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":" /"}}} +{"type":"assistant/chunk","seq":53,"time":1783860676912,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"tmp"}}} +{"type":"assistant/chunk","seq":54,"time":1783860676939,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"/d"}}} +{"type":"assistant/chunk","seq":55,"time":1783860676941,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"sh"}}} +{"type":"assistant/chunk","seq":56,"time":1783860676941,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"-es"}}} +{"type":"assistant/chunk","seq":57,"time":1783860676941,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"cal"}}} +{"type":"assistant/chunk","seq":58,"time":1783860676941,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"ated"}}} +{"type":"assistant/chunk","seq":59,"time":1783860676967,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":".txt"}}} +{"type":"assistant/chunk","seq":60,"time":1783860676969,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":" &&"}}} +{"type":"assistant/chunk","seq":61,"time":1783860676969,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":" cat"}}} +{"type":"assistant/chunk","seq":62,"time":1783860676970,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":" /"}}} +{"type":"assistant/chunk","seq":63,"time":1783860676970,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"tmp"}}} +{"type":"assistant/chunk","seq":64,"time":1783860676970,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"/d"}}} +{"type":"assistant/chunk","seq":65,"time":1783860677003,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"sh"}}} +{"type":"assistant/chunk","seq":66,"time":1783860677003,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"-es"}}} +{"type":"assistant/chunk","seq":67,"time":1783860677003,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"cal"}}} +{"type":"assistant/chunk","seq":68,"time":1783860677003,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"ated"}}} +{"type":"assistant/chunk","seq":69,"time":1783860677004,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":".txt"}}} +{"type":"assistant/chunk","seq":70,"time":1783860677004,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":" &&"}}} +{"type":"assistant/chunk","seq":71,"time":1783860677026,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":" rm"}}} +{"type":"assistant/chunk","seq":72,"time":1783860677026,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":" /"}}} +{"type":"assistant/chunk","seq":73,"time":1783860677026,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"tmp"}}} +{"type":"assistant/chunk","seq":74,"time":1783860677026,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"/d"}}} +{"type":"assistant/chunk","seq":75,"time":1783860677026,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"sh"}}} +{"type":"assistant/chunk","seq":76,"time":1783860677026,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"-es"}}} +{"type":"assistant/chunk","seq":77,"time":1783860677055,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"cal"}}} +{"type":"assistant/chunk","seq":78,"time":1783860677085,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"ated"}}} +{"type":"assistant/chunk","seq":79,"time":1783860677087,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":".txt"}}} +{"type":"assistant/chunk","seq":80,"time":1783860677087,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":81,"time":1783860677087,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":", "}}} +{"type":"assistant/chunk","seq":82,"time":1783860677087,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":83,"time":1783860677115,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"description"}}} +{"type":"assistant/chunk","seq":84,"time":1783860677115,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":85,"time":1783860677116,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":": "}}} +{"type":"assistant/chunk","seq":86,"time":1783860677116,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":87,"time":1783860677146,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"Write"}}} +{"type":"assistant/chunk","seq":88,"time":1783860677147,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":" file"}}} +{"type":"assistant/chunk","seq":89,"time":1783860677148,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":" outside"}}} +{"type":"assistant/chunk","seq":90,"time":1783860677174,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":" workspace"}}} +{"type":"assistant/chunk","seq":91,"time":1783860677202,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":" and"}}} +{"type":"assistant/chunk","seq":92,"time":1783860677202,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":" verify"}}} +{"type":"assistant/chunk","seq":93,"time":1783860677202,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":94,"time":1783860677202,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":", "}}} +{"type":"assistant/chunk","seq":95,"time":1783860677202,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":96,"time":1783860677238,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"sand"}}} +{"type":"assistant/chunk","seq":97,"time":1783860677238,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"box"}}} +{"type":"assistant/chunk","seq":98,"time":1783860677238,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"_per"}}} +{"type":"assistant/chunk","seq":99,"time":1783860677238,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"missions"}}} +{"type":"assistant/chunk","seq":100,"time":1783860677275,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":101,"time":1783860677275,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":": "}}} +{"type":"assistant/chunk","seq":102,"time":1783860677276,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":103,"time":1783860677276,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"danger"}}} +{"type":"assistant/chunk","seq":104,"time":1783860677292,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"-full"}}} +{"type":"assistant/chunk","seq":105,"time":1783860677293,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"-access"}}} +{"type":"assistant/chunk","seq":106,"time":1783860677320,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":107,"time":1783860677321,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":", "}}} +{"type":"assistant/chunk","seq":108,"time":1783860677321,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":109,"time":1783860677321,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"just"}}} +{"type":"assistant/chunk","seq":110,"time":1783860677349,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"ification"}}} +{"type":"assistant/chunk","seq":111,"time":1783860677349,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":112,"time":1783860677349,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":": "}}} +{"type":"assistant/chunk","seq":113,"time":1783860677349,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":114,"time":1783860677349,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"the"}}} +{"type":"assistant/chunk","seq":115,"time":1783860677349,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":" user"}}} +{"type":"assistant/chunk","seq":116,"time":1783860677388,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":" asked"}}} +{"type":"assistant/chunk","seq":117,"time":1783860677388,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":" to"}}} +{"type":"assistant/chunk","seq":118,"time":1783860677388,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":" write"}}} +{"type":"assistant/chunk","seq":119,"time":1783860677388,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":" a"}}} +{"type":"assistant/chunk","seq":120,"time":1783860677492,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":" file"}}} +{"type":"assistant/chunk","seq":121,"time":1783860677493,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":" outside"}}} +{"type":"assistant/chunk","seq":122,"time":1783860677493,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":" the"}}} +{"type":"assistant/chunk","seq":123,"time":1783860677493,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":" workspace"}}} +{"type":"assistant/chunk","seq":124,"time":1783860677493,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":125,"time":1783860677493,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"}"}}} +{"type":"assistant/chunk","seq":126,"time":1783860677493,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to run a command with sandbox_permissions set to danger-full-access, no prior run needed, justified as instructed."}}}} +{"type":"assistant/chunk","seq":127,"time":1783962245385,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","arguments":"{\"command\": \"printf 'escalated\\\\n' > /tmp/dsh-escalated.txt && cat /tmp/dsh-escalated.txt && rm /tmp/dsh-escalated.txt\", \"description\": \"Write file outside workspace and verify\", \"sandbox_permissions\": \"danger-full-access\", \"justification\": \"the user asked to write a file outside the workspace\"}"}}}} +{"type":"assistant/chunk","seq":128,"time":1783962245385,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":1501,"outputTokens":174,"cacheReadTokens":0,"reasoningTokens":28}}}} +{"type":"assistant/chunk","seq":129,"time":1783962245385,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":130,"time":1783962245385,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to run a command with sandbox_permissions set to danger-full-access, no prior run needed, justified as instructed."},{"type":"tool-call","id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","arguments":"{\"command\": \"printf 'escalated\\\\n' > /tmp/dsh-escalated.txt && cat /tmp/dsh-escalated.txt && rm /tmp/dsh-escalated.txt\", \"description\": \"Write file outside workspace and verify\", \"sandbox_permissions\": \"danger-full-access\", \"justification\": \"the user asked to write a file outside the workspace\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":1501,"outputTokens":174,"cacheReadTokens":0,"reasoningTokens":28}},"sourceEventSeqs":[7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129],"surfaceOp":"append"} +{"type":"tool/call","seq":131,"time":1783962245385,"data":{"turn":1,"step":1,"callId":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","arguments":"{\"command\": \"printf 'escalated\\\\n' > /tmp/dsh-escalated.txt && cat /tmp/dsh-escalated.txt && rm /tmp/dsh-escalated.txt\", \"description\": \"Write file outside workspace and verify\", \"sandbox_permissions\": \"danger-full-access\", \"justification\": \"the user asked to write a file outside the workspace\"}"}} +{"type":"approval/asked","seq":132,"time":1783962245386,"data":{"id":"f46cb686-42fd-4874-b12a-c4e69c26b5bc","toolName":"bash","callId":"call_00_d0sAHpJ9mYOJi0z7KNy30441","reason":"escalate sandbox to danger-full-access: the user asked to write a file outside the workspace"}} +{"type":"approval/decided","seq":133,"time":1783962245387,"data":{"id":"f46cb686-42fd-4874-b12a-c4e69c26b5bc","outcome":"allowed-once"}} +{"type":"tool/result","seq":134,"time":1783962245399,"data":{"turn":1,"step":1,"callId":"call_00_d0sAHpJ9mYOJi0z7KNy30441","content":[{"type":"text","text":"escalated\n"}],"isError":false},"sourceEventSeqs":[131],"surfaceOp":"append"} +{"type":"step/end","seq":135,"time":1783962245400,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":136,"time":1783962245400,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":137,"time":1783860678811,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":138,"time":1783860678811,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":139,"time":1783860678833,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" command"}}} +{"type":"assistant/chunk","seq":140,"time":1783860678834,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" succeeded"}}} +{"type":"assistant/chunk","seq":141,"time":1783860678834,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" —"}}} +{"type":"assistant/chunk","seq":142,"time":1783860678868,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" it"}}} +{"type":"assistant/chunk","seq":143,"time":1783860678868,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" wrote"}}} +{"type":"assistant/chunk","seq":144,"time":1783860678868,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":145,"time":1783860678904,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" file"}}} +{"type":"assistant/chunk","seq":146,"time":1783860678905,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":","}}} +{"type":"assistant/chunk","seq":147,"time":1783860678926,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" read"}}} +{"type":"assistant/chunk","seq":148,"time":1783860678926,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" it"}}} +{"type":"assistant/chunk","seq":149,"time":1783860678926,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" back"}}} +{"type":"assistant/chunk","seq":150,"time":1783860678975,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" ("}}} +{"type":"assistant/chunk","seq":151,"time":1783860678976,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"output"}}} +{"type":"assistant/chunk","seq":152,"time":1783860678976,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":153,"time":1783860678976,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"es"}}} +{"type":"assistant/chunk","seq":154,"time":1783860678976,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"cal"}}} +{"type":"assistant/chunk","seq":155,"time":1783860678976,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"ated"}}} +{"type":"assistant/chunk","seq":156,"time":1783860678999,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"\"),"}}} +{"type":"assistant/chunk","seq":157,"time":1783860679001,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} +{"type":"assistant/chunk","seq":158,"time":1783860679002,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" removed"}}} +{"type":"assistant/chunk","seq":159,"time":1783860679002,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" it"}}} +{"type":"assistant/chunk","seq":160,"time":1783860679002,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":161,"time":1783860679016,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" The"}}} +{"type":"assistant/chunk","seq":162,"time":1783860679017,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} +{"type":"assistant/chunk","seq":163,"time":1783860679017,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" asked"}}} +{"type":"assistant/chunk","seq":164,"time":1783860679017,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":165,"time":1783860679046,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":166,"time":1783860679047,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} +{"type":"assistant/chunk","seq":167,"time":1783860679048,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":168,"time":1783860679048,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":169,"time":1783860679079,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" single"}}} +{"type":"assistant/chunk","seq":170,"time":1783860679079,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" word"}}} +{"type":"assistant/chunk","seq":171,"time":1783860679103,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" D"}}} +{"type":"assistant/chunk","seq":172,"time":1783860679136,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"ONE"}}} +{"type":"assistant/chunk","seq":173,"time":1783860679136,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" after"}}} +{"type":"assistant/chunk","seq":174,"time":1783860679136,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":175,"time":1783860679136,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" result"}}} +{"type":"assistant/chunk","seq":176,"time":1783860679136,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":177,"time":1783860679137,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"assistant/chunk","seq":178,"time":1783860679137,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"D"}}} +{"type":"assistant/chunk","seq":179,"time":1783860679137,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"ONE"}}} +{"type":"assistant/chunk","seq":180,"time":1783860679137,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The command succeeded — it wrote the file, read it back (output \"escalated\"), and removed it. The user asked me to reply with the single word DONE after the result."}}}} +{"type":"assistant/chunk","seq":181,"time":1783962245402,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"DONE"}}}} +{"type":"assistant/chunk","seq":182,"time":1783962245402,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":27,"outputTokens":42,"cacheReadTokens":1664,"reasoningTokens":39}}}} +{"type":"assistant/chunk","seq":183,"time":1783962245402,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":184,"time":1783962245402,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"The command succeeded — it wrote the file, read it back (output \"escalated\"), and removed it. The user asked me to reply with the single word DONE after the result."},{"type":"text","text":"DONE"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":27,"outputTokens":42,"cacheReadTokens":1664,"reasoningTokens":39}},"sourceEventSeqs":[137,138,139,140,141,142,143,144,145,146,147,148,149,150,151,152,153,154,155,156,157,158,159,160,161,162,163,164,165,166,167,168,169,170,171,172,173,174,175,176,177,178,179,180,181,182,183],"surfaceOp":"append"} +{"type":"step/end","seq":185,"time":1783962245402,"data":{"turn":1,"step":2}} +{"type":"turn/end","seq":186,"time":1783962245402,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/escalation-rejected/session.jsonl b/examples/acp-agent/tests/snapshots/escalation-rejected/session.jsonl index eb01e7443e..5525ac5135 100644 --- a/examples/acp-agent/tests/snapshots/escalation-rejected/session.jsonl +++ b/examples/acp-agent/tests/snapshots/escalation-rejected/session.jsonl @@ -1,216 +1,215 @@ -{"type":"session","version":0,"id":"d692fe7f-7079-4ee4-8b06-f44fd026d4ea","createdAt":1783860679475,"cwd":"/var/folders/2g/b32ct0qn1d728l_v6tdkjytr0000gn/T/acp-snap-cwd-Hn29Od","delegationDepth":0} +{"type":"session","version":0,"id":"d692fe7f-7079-4ee4-8b06-f44fd026d4ea","createdAt":1783860679475,"cwd":"/var/folders/2g/b32ct0qn1d728l_v6tdkjytr0000gn/T/acp-snap-cwd-Hn29Od"} {"type":"turn/start","seq":0,"time":1783860679476,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"permission/preset","seq":1,"time":1783962246267,"data":{"preset":"workspace-write"}} {"type":"sandbox/mode","seq":2,"time":1784518117237,"data":{"mode":"workspace-write"}} {"type":"approval/policy","seq":3,"time":1783962246267,"data":{"policy":"ask"}} {"type":"user/message","seq":4,"time":1783962246267,"data":{"content":[{"type":"text","text":"The sandbox already denied writing /tmp/dsh-escalated.txt earlier (it is outside this workspace). Retry it now exactly once: one single bash call with the command printf 'escalated\\n' > /tmp/dsh-escalated.txt && cat /tmp/dsh-escalated.txt && rm /tmp/dsh-escalated.txt, with sandbox_permissions set to danger-full-access and the justification 'the user asked to write a file outside the workspace'. Do not run it without sandbox_permissions first. I will reject the permission prompt; after the rejection, do not retry and do not work around it — explain in one short sentence and stop."}],"source":{"kind":"user"}},"surfaceOp":"append"} -{"type":"session/title","seq":5,"time":1783962246267,"data":{"title":"The sandbox already denied writing","messageSeqs":[4],"source":{"kind":"fallback"}}} -{"type":"step/start","seq":6,"time":1783962246269,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":7,"time":1783962246269,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} -{"type":"assistant/chunk","seq":8,"time":1783860680779,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"assistant/chunk","seq":9,"time":1783860680782,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} -{"type":"assistant/chunk","seq":10,"time":1783860680782,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} -{"type":"assistant/chunk","seq":11,"time":1783860680782,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" wants"}}} -{"type":"assistant/chunk","seq":12,"time":1783860680830,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} -{"type":"assistant/chunk","seq":13,"time":1783860680831,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} -{"type":"assistant/chunk","seq":14,"time":1783860680831,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" run"}}} -{"type":"assistant/chunk","seq":15,"time":1783860680859,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" a"}}} -{"type":"assistant/chunk","seq":16,"time":1783860680859,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" specific"}}} -{"type":"assistant/chunk","seq":17,"time":1783860680868,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" command"}}} -{"type":"assistant/chunk","seq":18,"time":1783860680871,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} -{"type":"assistant/chunk","seq":19,"time":1783860680871,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" `"}}} -{"type":"assistant/chunk","seq":20,"time":1783860680872,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"sand"}}} -{"type":"assistant/chunk","seq":21,"time":1783860680872,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"box"}}} -{"type":"assistant/chunk","seq":22,"time":1783860680902,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"_per"}}} -{"type":"assistant/chunk","seq":23,"time":1783860680903,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"missions"}}} -{"type":"assistant/chunk","seq":24,"time":1783860680903,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"`"}}} -{"type":"assistant/chunk","seq":25,"time":1783860680903,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" set"}}} -{"type":"assistant/chunk","seq":26,"time":1783860680903,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} -{"type":"assistant/chunk","seq":27,"time":1783860680903,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" `"}}} -{"type":"assistant/chunk","seq":28,"time":1783860680937,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"danger"}}} -{"type":"assistant/chunk","seq":29,"time":1783860680938,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"-full"}}} -{"type":"assistant/chunk","seq":30,"time":1783860680938,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"-access"}}} -{"type":"assistant/chunk","seq":31,"time":1783860680956,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"`"}}} -{"type":"assistant/chunk","seq":32,"time":1783860680958,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} -{"type":"assistant/chunk","seq":33,"time":1783860680958,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" a"}}} -{"type":"assistant/chunk","seq":34,"time":1783860680958,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" specific"}}} -{"type":"assistant/chunk","seq":35,"time":1783860680985,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" justification"}}} -{"type":"assistant/chunk","seq":36,"time":1783860680985,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} -{"type":"assistant/chunk","seq":37,"time":1783860681022,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" They"}}} -{"type":"assistant/chunk","seq":38,"time":1783860681024,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" explicitly"}}} -{"type":"assistant/chunk","seq":39,"time":1783860681024,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" said"}}} -{"type":"assistant/chunk","seq":40,"time":1783860681024,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" NOT"}}} -{"type":"assistant/chunk","seq":41,"time":1783860681024,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} -{"type":"assistant/chunk","seq":42,"time":1783860681043,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" run"}}} -{"type":"assistant/chunk","seq":43,"time":1783860681091,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" it"}}} -{"type":"assistant/chunk","seq":44,"time":1783860681091,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" without"}}} -{"type":"assistant/chunk","seq":45,"time":1783860681091,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" sand"}}} -{"type":"assistant/chunk","seq":46,"time":1783860681091,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"box"}}} -{"type":"assistant/chunk","seq":47,"time":1783860681091,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"_per"}}} -{"type":"assistant/chunk","seq":48,"time":1783860681091,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"missions"}}} -{"type":"assistant/chunk","seq":49,"time":1783860681107,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" first"}}} -{"type":"assistant/chunk","seq":50,"time":1783860681107,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} -{"type":"assistant/chunk","seq":51,"time":1783860681108,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" Let"}}} -{"type":"assistant/chunk","seq":52,"time":1783860681138,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} -{"type":"assistant/chunk","seq":53,"time":1783860681138,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" do"}}} -{"type":"assistant/chunk","seq":54,"time":1783860681251,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" exactly"}}} -{"type":"assistant/chunk","seq":55,"time":1783860681251,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" that"}}} -{"type":"assistant/chunk","seq":56,"time":1783860681251,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} -{"type":"assistant/chunk","seq":57,"time":1783860681251,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":58,"time":1783860681252,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":""}}} -{"type":"assistant/chunk","seq":59,"time":1783860681252,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"{"}}} -{"type":"assistant/chunk","seq":60,"time":1783860681252,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":61,"time":1783860681252,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"command"}}} -{"type":"assistant/chunk","seq":62,"time":1783860681280,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":63,"time":1783860681281,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":": "}}} -{"type":"assistant/chunk","seq":64,"time":1783860681281,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":65,"time":1783860681324,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"printf"}}} -{"type":"assistant/chunk","seq":66,"time":1783860681324,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":" '"}}} -{"type":"assistant/chunk","seq":67,"time":1783860681324,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"es"}}} -{"type":"assistant/chunk","seq":68,"time":1783860681324,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"cal"}}} -{"type":"assistant/chunk","seq":69,"time":1783860681324,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"ated"}}} -{"type":"assistant/chunk","seq":70,"time":1783860681324,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"\\\\n"}}} -{"type":"assistant/chunk","seq":71,"time":1783860681342,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"'"}}} -{"type":"assistant/chunk","seq":72,"time":1783860681343,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":" >"}}} -{"type":"assistant/chunk","seq":73,"time":1783860681343,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":" /"}}} -{"type":"assistant/chunk","seq":74,"time":1783860681343,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"tmp"}}} -{"type":"assistant/chunk","seq":75,"time":1783860681368,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"/d"}}} -{"type":"assistant/chunk","seq":76,"time":1783860681371,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"sh"}}} -{"type":"assistant/chunk","seq":77,"time":1783860681371,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"-es"}}} -{"type":"assistant/chunk","seq":78,"time":1783860681371,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"cal"}}} -{"type":"assistant/chunk","seq":79,"time":1783860681371,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"ated"}}} -{"type":"assistant/chunk","seq":80,"time":1783860681400,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":".txt"}}} -{"type":"assistant/chunk","seq":81,"time":1783860681400,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":" &&"}}} -{"type":"assistant/chunk","seq":82,"time":1783860681400,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":" cat"}}} -{"type":"assistant/chunk","seq":83,"time":1783860681400,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":" /"}}} -{"type":"assistant/chunk","seq":84,"time":1783860681401,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"tmp"}}} -{"type":"assistant/chunk","seq":85,"time":1783860681402,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"/d"}}} -{"type":"assistant/chunk","seq":86,"time":1783860681432,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"sh"}}} -{"type":"assistant/chunk","seq":87,"time":1783860681432,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"-es"}}} -{"type":"assistant/chunk","seq":88,"time":1783860681432,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"cal"}}} -{"type":"assistant/chunk","seq":89,"time":1783860681432,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"ated"}}} -{"type":"assistant/chunk","seq":90,"time":1783860681432,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":".txt"}}} -{"type":"assistant/chunk","seq":91,"time":1783860681432,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":" &&"}}} -{"type":"assistant/chunk","seq":92,"time":1783860681456,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":" rm"}}} -{"type":"assistant/chunk","seq":93,"time":1783860681456,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":" /"}}} -{"type":"assistant/chunk","seq":94,"time":1783860681456,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"tmp"}}} -{"type":"assistant/chunk","seq":95,"time":1783860681456,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"/d"}}} -{"type":"assistant/chunk","seq":96,"time":1783860681456,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"sh"}}} -{"type":"assistant/chunk","seq":97,"time":1783860681456,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"-es"}}} -{"type":"assistant/chunk","seq":98,"time":1783860681485,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"cal"}}} -{"type":"assistant/chunk","seq":99,"time":1783860681519,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"ated"}}} -{"type":"assistant/chunk","seq":100,"time":1783860681521,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":".txt"}}} -{"type":"assistant/chunk","seq":101,"time":1783860681521,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":102,"time":1783860681521,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":", "}}} -{"type":"assistant/chunk","seq":103,"time":1783860681521,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":104,"time":1783860681542,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"description"}}} -{"type":"assistant/chunk","seq":105,"time":1783860681545,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":106,"time":1783860681545,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":": "}}} -{"type":"assistant/chunk","seq":107,"time":1783860681573,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":108,"time":1783860681573,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"Write"}}} -{"type":"assistant/chunk","seq":109,"time":1783860681574,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":" to"}}} -{"type":"assistant/chunk","seq":110,"time":1783860681605,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":" /"}}} -{"type":"assistant/chunk","seq":111,"time":1783860681608,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"tmp"}}} -{"type":"assistant/chunk","seq":112,"time":1783860681630,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":" and"}}} -{"type":"assistant/chunk","seq":113,"time":1783860681632,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":" verify"}}} -{"type":"assistant/chunk","seq":114,"time":1783860681661,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":","}}} -{"type":"assistant/chunk","seq":115,"time":1783860681661,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":" then"}}} -{"type":"assistant/chunk","seq":116,"time":1783860681693,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":" clean"}}} -{"type":"assistant/chunk","seq":117,"time":1783860681693,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":" up"}}} -{"type":"assistant/chunk","seq":118,"time":1783860681693,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":119,"time":1783860681693,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":", "}}} -{"type":"assistant/chunk","seq":120,"time":1783860681725,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":121,"time":1783860681725,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"sand"}}} -{"type":"assistant/chunk","seq":122,"time":1783860681726,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"box"}}} -{"type":"assistant/chunk","seq":123,"time":1783860681726,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"_per"}}} -{"type":"assistant/chunk","seq":124,"time":1783860681751,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"missions"}}} -{"type":"assistant/chunk","seq":125,"time":1783860681753,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":126,"time":1783860681754,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":": "}}} -{"type":"assistant/chunk","seq":127,"time":1783860681754,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":128,"time":1783860681754,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"danger"}}} -{"type":"assistant/chunk","seq":129,"time":1783860681813,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"-full"}}} -{"type":"assistant/chunk","seq":130,"time":1783860681813,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"-access"}}} -{"type":"assistant/chunk","seq":131,"time":1783860681813,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":132,"time":1783860681813,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":", "}}} -{"type":"assistant/chunk","seq":133,"time":1783860681813,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":134,"time":1783860681815,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"just"}}} -{"type":"assistant/chunk","seq":135,"time":1783860681840,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"ification"}}} -{"type":"assistant/chunk","seq":136,"time":1783860681842,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":137,"time":1783860681842,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":": "}}} -{"type":"assistant/chunk","seq":138,"time":1783860681842,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":139,"time":1783860681842,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"the"}}} -{"type":"assistant/chunk","seq":140,"time":1783860681870,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":" user"}}} -{"type":"assistant/chunk","seq":141,"time":1783860681870,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":" asked"}}} -{"type":"assistant/chunk","seq":142,"time":1783860681870,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":" to"}}} -{"type":"assistant/chunk","seq":143,"time":1783860681870,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":" write"}}} -{"type":"assistant/chunk","seq":144,"time":1783860681870,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":" a"}}} -{"type":"assistant/chunk","seq":145,"time":1783860681872,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":" file"}}} -{"type":"assistant/chunk","seq":146,"time":1783860681901,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":" outside"}}} -{"type":"assistant/chunk","seq":147,"time":1783860681903,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":" the"}}} -{"type":"assistant/chunk","seq":148,"time":1783860681967,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":" workspace"}}} -{"type":"assistant/chunk","seq":149,"time":1783860681967,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":150,"time":1783860681967,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"}"}}} -{"type":"assistant/chunk","seq":151,"time":1783860681967,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to run a specific command with `sandbox_permissions` set to `danger-full-access` and a specific justification. They explicitly said NOT to run it without sandbox_permissions first. Let me do exactly that."}}}} -{"type":"assistant/chunk","seq":152,"time":1783962246274,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","arguments":"{\"command\": \"printf 'escalated\\\\n' > /tmp/dsh-escalated.txt && cat /tmp/dsh-escalated.txt && rm /tmp/dsh-escalated.txt\", \"description\": \"Write to /tmp and verify, then clean up\", \"sandbox_permissions\": \"danger-full-access\", \"justification\": \"the user asked to write a file outside the workspace\"}"}}}} -{"type":"assistant/chunk","seq":153,"time":1783962246274,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":1509,"outputTokens":198,"cacheReadTokens":0,"reasoningTokens":48}}}} -{"type":"assistant/chunk","seq":154,"time":1783962246274,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":155,"time":1783962246274,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to run a specific command with `sandbox_permissions` set to `danger-full-access` and a specific justification. They explicitly said NOT to run it without sandbox_permissions first. Let me do exactly that."},{"type":"tool-call","id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","arguments":"{\"command\": \"printf 'escalated\\\\n' > /tmp/dsh-escalated.txt && cat /tmp/dsh-escalated.txt && rm /tmp/dsh-escalated.txt\", \"description\": \"Write to /tmp and verify, then clean up\", \"sandbox_permissions\": \"danger-full-access\", \"justification\": \"the user asked to write a file outside the workspace\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":1509,"outputTokens":198,"cacheReadTokens":0,"reasoningTokens":48}},"sourceEventSeqs":[8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132,133,134,135,136,137,138,139,140,141,142,143,144,145,146,147,148,149,150,151,152,153,154],"surfaceOp":"append"} -{"type":"tool/call","seq":156,"time":1783962246274,"data":{"turn":1,"step":1,"callId":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","arguments":"{\"command\": \"printf 'escalated\\\\n' > /tmp/dsh-escalated.txt && cat /tmp/dsh-escalated.txt && rm /tmp/dsh-escalated.txt\", \"description\": \"Write to /tmp and verify, then clean up\", \"sandbox_permissions\": \"danger-full-access\", \"justification\": \"the user asked to write a file outside the workspace\"}"}} -{"type":"approval/asked","seq":157,"time":1783962246275,"data":{"id":"e5cc84ef-8704-4cd9-8e47-897a0a51beb6","toolName":"bash","callId":"call_00_WB1vnPomi8yr6MlcFKTj7912","reason":"escalate sandbox to danger-full-access: the user asked to write a file outside the workspace"}} -{"type":"approval/decided","seq":158,"time":1783962246275,"data":{"id":"e5cc84ef-8704-4cd9-8e47-897a0a51beb6","outcome":"rejected"}} -{"type":"tool/result","seq":159,"time":1783962246275,"data":{"turn":1,"step":1,"callId":"call_00_WB1vnPomi8yr6MlcFKTj7912","content":[{"type":"text","text":"Error: the user rejected escalating this command to \"danger-full-access\""}],"isError":true},"sourceEventSeqs":[156],"surfaceOp":"append"} -{"type":"step/end","seq":160,"time":1783962246276,"data":{"turn":1,"step":1}} -{"type":"step/start","seq":161,"time":1783962246276,"data":{"turn":1,"step":2}} -{"type":"assistant/chunk","seq":162,"time":1783860683140,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"assistant/chunk","seq":163,"time":1783860683141,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} -{"type":"assistant/chunk","seq":164,"time":1783860683141,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} -{"type":"assistant/chunk","seq":165,"time":1783860683170,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" rejected"}}} -{"type":"assistant/chunk","seq":166,"time":1783860683170,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} -{"type":"assistant/chunk","seq":167,"time":1783860683170,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" escalation"}}} -{"type":"assistant/chunk","seq":168,"time":1783860683205,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} -{"type":"assistant/chunk","seq":169,"time":1783860683208,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" As"}}} -{"type":"assistant/chunk","seq":170,"time":1783860683208,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" instructed"}}} -{"type":"assistant/chunk","seq":171,"time":1783860683208,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":","}}} -{"type":"assistant/chunk","seq":172,"time":1783860683208,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" I"}}} -{"type":"assistant/chunk","seq":173,"time":1783860683230,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" will"}}} -{"type":"assistant/chunk","seq":174,"time":1783860683232,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" not"}}} -{"type":"assistant/chunk","seq":175,"time":1783860683233,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" ret"}}} -{"type":"assistant/chunk","seq":176,"time":1783860683260,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"ry"}}} -{"type":"assistant/chunk","seq":177,"time":1783860683260,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} -{"type":"assistant/chunk","seq":178,"time":1783860683260,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" will"}}} -{"type":"assistant/chunk","seq":179,"time":1783860683260,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" not"}}} -{"type":"assistant/chunk","seq":180,"time":1783860683260,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" work"}}} -{"type":"assistant/chunk","seq":181,"time":1783860683294,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" around"}}} -{"type":"assistant/chunk","seq":182,"time":1783860683296,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" it"}}} -{"type":"assistant/chunk","seq":183,"time":1783860683296,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" —"}}} -{"type":"assistant/chunk","seq":184,"time":1783860683317,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" just"}}} -{"type":"assistant/chunk","seq":185,"time":1783860683317,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" explain"}}} -{"type":"assistant/chunk","seq":186,"time":1783860683317,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" in"}}} -{"type":"assistant/chunk","seq":187,"time":1783860683318,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" one"}}} -{"type":"assistant/chunk","seq":188,"time":1783860683318,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" short"}}} -{"type":"assistant/chunk","seq":189,"time":1783860683318,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" sentence"}}} -{"type":"assistant/chunk","seq":190,"time":1783860683347,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} -{"type":"assistant/chunk","seq":191,"time":1783860683347,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" stop"}}} -{"type":"assistant/chunk","seq":192,"time":1783860683349,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} -{"type":"assistant/chunk","seq":193,"time":1783860683349,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} -{"type":"assistant/chunk","seq":194,"time":1783860683375,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"The"}}} -{"type":"assistant/chunk","seq":195,"time":1783860683376,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" user"}}} -{"type":"assistant/chunk","seq":196,"time":1783860683409,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" rejected"}}} -{"type":"assistant/chunk","seq":197,"time":1783860683410,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" the"}}} -{"type":"assistant/chunk","seq":198,"time":1783860683410,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" permission"}}} -{"type":"assistant/chunk","seq":199,"time":1783860683435,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" escalation"}}} -{"type":"assistant/chunk","seq":200,"time":1783860683437,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":","}}} -{"type":"assistant/chunk","seq":201,"time":1783860683437,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" so"}}} -{"type":"assistant/chunk","seq":202,"time":1783860683462,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" this"}}} -{"type":"assistant/chunk","seq":203,"time":1783860683464,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" command"}}} -{"type":"assistant/chunk","seq":204,"time":1783860683464,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" cannot"}}} -{"type":"assistant/chunk","seq":205,"time":1783860683506,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" be"}}} -{"type":"assistant/chunk","seq":206,"time":1783860683506,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" run"}}} -{"type":"assistant/chunk","seq":207,"time":1783860683506,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"."}}} -{"type":"assistant/chunk","seq":208,"time":1783860683506,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user rejected the escalation. As instructed, I will not retry and will not work around it — just explain in one short sentence and stop."}}}} -{"type":"assistant/chunk","seq":209,"time":1783962246279,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"The user rejected the permission escalation, so this command cannot be run."}}}} -{"type":"assistant/chunk","seq":210,"time":1783962246279,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":69,"outputTokens":45,"cacheReadTokens":1664,"reasoningTokens":30}}}} -{"type":"assistant/chunk","seq":211,"time":1783962246279,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":212,"time":1783962246279,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"The user rejected the escalation. As instructed, I will not retry and will not work around it — just explain in one short sentence and stop."},{"type":"text","text":"The user rejected the permission escalation, so this command cannot be run."}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":69,"outputTokens":45,"cacheReadTokens":1664,"reasoningTokens":30}},"sourceEventSeqs":[162,163,164,165,166,167,168,169,170,171,172,173,174,175,176,177,178,179,180,181,182,183,184,185,186,187,188,189,190,191,192,193,194,195,196,197,198,199,200,201,202,203,204,205,206,207,208,209,210,211],"surfaceOp":"append"} -{"type":"step/end","seq":213,"time":1783962246279,"data":{"turn":1,"step":2}} -{"type":"turn/end","seq":214,"time":1783962246279,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"step/start","seq":5,"time":1783962246269,"data":{"turn":1,"step":1}} +{"type":"request/header","seq":6,"time":1783962246269,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"assistant/chunk","seq":7,"time":1783860680779,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":8,"time":1783860680782,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":9,"time":1783860680782,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} +{"type":"assistant/chunk","seq":10,"time":1783860680782,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" wants"}}} +{"type":"assistant/chunk","seq":11,"time":1783860680830,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":12,"time":1783860680831,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":13,"time":1783860680831,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" run"}}} +{"type":"assistant/chunk","seq":14,"time":1783860680859,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" a"}}} +{"type":"assistant/chunk","seq":15,"time":1783860680859,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" specific"}}} +{"type":"assistant/chunk","seq":16,"time":1783860680868,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" command"}}} +{"type":"assistant/chunk","seq":17,"time":1783860680871,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":18,"time":1783860680871,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" `"}}} +{"type":"assistant/chunk","seq":19,"time":1783860680872,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"sand"}}} +{"type":"assistant/chunk","seq":20,"time":1783860680872,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"box"}}} +{"type":"assistant/chunk","seq":21,"time":1783860680902,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"_per"}}} +{"type":"assistant/chunk","seq":22,"time":1783860680903,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"missions"}}} +{"type":"assistant/chunk","seq":23,"time":1783860680903,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"`"}}} +{"type":"assistant/chunk","seq":24,"time":1783860680903,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" set"}}} +{"type":"assistant/chunk","seq":25,"time":1783860680903,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":26,"time":1783860680903,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" `"}}} +{"type":"assistant/chunk","seq":27,"time":1783860680937,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"danger"}}} +{"type":"assistant/chunk","seq":28,"time":1783860680938,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"-full"}}} +{"type":"assistant/chunk","seq":29,"time":1783860680938,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"-access"}}} +{"type":"assistant/chunk","seq":30,"time":1783860680956,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"`"}}} +{"type":"assistant/chunk","seq":31,"time":1783860680958,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} +{"type":"assistant/chunk","seq":32,"time":1783860680958,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" a"}}} +{"type":"assistant/chunk","seq":33,"time":1783860680958,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" specific"}}} +{"type":"assistant/chunk","seq":34,"time":1783860680985,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" justification"}}} +{"type":"assistant/chunk","seq":35,"time":1783860680985,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":36,"time":1783860681022,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" They"}}} +{"type":"assistant/chunk","seq":37,"time":1783860681024,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" explicitly"}}} +{"type":"assistant/chunk","seq":38,"time":1783860681024,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" said"}}} +{"type":"assistant/chunk","seq":39,"time":1783860681024,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" NOT"}}} +{"type":"assistant/chunk","seq":40,"time":1783860681024,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":41,"time":1783860681043,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" run"}}} +{"type":"assistant/chunk","seq":42,"time":1783860681091,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" it"}}} +{"type":"assistant/chunk","seq":43,"time":1783860681091,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" without"}}} +{"type":"assistant/chunk","seq":44,"time":1783860681091,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" sand"}}} +{"type":"assistant/chunk","seq":45,"time":1783860681091,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"box"}}} +{"type":"assistant/chunk","seq":46,"time":1783860681091,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"_per"}}} +{"type":"assistant/chunk","seq":47,"time":1783860681091,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"missions"}}} +{"type":"assistant/chunk","seq":48,"time":1783860681107,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" first"}}} +{"type":"assistant/chunk","seq":49,"time":1783860681107,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":50,"time":1783860681108,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" Let"}}} +{"type":"assistant/chunk","seq":51,"time":1783860681138,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":52,"time":1783860681138,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" do"}}} +{"type":"assistant/chunk","seq":53,"time":1783860681251,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" exactly"}}} +{"type":"assistant/chunk","seq":54,"time":1783860681251,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" that"}}} +{"type":"assistant/chunk","seq":55,"time":1783860681251,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":56,"time":1783860681251,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":57,"time":1783860681252,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":""}}} +{"type":"assistant/chunk","seq":58,"time":1783860681252,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"{"}}} +{"type":"assistant/chunk","seq":59,"time":1783860681252,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":60,"time":1783860681252,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"command"}}} +{"type":"assistant/chunk","seq":61,"time":1783860681280,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":62,"time":1783860681281,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":": "}}} +{"type":"assistant/chunk","seq":63,"time":1783860681281,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":64,"time":1783860681324,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"printf"}}} +{"type":"assistant/chunk","seq":65,"time":1783860681324,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":" '"}}} +{"type":"assistant/chunk","seq":66,"time":1783860681324,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"es"}}} +{"type":"assistant/chunk","seq":67,"time":1783860681324,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"cal"}}} +{"type":"assistant/chunk","seq":68,"time":1783860681324,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"ated"}}} +{"type":"assistant/chunk","seq":69,"time":1783860681324,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"\\\\n"}}} +{"type":"assistant/chunk","seq":70,"time":1783860681342,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"'"}}} +{"type":"assistant/chunk","seq":71,"time":1783860681343,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":" >"}}} +{"type":"assistant/chunk","seq":72,"time":1783860681343,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":" /"}}} +{"type":"assistant/chunk","seq":73,"time":1783860681343,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"tmp"}}} +{"type":"assistant/chunk","seq":74,"time":1783860681368,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"/d"}}} +{"type":"assistant/chunk","seq":75,"time":1783860681371,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"sh"}}} +{"type":"assistant/chunk","seq":76,"time":1783860681371,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"-es"}}} +{"type":"assistant/chunk","seq":77,"time":1783860681371,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"cal"}}} +{"type":"assistant/chunk","seq":78,"time":1783860681371,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"ated"}}} +{"type":"assistant/chunk","seq":79,"time":1783860681400,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":".txt"}}} +{"type":"assistant/chunk","seq":80,"time":1783860681400,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":" &&"}}} +{"type":"assistant/chunk","seq":81,"time":1783860681400,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":" cat"}}} +{"type":"assistant/chunk","seq":82,"time":1783860681400,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":" /"}}} +{"type":"assistant/chunk","seq":83,"time":1783860681401,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"tmp"}}} +{"type":"assistant/chunk","seq":84,"time":1783860681402,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"/d"}}} +{"type":"assistant/chunk","seq":85,"time":1783860681432,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"sh"}}} +{"type":"assistant/chunk","seq":86,"time":1783860681432,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"-es"}}} +{"type":"assistant/chunk","seq":87,"time":1783860681432,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"cal"}}} +{"type":"assistant/chunk","seq":88,"time":1783860681432,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"ated"}}} +{"type":"assistant/chunk","seq":89,"time":1783860681432,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":".txt"}}} +{"type":"assistant/chunk","seq":90,"time":1783860681432,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":" &&"}}} +{"type":"assistant/chunk","seq":91,"time":1783860681456,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":" rm"}}} +{"type":"assistant/chunk","seq":92,"time":1783860681456,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":" /"}}} +{"type":"assistant/chunk","seq":93,"time":1783860681456,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"tmp"}}} +{"type":"assistant/chunk","seq":94,"time":1783860681456,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"/d"}}} +{"type":"assistant/chunk","seq":95,"time":1783860681456,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"sh"}}} +{"type":"assistant/chunk","seq":96,"time":1783860681456,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"-es"}}} +{"type":"assistant/chunk","seq":97,"time":1783860681485,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"cal"}}} +{"type":"assistant/chunk","seq":98,"time":1783860681519,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"ated"}}} +{"type":"assistant/chunk","seq":99,"time":1783860681521,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":".txt"}}} +{"type":"assistant/chunk","seq":100,"time":1783860681521,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":101,"time":1783860681521,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":", "}}} +{"type":"assistant/chunk","seq":102,"time":1783860681521,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":103,"time":1783860681542,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"description"}}} +{"type":"assistant/chunk","seq":104,"time":1783860681545,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":105,"time":1783860681545,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":": "}}} +{"type":"assistant/chunk","seq":106,"time":1783860681573,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":107,"time":1783860681573,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"Write"}}} +{"type":"assistant/chunk","seq":108,"time":1783860681574,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":" to"}}} +{"type":"assistant/chunk","seq":109,"time":1783860681605,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":" /"}}} +{"type":"assistant/chunk","seq":110,"time":1783860681608,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"tmp"}}} +{"type":"assistant/chunk","seq":111,"time":1783860681630,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":" and"}}} +{"type":"assistant/chunk","seq":112,"time":1783860681632,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":" verify"}}} +{"type":"assistant/chunk","seq":113,"time":1783860681661,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":","}}} +{"type":"assistant/chunk","seq":114,"time":1783860681661,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":" then"}}} +{"type":"assistant/chunk","seq":115,"time":1783860681693,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":" clean"}}} +{"type":"assistant/chunk","seq":116,"time":1783860681693,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":" up"}}} +{"type":"assistant/chunk","seq":117,"time":1783860681693,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":118,"time":1783860681693,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":", "}}} +{"type":"assistant/chunk","seq":119,"time":1783860681725,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":120,"time":1783860681725,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"sand"}}} +{"type":"assistant/chunk","seq":121,"time":1783860681726,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"box"}}} +{"type":"assistant/chunk","seq":122,"time":1783860681726,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"_per"}}} +{"type":"assistant/chunk","seq":123,"time":1783860681751,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"missions"}}} +{"type":"assistant/chunk","seq":124,"time":1783860681753,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":125,"time":1783860681754,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":": "}}} +{"type":"assistant/chunk","seq":126,"time":1783860681754,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":127,"time":1783860681754,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"danger"}}} +{"type":"assistant/chunk","seq":128,"time":1783860681813,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"-full"}}} +{"type":"assistant/chunk","seq":129,"time":1783860681813,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"-access"}}} +{"type":"assistant/chunk","seq":130,"time":1783860681813,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":131,"time":1783860681813,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":", "}}} +{"type":"assistant/chunk","seq":132,"time":1783860681813,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":133,"time":1783860681815,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"just"}}} +{"type":"assistant/chunk","seq":134,"time":1783860681840,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"ification"}}} +{"type":"assistant/chunk","seq":135,"time":1783860681842,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":136,"time":1783860681842,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":": "}}} +{"type":"assistant/chunk","seq":137,"time":1783860681842,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":138,"time":1783860681842,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"the"}}} +{"type":"assistant/chunk","seq":139,"time":1783860681870,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":" user"}}} +{"type":"assistant/chunk","seq":140,"time":1783860681870,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":" asked"}}} +{"type":"assistant/chunk","seq":141,"time":1783860681870,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":" to"}}} +{"type":"assistant/chunk","seq":142,"time":1783860681870,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":" write"}}} +{"type":"assistant/chunk","seq":143,"time":1783860681870,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":" a"}}} +{"type":"assistant/chunk","seq":144,"time":1783860681872,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":" file"}}} +{"type":"assistant/chunk","seq":145,"time":1783860681901,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":" outside"}}} +{"type":"assistant/chunk","seq":146,"time":1783860681903,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":" the"}}} +{"type":"assistant/chunk","seq":147,"time":1783860681967,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":" workspace"}}} +{"type":"assistant/chunk","seq":148,"time":1783860681967,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":149,"time":1783860681967,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"}"}}} +{"type":"assistant/chunk","seq":150,"time":1783860681967,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to run a specific command with `sandbox_permissions` set to `danger-full-access` and a specific justification. They explicitly said NOT to run it without sandbox_permissions first. Let me do exactly that."}}}} +{"type":"assistant/chunk","seq":151,"time":1783962246274,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","arguments":"{\"command\": \"printf 'escalated\\\\n' > /tmp/dsh-escalated.txt && cat /tmp/dsh-escalated.txt && rm /tmp/dsh-escalated.txt\", \"description\": \"Write to /tmp and verify, then clean up\", \"sandbox_permissions\": \"danger-full-access\", \"justification\": \"the user asked to write a file outside the workspace\"}"}}}} +{"type":"assistant/chunk","seq":152,"time":1783962246274,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":1509,"outputTokens":198,"cacheReadTokens":0,"reasoningTokens":48}}}} +{"type":"assistant/chunk","seq":153,"time":1783962246274,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":154,"time":1783962246274,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to run a specific command with `sandbox_permissions` set to `danger-full-access` and a specific justification. They explicitly said NOT to run it without sandbox_permissions first. Let me do exactly that."},{"type":"tool-call","id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","arguments":"{\"command\": \"printf 'escalated\\\\n' > /tmp/dsh-escalated.txt && cat /tmp/dsh-escalated.txt && rm /tmp/dsh-escalated.txt\", \"description\": \"Write to /tmp and verify, then clean up\", \"sandbox_permissions\": \"danger-full-access\", \"justification\": \"the user asked to write a file outside the workspace\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":1509,"outputTokens":198,"cacheReadTokens":0,"reasoningTokens":48}},"sourceEventSeqs":[7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132,133,134,135,136,137,138,139,140,141,142,143,144,145,146,147,148,149,150,151,152,153],"surfaceOp":"append"} +{"type":"tool/call","seq":155,"time":1783962246274,"data":{"turn":1,"step":1,"callId":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","arguments":"{\"command\": \"printf 'escalated\\\\n' > /tmp/dsh-escalated.txt && cat /tmp/dsh-escalated.txt && rm /tmp/dsh-escalated.txt\", \"description\": \"Write to /tmp and verify, then clean up\", \"sandbox_permissions\": \"danger-full-access\", \"justification\": \"the user asked to write a file outside the workspace\"}"}} +{"type":"approval/asked","seq":156,"time":1783962246275,"data":{"id":"6aac7131-b376-43bc-85c1-266c54443987","toolName":"bash","callId":"call_00_WB1vnPomi8yr6MlcFKTj7912","reason":"escalate sandbox to danger-full-access: the user asked to write a file outside the workspace"}} +{"type":"approval/decided","seq":157,"time":1783962246275,"data":{"id":"6aac7131-b376-43bc-85c1-266c54443987","outcome":"rejected"}} +{"type":"tool/result","seq":158,"time":1783962246275,"data":{"turn":1,"step":1,"callId":"call_00_WB1vnPomi8yr6MlcFKTj7912","content":[{"type":"text","text":"Error: the user rejected escalating this command to \"danger-full-access\""}],"isError":true},"sourceEventSeqs":[155],"surfaceOp":"append"} +{"type":"step/end","seq":159,"time":1783962246276,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":160,"time":1783962246276,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":161,"time":1783860683140,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":162,"time":1783860683141,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":163,"time":1783860683141,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} +{"type":"assistant/chunk","seq":164,"time":1783860683170,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" rejected"}}} +{"type":"assistant/chunk","seq":165,"time":1783860683170,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":166,"time":1783860683170,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" escalation"}}} +{"type":"assistant/chunk","seq":167,"time":1783860683205,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":168,"time":1783860683208,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" As"}}} +{"type":"assistant/chunk","seq":169,"time":1783860683208,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" instructed"}}} +{"type":"assistant/chunk","seq":170,"time":1783860683208,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":","}}} +{"type":"assistant/chunk","seq":171,"time":1783860683208,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" I"}}} +{"type":"assistant/chunk","seq":172,"time":1783860683230,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" will"}}} +{"type":"assistant/chunk","seq":173,"time":1783860683232,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" not"}}} +{"type":"assistant/chunk","seq":174,"time":1783860683233,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" ret"}}} +{"type":"assistant/chunk","seq":175,"time":1783860683260,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"ry"}}} +{"type":"assistant/chunk","seq":176,"time":1783860683260,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} +{"type":"assistant/chunk","seq":177,"time":1783860683260,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" will"}}} +{"type":"assistant/chunk","seq":178,"time":1783860683260,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" not"}}} +{"type":"assistant/chunk","seq":179,"time":1783860683260,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" work"}}} +{"type":"assistant/chunk","seq":180,"time":1783860683294,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" around"}}} +{"type":"assistant/chunk","seq":181,"time":1783860683296,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" it"}}} +{"type":"assistant/chunk","seq":182,"time":1783860683296,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" —"}}} +{"type":"assistant/chunk","seq":183,"time":1783860683317,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" just"}}} +{"type":"assistant/chunk","seq":184,"time":1783860683317,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" explain"}}} +{"type":"assistant/chunk","seq":185,"time":1783860683317,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" in"}}} +{"type":"assistant/chunk","seq":186,"time":1783860683318,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" one"}}} +{"type":"assistant/chunk","seq":187,"time":1783860683318,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" short"}}} +{"type":"assistant/chunk","seq":188,"time":1783860683318,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" sentence"}}} +{"type":"assistant/chunk","seq":189,"time":1783860683347,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} +{"type":"assistant/chunk","seq":190,"time":1783860683347,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" stop"}}} +{"type":"assistant/chunk","seq":191,"time":1783860683349,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":192,"time":1783860683349,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"assistant/chunk","seq":193,"time":1783860683375,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"The"}}} +{"type":"assistant/chunk","seq":194,"time":1783860683376,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" user"}}} +{"type":"assistant/chunk","seq":195,"time":1783860683409,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" rejected"}}} +{"type":"assistant/chunk","seq":196,"time":1783860683410,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" the"}}} +{"type":"assistant/chunk","seq":197,"time":1783860683410,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" permission"}}} +{"type":"assistant/chunk","seq":198,"time":1783860683435,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" escalation"}}} +{"type":"assistant/chunk","seq":199,"time":1783860683437,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":","}}} +{"type":"assistant/chunk","seq":200,"time":1783860683437,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" so"}}} +{"type":"assistant/chunk","seq":201,"time":1783860683462,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" this"}}} +{"type":"assistant/chunk","seq":202,"time":1783860683464,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" command"}}} +{"type":"assistant/chunk","seq":203,"time":1783860683464,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" cannot"}}} +{"type":"assistant/chunk","seq":204,"time":1783860683506,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" be"}}} +{"type":"assistant/chunk","seq":205,"time":1783860683506,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" run"}}} +{"type":"assistant/chunk","seq":206,"time":1783860683506,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"."}}} +{"type":"assistant/chunk","seq":207,"time":1783860683506,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user rejected the escalation. As instructed, I will not retry and will not work around it — just explain in one short sentence and stop."}}}} +{"type":"assistant/chunk","seq":208,"time":1783962246279,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"The user rejected the permission escalation, so this command cannot be run."}}}} +{"type":"assistant/chunk","seq":209,"time":1783962246279,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":69,"outputTokens":45,"cacheReadTokens":1664,"reasoningTokens":30}}}} +{"type":"assistant/chunk","seq":210,"time":1783962246279,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":211,"time":1783962246279,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"The user rejected the escalation. As instructed, I will not retry and will not work around it — just explain in one short sentence and stop."},{"type":"text","text":"The user rejected the permission escalation, so this command cannot be run."}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":69,"outputTokens":45,"cacheReadTokens":1664,"reasoningTokens":30}},"sourceEventSeqs":[161,162,163,164,165,166,167,168,169,170,171,172,173,174,175,176,177,178,179,180,181,182,183,184,185,186,187,188,189,190,191,192,193,194,195,196,197,198,199,200,201,202,203,204,205,206,207,208,209,210],"surfaceOp":"append"} +{"type":"step/end","seq":212,"time":1783962246279,"data":{"turn":1,"step":2}} +{"type":"turn/end","seq":213,"time":1783962246279,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/fs-escalation-approved/session.jsonl b/examples/acp-agent/tests/snapshots/fs-escalation-approved/session.jsonl index 424aeb1aa3..4ff73a5020 100644 --- a/examples/acp-agent/tests/snapshots/fs-escalation-approved/session.jsonl +++ b/examples/acp-agent/tests/snapshots/fs-escalation-approved/session.jsonl @@ -1,128 +1,127 @@ -{"type":"session","version":0,"id":"977a4820-f609-4b48-9039-adcdd921c5fe","createdAt":1784045702340,"cwd":"/var/folders/2g/b32ct0qn1d728l_v6tdkjytr0000gn/T/acp-snap-cwd-vmEGzd","delegationDepth":0} +{"type":"session","version":0,"id":"977a4820-f609-4b48-9039-adcdd921c5fe","createdAt":1784045702340,"cwd":"/var/folders/2g/b32ct0qn1d728l_v6tdkjytr0000gn/T/acp-snap-cwd-vmEGzd"} {"type":"turn/start","seq":0,"time":1784045702342,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"permission/preset","seq":1,"time":1784045702343,"data":{"preset":"workspace-write"}} {"type":"sandbox/mode","seq":2,"time":1784045702343,"data":{"mode":"workspace-write"}} {"type":"approval/policy","seq":3,"time":1784045702343,"data":{"policy":"ask"}} {"type":"user/message","seq":4,"time":1784045702343,"data":{"content":[{"type":"text","text":"Use the write tool (NOT bash) to create escalated.md in the current directory containing exactly the single line: escalated. An equivalent write was denied earlier, so make this one single write call with sandbox_permissions set to danger-full-access and the justification 'the user asked to escalate this write'. Do not call write without sandbox_permissions first. I will approve the permission prompt. After the result, reply with exactly the single word DONE."}],"source":{"kind":"user"}},"surfaceOp":"append"} -{"type":"session/title","seq":5,"time":1784045702343,"data":{"title":"Use the write tool (NOT","messageSeqs":[4],"source":{"kind":"fallback"}}} -{"type":"step/start","seq":6,"time":1784045702345,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":7,"time":1784045702345,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} -{"type":"assistant/chunk","seq":8,"time":1784045703046,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"assistant/chunk","seq":9,"time":1784045703046,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} -{"type":"assistant/chunk","seq":10,"time":1784045703162,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} -{"type":"assistant/chunk","seq":11,"time":1784045703172,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" wants"}}} -{"type":"assistant/chunk","seq":12,"time":1784045703172,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} -{"type":"assistant/chunk","seq":13,"time":1784045703173,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} -{"type":"assistant/chunk","seq":14,"time":1784045703173,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" create"}}} -{"type":"assistant/chunk","seq":15,"time":1784045703173,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" a"}}} -{"type":"assistant/chunk","seq":16,"time":1784045703173,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" file"}}} -{"type":"assistant/chunk","seq":17,"time":1784045703199,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" using"}}} -{"type":"assistant/chunk","seq":18,"time":1784045703225,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} -{"type":"assistant/chunk","seq":19,"time":1784045703251,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" write"}}} -{"type":"assistant/chunk","seq":20,"time":1784045703252,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" tool"}}} -{"type":"assistant/chunk","seq":21,"time":1784045703252,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} -{"type":"assistant/chunk","seq":22,"time":1784045703252,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" sand"}}} -{"type":"assistant/chunk","seq":23,"time":1784045703252,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"box"}}} -{"type":"assistant/chunk","seq":24,"time":1784045703252,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"_per"}}} -{"type":"assistant/chunk","seq":25,"time":1784045703277,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"missions"}}} -{"type":"assistant/chunk","seq":26,"time":1784045703278,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} -{"type":"assistant/chunk","seq":27,"time":1784045703278,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" Let"}}} -{"type":"assistant/chunk","seq":28,"time":1784045703278,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} -{"type":"assistant/chunk","seq":29,"time":1784045703278,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" do"}}} -{"type":"assistant/chunk","seq":30,"time":1784045703304,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" that"}}} -{"type":"assistant/chunk","seq":31,"time":1784045703304,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} -{"type":"assistant/chunk","seq":32,"time":1784045703356,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":33,"time":1784045703356,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","argumentsDelta":""}}} -{"type":"assistant/chunk","seq":34,"time":1784045703381,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","argumentsDelta":"{"}}} -{"type":"assistant/chunk","seq":35,"time":1784045703381,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":36,"time":1784045703381,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","argumentsDelta":"file"}}} -{"type":"assistant/chunk","seq":37,"time":1784045703405,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","argumentsDelta":"_path"}}} -{"type":"assistant/chunk","seq":38,"time":1784045703406,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":39,"time":1784045703406,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","argumentsDelta":": "}}} -{"type":"assistant/chunk","seq":40,"time":1784045703406,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":41,"time":1784045703431,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","argumentsDelta":"es"}}} -{"type":"assistant/chunk","seq":42,"time":1784045703432,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","argumentsDelta":"cal"}}} -{"type":"assistant/chunk","seq":43,"time":1784045703432,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","argumentsDelta":"ated"}}} -{"type":"assistant/chunk","seq":44,"time":1784045703432,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","argumentsDelta":".md"}}} -{"type":"assistant/chunk","seq":45,"time":1784045703432,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":46,"time":1784045703483,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","argumentsDelta":", "}}} -{"type":"assistant/chunk","seq":47,"time":1784045703483,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":48,"time":1784045703483,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","argumentsDelta":"content"}}} -{"type":"assistant/chunk","seq":49,"time":1784045703483,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":50,"time":1784045703483,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","argumentsDelta":": "}}} -{"type":"assistant/chunk","seq":51,"time":1784045703509,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":52,"time":1784045703509,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","argumentsDelta":"es"}}} -{"type":"assistant/chunk","seq":53,"time":1784045703509,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","argumentsDelta":"cal"}}} -{"type":"assistant/chunk","seq":54,"time":1784045703509,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","argumentsDelta":"ated"}}} -{"type":"assistant/chunk","seq":55,"time":1784045703509,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":56,"time":1784045703565,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","argumentsDelta":", "}}} -{"type":"assistant/chunk","seq":57,"time":1784045703566,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":58,"time":1784045703566,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","argumentsDelta":"sand"}}} -{"type":"assistant/chunk","seq":59,"time":1784045703566,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","argumentsDelta":"box"}}} -{"type":"assistant/chunk","seq":60,"time":1784045703566,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","argumentsDelta":"_per"}}} -{"type":"assistant/chunk","seq":61,"time":1784045703566,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","argumentsDelta":"missions"}}} -{"type":"assistant/chunk","seq":62,"time":1784045703566,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":63,"time":1784045703591,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","argumentsDelta":": "}}} -{"type":"assistant/chunk","seq":64,"time":1784045703591,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":65,"time":1784045703591,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","argumentsDelta":"danger"}}} -{"type":"assistant/chunk","seq":66,"time":1784045703591,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","argumentsDelta":"-full"}}} -{"type":"assistant/chunk","seq":67,"time":1784045703617,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","argumentsDelta":"-access"}}} -{"type":"assistant/chunk","seq":68,"time":1784045703618,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":69,"time":1784045703644,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","argumentsDelta":", "}}} -{"type":"assistant/chunk","seq":70,"time":1784045703645,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":71,"time":1784045703645,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","argumentsDelta":"just"}}} -{"type":"assistant/chunk","seq":72,"time":1784045703645,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","argumentsDelta":"ification"}}} -{"type":"assistant/chunk","seq":73,"time":1784045703669,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":74,"time":1784045703669,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","argumentsDelta":": "}}} -{"type":"assistant/chunk","seq":75,"time":1784045703669,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":76,"time":1784045703669,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","argumentsDelta":"the"}}} -{"type":"assistant/chunk","seq":77,"time":1784045703696,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","argumentsDelta":" user"}}} -{"type":"assistant/chunk","seq":78,"time":1784045703696,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","argumentsDelta":" asked"}}} -{"type":"assistant/chunk","seq":79,"time":1784045703696,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","argumentsDelta":" to"}}} -{"type":"assistant/chunk","seq":80,"time":1784045703696,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","argumentsDelta":" escalate"}}} -{"type":"assistant/chunk","seq":81,"time":1784045703724,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","argumentsDelta":" this"}}} -{"type":"assistant/chunk","seq":82,"time":1784045703724,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","argumentsDelta":" write"}}} -{"type":"assistant/chunk","seq":83,"time":1784045703724,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":84,"time":1784045703749,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","argumentsDelta":"}"}}} -{"type":"assistant/chunk","seq":85,"time":1784045703776,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to create a file using the write tool with sandbox_permissions. Let me do that."}}}} -{"type":"assistant/chunk","seq":86,"time":1784045703776,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","arguments":"{\"file_path\": \"escalated.md\", \"content\": \"escalated\", \"sandbox_permissions\": \"danger-full-access\", \"justification\": \"the user asked to escalate this write\"}"}}}} -{"type":"assistant/chunk","seq":87,"time":1784045703776,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3871,"outputTokens":132,"cacheReadTokens":0,"reasoningTokens":23}}}} -{"type":"assistant/chunk","seq":88,"time":1784045703776,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":89,"time":1784045703780,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to create a file using the write tool with sandbox_permissions. Let me do that."},{"type":"tool-call","id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","arguments":"{\"file_path\": \"escalated.md\", \"content\": \"escalated\", \"sandbox_permissions\": \"danger-full-access\", \"justification\": \"the user asked to escalate this write\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":3871,"outputTokens":132,"cacheReadTokens":0,"reasoningTokens":23}},"sourceEventSeqs":[8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88],"surfaceOp":"append"} -{"type":"tool/call","seq":90,"time":1784045703780,"data":{"turn":1,"step":1,"callId":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","arguments":"{\"file_path\": \"escalated.md\", \"content\": \"escalated\", \"sandbox_permissions\": \"danger-full-access\", \"justification\": \"the user asked to escalate this write\"}"}} -{"type":"approval/asked","seq":91,"time":1784045703782,"data":{"id":"d7250dff-fc08-4163-9b51-f43f08d6f36f","toolName":"write","callId":"call_00_Fnymmavpr4klMDy4Fdej3227","reason":"escalate sandbox to danger-full-access: the user asked to escalate this write"}} -{"type":"approval/decided","seq":92,"time":1784045703786,"data":{"id":"d7250dff-fc08-4163-9b51-f43f08d6f36f","outcome":"allowed-once"}} -{"type":"tool/result","seq":93,"time":1784045703798,"data":{"turn":1,"step":1,"callId":"call_00_Fnymmavpr4klMDy4Fdej3227","content":[{"type":"text","text":"/var/folders/2g/b32ct0qn1d728l_v6tdkjytr0000gn/T/acp-snap-cwd-vmEGzd/escalated.md\nfile\n\nCreated file\n"}],"isError":false},"sourceEventSeqs":[90],"surfaceOp":"append"} -{"type":"step/end","seq":94,"time":1784045703798,"data":{"turn":1,"step":1}} -{"type":"step/start","seq":95,"time":1784045703799,"data":{"turn":1,"step":2}} -{"type":"assistant/chunk","seq":96,"time":1784045704512,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"assistant/chunk","seq":97,"time":1784045704512,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} -{"type":"assistant/chunk","seq":98,"time":1784045704620,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" file"}}} -{"type":"assistant/chunk","seq":99,"time":1784045704645,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" was"}}} -{"type":"assistant/chunk","seq":100,"time":1784045704646,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" created"}}} -{"type":"assistant/chunk","seq":101,"time":1784045704646,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" successfully"}}} -{"type":"assistant/chunk","seq":102,"time":1784045704646,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} -{"type":"assistant/chunk","seq":103,"time":1784045704646,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" The"}}} -{"type":"assistant/chunk","seq":104,"time":1784045704646,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} -{"type":"assistant/chunk","seq":105,"time":1784045704672,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" asked"}}} -{"type":"assistant/chunk","seq":106,"time":1784045704673,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} -{"type":"assistant/chunk","seq":107,"time":1784045704673,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} -{"type":"assistant/chunk","seq":108,"time":1784045704673,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} -{"type":"assistant/chunk","seq":109,"time":1784045704699,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} -{"type":"assistant/chunk","seq":110,"time":1784045704699,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" exactly"}}} -{"type":"assistant/chunk","seq":111,"time":1784045704699,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} -{"type":"assistant/chunk","seq":112,"time":1784045704726,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" single"}}} -{"type":"assistant/chunk","seq":113,"time":1784045704726,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" word"}}} -{"type":"assistant/chunk","seq":114,"time":1784045704726,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" D"}}} -{"type":"assistant/chunk","seq":115,"time":1784045704754,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"ONE"}}} -{"type":"assistant/chunk","seq":116,"time":1784045704754,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} -{"type":"assistant/chunk","seq":117,"time":1784045704754,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} -{"type":"assistant/chunk","seq":118,"time":1784045704754,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"D"}}} -{"type":"assistant/chunk","seq":119,"time":1784045704754,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"ONE"}}} -{"type":"assistant/chunk","seq":120,"time":1784045704755,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The file was created successfully. The user asked me to reply with exactly the single word DONE."}}}} -{"type":"assistant/chunk","seq":121,"time":1784045704755,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"DONE"}}}} -{"type":"assistant/chunk","seq":122,"time":1784045704755,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":107,"outputTokens":23,"cacheReadTokens":3968,"reasoningTokens":20}}}} -{"type":"assistant/chunk","seq":123,"time":1784045704755,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":124,"time":1784045704755,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"The file was created successfully. The user asked me to reply with exactly the single word DONE."},{"type":"text","text":"DONE"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":107,"outputTokens":23,"cacheReadTokens":3968,"reasoningTokens":20}},"sourceEventSeqs":[96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123],"surfaceOp":"append"} -{"type":"step/end","seq":125,"time":1784045704755,"data":{"turn":1,"step":2}} -{"type":"turn/end","seq":126,"time":1784045704756,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"step/start","seq":5,"time":1784045702345,"data":{"turn":1,"step":1}} +{"type":"request/header","seq":6,"time":1784045702345,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"assistant/chunk","seq":7,"time":1784045703046,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":8,"time":1784045703046,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":9,"time":1784045703162,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} +{"type":"assistant/chunk","seq":10,"time":1784045703172,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" wants"}}} +{"type":"assistant/chunk","seq":11,"time":1784045703172,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":12,"time":1784045703173,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":13,"time":1784045703173,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" create"}}} +{"type":"assistant/chunk","seq":14,"time":1784045703173,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" a"}}} +{"type":"assistant/chunk","seq":15,"time":1784045703173,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" file"}}} +{"type":"assistant/chunk","seq":16,"time":1784045703199,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" using"}}} +{"type":"assistant/chunk","seq":17,"time":1784045703225,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":18,"time":1784045703251,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" write"}}} +{"type":"assistant/chunk","seq":19,"time":1784045703252,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" tool"}}} +{"type":"assistant/chunk","seq":20,"time":1784045703252,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":21,"time":1784045703252,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" sand"}}} +{"type":"assistant/chunk","seq":22,"time":1784045703252,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"box"}}} +{"type":"assistant/chunk","seq":23,"time":1784045703252,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"_per"}}} +{"type":"assistant/chunk","seq":24,"time":1784045703277,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"missions"}}} +{"type":"assistant/chunk","seq":25,"time":1784045703278,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":26,"time":1784045703278,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" Let"}}} +{"type":"assistant/chunk","seq":27,"time":1784045703278,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":28,"time":1784045703278,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" do"}}} +{"type":"assistant/chunk","seq":29,"time":1784045703304,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" that"}}} +{"type":"assistant/chunk","seq":30,"time":1784045703304,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":31,"time":1784045703356,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":32,"time":1784045703356,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","argumentsDelta":""}}} +{"type":"assistant/chunk","seq":33,"time":1784045703381,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","argumentsDelta":"{"}}} +{"type":"assistant/chunk","seq":34,"time":1784045703381,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":35,"time":1784045703381,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","argumentsDelta":"file"}}} +{"type":"assistant/chunk","seq":36,"time":1784045703405,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","argumentsDelta":"_path"}}} +{"type":"assistant/chunk","seq":37,"time":1784045703406,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":38,"time":1784045703406,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","argumentsDelta":": "}}} +{"type":"assistant/chunk","seq":39,"time":1784045703406,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":40,"time":1784045703431,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","argumentsDelta":"es"}}} +{"type":"assistant/chunk","seq":41,"time":1784045703432,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","argumentsDelta":"cal"}}} +{"type":"assistant/chunk","seq":42,"time":1784045703432,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","argumentsDelta":"ated"}}} +{"type":"assistant/chunk","seq":43,"time":1784045703432,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","argumentsDelta":".md"}}} +{"type":"assistant/chunk","seq":44,"time":1784045703432,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":45,"time":1784045703483,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","argumentsDelta":", "}}} +{"type":"assistant/chunk","seq":46,"time":1784045703483,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":47,"time":1784045703483,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","argumentsDelta":"content"}}} +{"type":"assistant/chunk","seq":48,"time":1784045703483,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":49,"time":1784045703483,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","argumentsDelta":": "}}} +{"type":"assistant/chunk","seq":50,"time":1784045703509,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":51,"time":1784045703509,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","argumentsDelta":"es"}}} +{"type":"assistant/chunk","seq":52,"time":1784045703509,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","argumentsDelta":"cal"}}} +{"type":"assistant/chunk","seq":53,"time":1784045703509,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","argumentsDelta":"ated"}}} +{"type":"assistant/chunk","seq":54,"time":1784045703509,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":55,"time":1784045703565,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","argumentsDelta":", "}}} +{"type":"assistant/chunk","seq":56,"time":1784045703566,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":57,"time":1784045703566,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","argumentsDelta":"sand"}}} +{"type":"assistant/chunk","seq":58,"time":1784045703566,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","argumentsDelta":"box"}}} +{"type":"assistant/chunk","seq":59,"time":1784045703566,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","argumentsDelta":"_per"}}} +{"type":"assistant/chunk","seq":60,"time":1784045703566,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","argumentsDelta":"missions"}}} +{"type":"assistant/chunk","seq":61,"time":1784045703566,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":62,"time":1784045703591,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","argumentsDelta":": "}}} +{"type":"assistant/chunk","seq":63,"time":1784045703591,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":64,"time":1784045703591,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","argumentsDelta":"danger"}}} +{"type":"assistant/chunk","seq":65,"time":1784045703591,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","argumentsDelta":"-full"}}} +{"type":"assistant/chunk","seq":66,"time":1784045703617,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","argumentsDelta":"-access"}}} +{"type":"assistant/chunk","seq":67,"time":1784045703618,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":68,"time":1784045703644,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","argumentsDelta":", "}}} +{"type":"assistant/chunk","seq":69,"time":1784045703645,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":70,"time":1784045703645,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","argumentsDelta":"just"}}} +{"type":"assistant/chunk","seq":71,"time":1784045703645,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","argumentsDelta":"ification"}}} +{"type":"assistant/chunk","seq":72,"time":1784045703669,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":73,"time":1784045703669,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","argumentsDelta":": "}}} +{"type":"assistant/chunk","seq":74,"time":1784045703669,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":75,"time":1784045703669,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","argumentsDelta":"the"}}} +{"type":"assistant/chunk","seq":76,"time":1784045703696,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","argumentsDelta":" user"}}} +{"type":"assistant/chunk","seq":77,"time":1784045703696,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","argumentsDelta":" asked"}}} +{"type":"assistant/chunk","seq":78,"time":1784045703696,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","argumentsDelta":" to"}}} +{"type":"assistant/chunk","seq":79,"time":1784045703696,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","argumentsDelta":" escalate"}}} +{"type":"assistant/chunk","seq":80,"time":1784045703724,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","argumentsDelta":" this"}}} +{"type":"assistant/chunk","seq":81,"time":1784045703724,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","argumentsDelta":" write"}}} +{"type":"assistant/chunk","seq":82,"time":1784045703724,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":83,"time":1784045703749,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","argumentsDelta":"}"}}} +{"type":"assistant/chunk","seq":84,"time":1784045703776,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to create a file using the write tool with sandbox_permissions. Let me do that."}}}} +{"type":"assistant/chunk","seq":85,"time":1784045703776,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","arguments":"{\"file_path\": \"escalated.md\", \"content\": \"escalated\", \"sandbox_permissions\": \"danger-full-access\", \"justification\": \"the user asked to escalate this write\"}"}}}} +{"type":"assistant/chunk","seq":86,"time":1784045703776,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3871,"outputTokens":132,"cacheReadTokens":0,"reasoningTokens":23}}}} +{"type":"assistant/chunk","seq":87,"time":1784045703776,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":88,"time":1784045703780,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to create a file using the write tool with sandbox_permissions. Let me do that."},{"type":"tool-call","id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","arguments":"{\"file_path\": \"escalated.md\", \"content\": \"escalated\", \"sandbox_permissions\": \"danger-full-access\", \"justification\": \"the user asked to escalate this write\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":3871,"outputTokens":132,"cacheReadTokens":0,"reasoningTokens":23}},"sourceEventSeqs":[7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87],"surfaceOp":"append"} +{"type":"tool/call","seq":89,"time":1784045703780,"data":{"turn":1,"step":1,"callId":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","arguments":"{\"file_path\": \"escalated.md\", \"content\": \"escalated\", \"sandbox_permissions\": \"danger-full-access\", \"justification\": \"the user asked to escalate this write\"}"}} +{"type":"approval/asked","seq":90,"time":1784045703782,"data":{"id":"81db5df9-1287-4cc2-a4dc-24fd34b6e711","toolName":"write","callId":"call_00_Fnymmavpr4klMDy4Fdej3227","reason":"escalate sandbox to danger-full-access: the user asked to escalate this write"}} +{"type":"approval/decided","seq":91,"time":1784045703786,"data":{"id":"81db5df9-1287-4cc2-a4dc-24fd34b6e711","outcome":"allowed-once"}} +{"type":"tool/result","seq":92,"time":1784045703798,"data":{"turn":1,"step":1,"callId":"call_00_Fnymmavpr4klMDy4Fdej3227","content":[{"type":"text","text":"/var/folders/2g/b32ct0qn1d728l_v6tdkjytr0000gn/T/acp-snap-cwd-vmEGzd/escalated.md\nfile\n\nCreated file\n"}],"isError":false},"sourceEventSeqs":[89],"surfaceOp":"append"} +{"type":"step/end","seq":93,"time":1784045703798,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":94,"time":1784045703799,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":95,"time":1784045704512,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":96,"time":1784045704512,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":97,"time":1784045704620,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" file"}}} +{"type":"assistant/chunk","seq":98,"time":1784045704645,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" was"}}} +{"type":"assistant/chunk","seq":99,"time":1784045704646,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" created"}}} +{"type":"assistant/chunk","seq":100,"time":1784045704646,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" successfully"}}} +{"type":"assistant/chunk","seq":101,"time":1784045704646,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":102,"time":1784045704646,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" The"}}} +{"type":"assistant/chunk","seq":103,"time":1784045704646,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} +{"type":"assistant/chunk","seq":104,"time":1784045704672,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" asked"}}} +{"type":"assistant/chunk","seq":105,"time":1784045704673,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":106,"time":1784045704673,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":107,"time":1784045704673,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} +{"type":"assistant/chunk","seq":108,"time":1784045704699,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":109,"time":1784045704699,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" exactly"}}} +{"type":"assistant/chunk","seq":110,"time":1784045704699,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":111,"time":1784045704726,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" single"}}} +{"type":"assistant/chunk","seq":112,"time":1784045704726,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" word"}}} +{"type":"assistant/chunk","seq":113,"time":1784045704726,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" D"}}} +{"type":"assistant/chunk","seq":114,"time":1784045704754,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"ONE"}}} +{"type":"assistant/chunk","seq":115,"time":1784045704754,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":116,"time":1784045704754,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"assistant/chunk","seq":117,"time":1784045704754,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"D"}}} +{"type":"assistant/chunk","seq":118,"time":1784045704754,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"ONE"}}} +{"type":"assistant/chunk","seq":119,"time":1784045704755,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The file was created successfully. The user asked me to reply with exactly the single word DONE."}}}} +{"type":"assistant/chunk","seq":120,"time":1784045704755,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"DONE"}}}} +{"type":"assistant/chunk","seq":121,"time":1784045704755,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":107,"outputTokens":23,"cacheReadTokens":3968,"reasoningTokens":20}}}} +{"type":"assistant/chunk","seq":122,"time":1784045704755,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":123,"time":1784045704755,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"The file was created successfully. The user asked me to reply with exactly the single word DONE."},{"type":"text","text":"DONE"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":107,"outputTokens":23,"cacheReadTokens":3968,"reasoningTokens":20}},"sourceEventSeqs":[95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122],"surfaceOp":"append"} +{"type":"step/end","seq":124,"time":1784045704755,"data":{"turn":1,"step":2}} +{"type":"turn/end","seq":125,"time":1784045704756,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/hook-cc-pretool-ask/session.jsonl b/examples/acp-agent/tests/snapshots/hook-cc-pretool-ask/session.jsonl index 888f2f5c13..1a3b0f784a 100644 --- a/examples/acp-agent/tests/snapshots/hook-cc-pretool-ask/session.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-cc-pretool-ask/session.jsonl @@ -1,116 +1,115 @@ -{"type":"session","version":0,"id":"f688431c-01a8-4326-a5c5-1b5f0fd08483","createdAt":1783352171511,"cwd":"/tmp/acp-snap-cwd-iKVciS","delegationDepth":0} +{"type":"session","version":0,"id":"f688431c-01a8-4326-a5c5-1b5f0fd08483","createdAt":1783352171511,"cwd":"/tmp/acp-snap-cwd-iKVciS"} {"type":"turn/start","seq":0,"time":1783352171519,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1783352171520,"data":{"content":[{"type":"text","text":"Use the bash tool to run exactly: echo HELLO. Report the tool result you got back verbatim, then stop."}],"source":{"kind":"user"}},"surfaceOp":"append"} -{"type":"session/title","seq":2,"time":1783352171520,"data":{"title":"Use the bash tool to","messageSeqs":[1],"source":{"kind":"fallback"}}} -{"type":"step/start","seq":3,"time":1783352171527,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":4,"time":1783352171528,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} -{"type":"assistant/chunk","seq":5,"time":1783352171991,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"assistant/chunk","seq":6,"time":1783352171991,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} -{"type":"assistant/chunk","seq":7,"time":1783352172088,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} -{"type":"assistant/chunk","seq":8,"time":1783352172117,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" wants"}}} -{"type":"assistant/chunk","seq":9,"time":1783352172118,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} -{"type":"assistant/chunk","seq":10,"time":1783352172118,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} -{"type":"assistant/chunk","seq":11,"time":1783352172118,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" run"}}} -{"type":"assistant/chunk","seq":12,"time":1783352172145,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" a"}}} -{"type":"assistant/chunk","seq":13,"time":1783352172145,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" simple"}}} -{"type":"assistant/chunk","seq":14,"time":1783352172146,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" bash"}}} -{"type":"assistant/chunk","seq":15,"time":1783352172146,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" command"}}} -{"type":"assistant/chunk","seq":16,"time":1783352172175,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} -{"type":"assistant/chunk","seq":17,"time":1783352172175,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" report"}}} -{"type":"assistant/chunk","seq":18,"time":1783352172175,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} -{"type":"assistant/chunk","seq":19,"time":1783352172175,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" result"}}} -{"type":"assistant/chunk","seq":20,"time":1783352172203,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" verb"}}} -{"type":"assistant/chunk","seq":21,"time":1783352172203,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"atim"}}} -{"type":"assistant/chunk","seq":22,"time":1783352172203,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} -{"type":"assistant/chunk","seq":23,"time":1783352172289,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":24,"time":1783352172290,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6k0oGSliVHxGSgqBmMEO4311","name":"bash","argumentsDelta":""}}} -{"type":"assistant/chunk","seq":25,"time":1783352172290,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6k0oGSliVHxGSgqBmMEO4311","name":"bash","argumentsDelta":"{"}}} -{"type":"assistant/chunk","seq":26,"time":1783352172290,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6k0oGSliVHxGSgqBmMEO4311","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":27,"time":1783352172318,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6k0oGSliVHxGSgqBmMEO4311","name":"bash","argumentsDelta":"command"}}} -{"type":"assistant/chunk","seq":28,"time":1783352172319,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6k0oGSliVHxGSgqBmMEO4311","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":29,"time":1783352172319,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6k0oGSliVHxGSgqBmMEO4311","name":"bash","argumentsDelta":": "}}} -{"type":"assistant/chunk","seq":30,"time":1783352172319,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6k0oGSliVHxGSgqBmMEO4311","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":31,"time":1783352172348,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6k0oGSliVHxGSgqBmMEO4311","name":"bash","argumentsDelta":"echo"}}} -{"type":"assistant/chunk","seq":32,"time":1783352172348,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6k0oGSliVHxGSgqBmMEO4311","name":"bash","argumentsDelta":" HE"}}} -{"type":"assistant/chunk","seq":33,"time":1783352172348,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6k0oGSliVHxGSgqBmMEO4311","name":"bash","argumentsDelta":"LL"}}} -{"type":"assistant/chunk","seq":34,"time":1783352172348,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6k0oGSliVHxGSgqBmMEO4311","name":"bash","argumentsDelta":"O"}}} -{"type":"assistant/chunk","seq":35,"time":1783352172348,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6k0oGSliVHxGSgqBmMEO4311","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":36,"time":1783352172405,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6k0oGSliVHxGSgqBmMEO4311","name":"bash","argumentsDelta":", "}}} -{"type":"assistant/chunk","seq":37,"time":1783352172406,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6k0oGSliVHxGSgqBmMEO4311","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":38,"time":1783352172406,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6k0oGSliVHxGSgqBmMEO4311","name":"bash","argumentsDelta":"description"}}} -{"type":"assistant/chunk","seq":39,"time":1783352172406,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6k0oGSliVHxGSgqBmMEO4311","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":40,"time":1783352172406,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6k0oGSliVHxGSgqBmMEO4311","name":"bash","argumentsDelta":": "}}} -{"type":"assistant/chunk","seq":41,"time":1783352172434,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6k0oGSliVHxGSgqBmMEO4311","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":42,"time":1783352172434,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6k0oGSliVHxGSgqBmMEO4311","name":"bash","argumentsDelta":"E"}}} -{"type":"assistant/chunk","seq":43,"time":1783352172434,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6k0oGSliVHxGSgqBmMEO4311","name":"bash","argumentsDelta":"cho"}}} -{"type":"assistant/chunk","seq":44,"time":1783352172464,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6k0oGSliVHxGSgqBmMEO4311","name":"bash","argumentsDelta":" HE"}}} -{"type":"assistant/chunk","seq":45,"time":1783352172464,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6k0oGSliVHxGSgqBmMEO4311","name":"bash","argumentsDelta":"LL"}}} -{"type":"assistant/chunk","seq":46,"time":1783352172464,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6k0oGSliVHxGSgqBmMEO4311","name":"bash","argumentsDelta":"O"}}} -{"type":"assistant/chunk","seq":47,"time":1783352172464,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6k0oGSliVHxGSgqBmMEO4311","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":48,"time":1783352172496,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6k0oGSliVHxGSgqBmMEO4311","name":"bash","argumentsDelta":"}"}}} -{"type":"assistant/chunk","seq":49,"time":1783352172555,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to run a simple bash command and report the result verbatim."}}}} -{"type":"assistant/chunk","seq":50,"time":1783352172555,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_6k0oGSliVHxGSgqBmMEO4311","name":"bash","arguments":"{\"command\": \"echo HELLO\", \"description\": \"Echo HELLO\"}"}}}} -{"type":"assistant/chunk","seq":51,"time":1783352172555,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":2878,"outputTokens":83,"cacheReadTokens":0,"reasoningTokens":17}}}} -{"type":"assistant/chunk","seq":52,"time":1783352172555,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":53,"time":1783352172557,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to run a simple bash command and report the result verbatim."},{"type":"tool-call","id":"call_00_6k0oGSliVHxGSgqBmMEO4311","name":"bash","arguments":"{\"command\": \"echo HELLO\", \"description\": \"Echo HELLO\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":2878,"outputTokens":83,"cacheReadTokens":0,"reasoningTokens":17}},"sourceEventSeqs":[5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52],"surfaceOp":"append"} -{"type":"tool/call","seq":54,"time":1783352172557,"data":{"turn":1,"step":1,"callId":"call_00_6k0oGSliVHxGSgqBmMEO4311","name":"bash","arguments":"{\"command\": \"echo HELLO\", \"description\": \"Echo HELLO\"}"}} -{"type":"hook/invoked","seq":55,"time":1783352172558,"data":{"turn":1,"point":"PreToolUse","dialect":"claude","handlerId":"claude:PreToolUse:1","matcher":"bash"}} -{"type":"hook/result","seq":56,"time":1783352172573,"data":{"turn":1,"point":"PreToolUse","handlerId":"claude:PreToolUse:1","decision":"ask","exitCode":0,"durationMs":14.113374999999905}} -{"type":"approval/asked","seq":57,"time":1783962235813,"data":{"id":"97616288-1a5e-4110-a75d-7616a24adcc4","toolName":"bash","callId":"call_00_6k0oGSliVHxGSgqBmMEO4311","reason":"bash requires manual approval in this session"}} -{"type":"approval/decided","seq":58,"time":1783962235813,"data":{"id":"97616288-1a5e-4110-a75d-7616a24adcc4","outcome":"rejected"}} -{"type":"tool/result","seq":59,"time":1783962235814,"data":{"turn":1,"step":1,"callId":"call_00_6k0oGSliVHxGSgqBmMEO4311","content":[{"type":"text","text":"Error: the user rejected tool \"bash\""}],"isError":true},"sourceEventSeqs":[54],"surfaceOp":"append"} -{"type":"step/end","seq":60,"time":1783962235814,"data":{"turn":1,"step":1}} -{"type":"step/start","seq":61,"time":1783962235814,"data":{"turn":1,"step":2}} -{"type":"assistant/chunk","seq":62,"time":1783352173584,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"assistant/chunk","seq":63,"time":1783352173615,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} -{"type":"assistant/chunk","seq":64,"time":1783352173615,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" bash"}}} -{"type":"assistant/chunk","seq":65,"time":1783352173644,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" tool"}}} -{"type":"assistant/chunk","seq":66,"time":1783352173645,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" returned"}}} -{"type":"assistant/chunk","seq":67,"time":1783352173645,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" an"}}} -{"type":"assistant/chunk","seq":68,"time":1783352173645,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" error"}}} -{"type":"assistant/chunk","seq":69,"time":1783352173669,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" saying"}}} -{"type":"assistant/chunk","seq":70,"time":1783352173669,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" it"}}} -{"type":"assistant/chunk","seq":71,"time":1783352173670,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" requires"}}} -{"type":"assistant/chunk","seq":72,"time":1783352173670,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" manual"}}} -{"type":"assistant/chunk","seq":73,"time":1783352173698,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" approval"}}} -{"type":"assistant/chunk","seq":74,"time":1783352173699,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" in"}}} -{"type":"assistant/chunk","seq":75,"time":1783352173699,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" this"}}} -{"type":"assistant/chunk","seq":76,"time":1783352173699,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" session"}}} -{"type":"assistant/chunk","seq":77,"time":1783352173728,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} -{"type":"assistant/chunk","seq":78,"time":1783352173728,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" I"}}} -{"type":"assistant/chunk","seq":79,"time":1783352173729,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"'ll"}}} -{"type":"assistant/chunk","seq":80,"time":1783352173729,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" report"}}} -{"type":"assistant/chunk","seq":81,"time":1783352173755,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" this"}}} -{"type":"assistant/chunk","seq":82,"time":1783352173756,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" verb"}}} -{"type":"assistant/chunk","seq":83,"time":1783352173756,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"atim"}}} -{"type":"assistant/chunk","seq":84,"time":1783352173756,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} -{"type":"assistant/chunk","seq":85,"time":1783352173756,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} -{"type":"assistant/chunk","seq":86,"time":1783352173789,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"The"}}} -{"type":"assistant/chunk","seq":87,"time":1783352173823,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" tool"}}} -{"type":"assistant/chunk","seq":88,"time":1783352173854,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" result"}}} -{"type":"assistant/chunk","seq":89,"time":1783352173854,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" I"}}} -{"type":"assistant/chunk","seq":90,"time":1783352173879,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" got"}}} -{"type":"assistant/chunk","seq":91,"time":1783352173906,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" back"}}} -{"type":"assistant/chunk","seq":92,"time":1783352173906,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" verb"}}} -{"type":"assistant/chunk","seq":93,"time":1783352173907,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"atim"}}} -{"type":"assistant/chunk","seq":94,"time":1783352173907,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" is"}}} -{"type":"assistant/chunk","seq":95,"time":1783352173907,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":":\n\n"}}} -{"type":"assistant/chunk","seq":96,"time":1783352173932,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"```\n"}}} -{"type":"assistant/chunk","seq":97,"time":1783352173933,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"Error"}}} -{"type":"assistant/chunk","seq":98,"time":1783352173933,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":":"}}} -{"type":"assistant/chunk","seq":99,"time":1783352173933,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" bash"}}} -{"type":"assistant/chunk","seq":100,"time":1783352173933,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" requires"}}} -{"type":"assistant/chunk","seq":101,"time":1783352173933,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" manual"}}} -{"type":"assistant/chunk","seq":102,"time":1783352173963,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" approval"}}} -{"type":"assistant/chunk","seq":103,"time":1783352173963,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" in"}}} -{"type":"assistant/chunk","seq":104,"time":1783352173963,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" this"}}} -{"type":"assistant/chunk","seq":105,"time":1783352173963,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" session"}}} -{"type":"assistant/chunk","seq":106,"time":1783352173964,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"\n"}}} -{"type":"assistant/chunk","seq":107,"time":1783352173964,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"```"}}} -{"type":"assistant/chunk","seq":108,"time":1783352173964,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The bash tool returned an error saying it requires manual approval in this session. I'll report this verbatim."}}}} -{"type":"assistant/chunk","seq":109,"time":1783352173964,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"The tool result I got back verbatim is:\n\n```\nError: bash requires manual approval in this session\n```"}}}} -{"type":"assistant/chunk","seq":110,"time":1783962235816,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":166,"outputTokens":45,"cacheReadTokens":2816,"reasoningTokens":22}}}} -{"type":"assistant/chunk","seq":111,"time":1783962235816,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":112,"time":1783962235816,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"The bash tool returned an error saying it requires manual approval in this session. I'll report this verbatim."},{"type":"text","text":"The tool result I got back verbatim is:\n\n```\nError: bash requires manual approval in this session\n```"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":166,"outputTokens":45,"cacheReadTokens":2816,"reasoningTokens":22}},"sourceEventSeqs":[62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111],"surfaceOp":"append"} -{"type":"step/end","seq":113,"time":1783962235816,"data":{"turn":1,"step":2}} -{"type":"turn/end","seq":114,"time":1783962235816,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"step/start","seq":2,"time":1783352171527,"data":{"turn":1,"step":1}} +{"type":"request/header","seq":3,"time":1783352171528,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"assistant/chunk","seq":4,"time":1783352171991,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":5,"time":1783352171991,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":6,"time":1783352172088,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} +{"type":"assistant/chunk","seq":7,"time":1783352172117,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" wants"}}} +{"type":"assistant/chunk","seq":8,"time":1783352172118,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":9,"time":1783352172118,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":10,"time":1783352172118,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" run"}}} +{"type":"assistant/chunk","seq":11,"time":1783352172145,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" a"}}} +{"type":"assistant/chunk","seq":12,"time":1783352172145,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" simple"}}} +{"type":"assistant/chunk","seq":13,"time":1783352172146,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" bash"}}} +{"type":"assistant/chunk","seq":14,"time":1783352172146,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" command"}}} +{"type":"assistant/chunk","seq":15,"time":1783352172175,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} +{"type":"assistant/chunk","seq":16,"time":1783352172175,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" report"}}} +{"type":"assistant/chunk","seq":17,"time":1783352172175,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":18,"time":1783352172175,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" result"}}} +{"type":"assistant/chunk","seq":19,"time":1783352172203,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" verb"}}} +{"type":"assistant/chunk","seq":20,"time":1783352172203,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"atim"}}} +{"type":"assistant/chunk","seq":21,"time":1783352172203,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":22,"time":1783352172289,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":23,"time":1783352172290,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6k0oGSliVHxGSgqBmMEO4311","name":"bash","argumentsDelta":""}}} +{"type":"assistant/chunk","seq":24,"time":1783352172290,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6k0oGSliVHxGSgqBmMEO4311","name":"bash","argumentsDelta":"{"}}} +{"type":"assistant/chunk","seq":25,"time":1783352172290,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6k0oGSliVHxGSgqBmMEO4311","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":26,"time":1783352172318,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6k0oGSliVHxGSgqBmMEO4311","name":"bash","argumentsDelta":"command"}}} +{"type":"assistant/chunk","seq":27,"time":1783352172319,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6k0oGSliVHxGSgqBmMEO4311","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":28,"time":1783352172319,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6k0oGSliVHxGSgqBmMEO4311","name":"bash","argumentsDelta":": "}}} +{"type":"assistant/chunk","seq":29,"time":1783352172319,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6k0oGSliVHxGSgqBmMEO4311","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":30,"time":1783352172348,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6k0oGSliVHxGSgqBmMEO4311","name":"bash","argumentsDelta":"echo"}}} +{"type":"assistant/chunk","seq":31,"time":1783352172348,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6k0oGSliVHxGSgqBmMEO4311","name":"bash","argumentsDelta":" HE"}}} +{"type":"assistant/chunk","seq":32,"time":1783352172348,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6k0oGSliVHxGSgqBmMEO4311","name":"bash","argumentsDelta":"LL"}}} +{"type":"assistant/chunk","seq":33,"time":1783352172348,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6k0oGSliVHxGSgqBmMEO4311","name":"bash","argumentsDelta":"O"}}} +{"type":"assistant/chunk","seq":34,"time":1783352172348,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6k0oGSliVHxGSgqBmMEO4311","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":35,"time":1783352172405,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6k0oGSliVHxGSgqBmMEO4311","name":"bash","argumentsDelta":", "}}} +{"type":"assistant/chunk","seq":36,"time":1783352172406,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6k0oGSliVHxGSgqBmMEO4311","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":37,"time":1783352172406,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6k0oGSliVHxGSgqBmMEO4311","name":"bash","argumentsDelta":"description"}}} +{"type":"assistant/chunk","seq":38,"time":1783352172406,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6k0oGSliVHxGSgqBmMEO4311","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":39,"time":1783352172406,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6k0oGSliVHxGSgqBmMEO4311","name":"bash","argumentsDelta":": "}}} +{"type":"assistant/chunk","seq":40,"time":1783352172434,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6k0oGSliVHxGSgqBmMEO4311","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":41,"time":1783352172434,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6k0oGSliVHxGSgqBmMEO4311","name":"bash","argumentsDelta":"E"}}} +{"type":"assistant/chunk","seq":42,"time":1783352172434,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6k0oGSliVHxGSgqBmMEO4311","name":"bash","argumentsDelta":"cho"}}} +{"type":"assistant/chunk","seq":43,"time":1783352172464,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6k0oGSliVHxGSgqBmMEO4311","name":"bash","argumentsDelta":" HE"}}} +{"type":"assistant/chunk","seq":44,"time":1783352172464,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6k0oGSliVHxGSgqBmMEO4311","name":"bash","argumentsDelta":"LL"}}} +{"type":"assistant/chunk","seq":45,"time":1783352172464,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6k0oGSliVHxGSgqBmMEO4311","name":"bash","argumentsDelta":"O"}}} +{"type":"assistant/chunk","seq":46,"time":1783352172464,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6k0oGSliVHxGSgqBmMEO4311","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":47,"time":1783352172496,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6k0oGSliVHxGSgqBmMEO4311","name":"bash","argumentsDelta":"}"}}} +{"type":"assistant/chunk","seq":48,"time":1783352172555,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to run a simple bash command and report the result verbatim."}}}} +{"type":"assistant/chunk","seq":49,"time":1783352172555,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_6k0oGSliVHxGSgqBmMEO4311","name":"bash","arguments":"{\"command\": \"echo HELLO\", \"description\": \"Echo HELLO\"}"}}}} +{"type":"assistant/chunk","seq":50,"time":1783352172555,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":2878,"outputTokens":83,"cacheReadTokens":0,"reasoningTokens":17}}}} +{"type":"assistant/chunk","seq":51,"time":1783352172555,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":52,"time":1783352172557,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to run a simple bash command and report the result verbatim."},{"type":"tool-call","id":"call_00_6k0oGSliVHxGSgqBmMEO4311","name":"bash","arguments":"{\"command\": \"echo HELLO\", \"description\": \"Echo HELLO\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":2878,"outputTokens":83,"cacheReadTokens":0,"reasoningTokens":17}},"sourceEventSeqs":[4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51],"surfaceOp":"append"} +{"type":"tool/call","seq":53,"time":1783352172557,"data":{"turn":1,"step":1,"callId":"call_00_6k0oGSliVHxGSgqBmMEO4311","name":"bash","arguments":"{\"command\": \"echo HELLO\", \"description\": \"Echo HELLO\"}"}} +{"type":"hook/invoked","seq":54,"time":1783352172558,"data":{"turn":1,"point":"PreToolUse","dialect":"claude","handlerId":"claude:PreToolUse:1","matcher":"bash"}} +{"type":"hook/result","seq":55,"time":1783352172573,"data":{"turn":1,"point":"PreToolUse","handlerId":"claude:PreToolUse:1","decision":"ask","exitCode":0,"durationMs":14.113374999999905}} +{"type":"approval/asked","seq":56,"time":1783962235813,"data":{"id":"73c9d486-e039-43b8-8f73-20720d5bf2b6","toolName":"bash","callId":"call_00_6k0oGSliVHxGSgqBmMEO4311","reason":"bash requires manual approval in this session"}} +{"type":"approval/decided","seq":57,"time":1783962235813,"data":{"id":"73c9d486-e039-43b8-8f73-20720d5bf2b6","outcome":"rejected"}} +{"type":"tool/result","seq":58,"time":1783962235814,"data":{"turn":1,"step":1,"callId":"call_00_6k0oGSliVHxGSgqBmMEO4311","content":[{"type":"text","text":"Error: the user rejected tool \"bash\""}],"isError":true},"sourceEventSeqs":[53],"surfaceOp":"append"} +{"type":"step/end","seq":59,"time":1783962235814,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":60,"time":1783962235814,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":61,"time":1783352173584,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":62,"time":1783352173615,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":63,"time":1783352173615,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" bash"}}} +{"type":"assistant/chunk","seq":64,"time":1783352173644,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" tool"}}} +{"type":"assistant/chunk","seq":65,"time":1783352173645,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" returned"}}} +{"type":"assistant/chunk","seq":66,"time":1783352173645,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" an"}}} +{"type":"assistant/chunk","seq":67,"time":1783352173645,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" error"}}} +{"type":"assistant/chunk","seq":68,"time":1783352173669,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" saying"}}} +{"type":"assistant/chunk","seq":69,"time":1783352173669,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" it"}}} +{"type":"assistant/chunk","seq":70,"time":1783352173670,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" requires"}}} +{"type":"assistant/chunk","seq":71,"time":1783352173670,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" manual"}}} +{"type":"assistant/chunk","seq":72,"time":1783352173698,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" approval"}}} +{"type":"assistant/chunk","seq":73,"time":1783352173699,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" in"}}} +{"type":"assistant/chunk","seq":74,"time":1783352173699,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" this"}}} +{"type":"assistant/chunk","seq":75,"time":1783352173699,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" session"}}} +{"type":"assistant/chunk","seq":76,"time":1783352173728,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":77,"time":1783352173728,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" I"}}} +{"type":"assistant/chunk","seq":78,"time":1783352173729,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"'ll"}}} +{"type":"assistant/chunk","seq":79,"time":1783352173729,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" report"}}} +{"type":"assistant/chunk","seq":80,"time":1783352173755,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" this"}}} +{"type":"assistant/chunk","seq":81,"time":1783352173756,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" verb"}}} +{"type":"assistant/chunk","seq":82,"time":1783352173756,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"atim"}}} +{"type":"assistant/chunk","seq":83,"time":1783352173756,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":84,"time":1783352173756,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"assistant/chunk","seq":85,"time":1783352173789,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"The"}}} +{"type":"assistant/chunk","seq":86,"time":1783352173823,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" tool"}}} +{"type":"assistant/chunk","seq":87,"time":1783352173854,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" result"}}} +{"type":"assistant/chunk","seq":88,"time":1783352173854,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" I"}}} +{"type":"assistant/chunk","seq":89,"time":1783352173879,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" got"}}} +{"type":"assistant/chunk","seq":90,"time":1783352173906,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" back"}}} +{"type":"assistant/chunk","seq":91,"time":1783352173906,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" verb"}}} +{"type":"assistant/chunk","seq":92,"time":1783352173907,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"atim"}}} +{"type":"assistant/chunk","seq":93,"time":1783352173907,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" is"}}} +{"type":"assistant/chunk","seq":94,"time":1783352173907,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":":\n\n"}}} +{"type":"assistant/chunk","seq":95,"time":1783352173932,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"```\n"}}} +{"type":"assistant/chunk","seq":96,"time":1783352173933,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"Error"}}} +{"type":"assistant/chunk","seq":97,"time":1783352173933,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":":"}}} +{"type":"assistant/chunk","seq":98,"time":1783352173933,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" bash"}}} +{"type":"assistant/chunk","seq":99,"time":1783352173933,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" requires"}}} +{"type":"assistant/chunk","seq":100,"time":1783352173933,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" manual"}}} +{"type":"assistant/chunk","seq":101,"time":1783352173963,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" approval"}}} +{"type":"assistant/chunk","seq":102,"time":1783352173963,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" in"}}} +{"type":"assistant/chunk","seq":103,"time":1783352173963,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" this"}}} +{"type":"assistant/chunk","seq":104,"time":1783352173963,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" session"}}} +{"type":"assistant/chunk","seq":105,"time":1783352173964,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"\n"}}} +{"type":"assistant/chunk","seq":106,"time":1783352173964,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"```"}}} +{"type":"assistant/chunk","seq":107,"time":1783352173964,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The bash tool returned an error saying it requires manual approval in this session. I'll report this verbatim."}}}} +{"type":"assistant/chunk","seq":108,"time":1783352173964,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"The tool result I got back verbatim is:\n\n```\nError: bash requires manual approval in this session\n```"}}}} +{"type":"assistant/chunk","seq":109,"time":1783962235816,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":166,"outputTokens":45,"cacheReadTokens":2816,"reasoningTokens":22}}}} +{"type":"assistant/chunk","seq":110,"time":1783962235816,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":111,"time":1783962235816,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"The bash tool returned an error saying it requires manual approval in this session. I'll report this verbatim."},{"type":"text","text":"The tool result I got back verbatim is:\n\n```\nError: bash requires manual approval in this session\n```"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":166,"outputTokens":45,"cacheReadTokens":2816,"reasoningTokens":22}},"sourceEventSeqs":[61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110],"surfaceOp":"append"} +{"type":"step/end","seq":112,"time":1783962235816,"data":{"turn":1,"step":2}} +{"type":"turn/end","seq":113,"time":1783962235816,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/workspace-context/session.jsonl b/examples/acp-agent/tests/snapshots/workspace-context/session.jsonl index ddcba507fd..168e0afab4 100644 --- a/examples/acp-agent/tests/snapshots/workspace-context/session.jsonl +++ b/examples/acp-agent/tests/snapshots/workspace-context/session.jsonl @@ -1,25 +1,24 @@ -{"type":"session","version":0,"id":"{{sessionId}}","createdAt":0,"cwd":"{{cwd}}","delegationDepth":0} +{"type":"session","version":0,"id":"{{sessionId}}","createdAt":0,"cwd":"{{cwd}}"} {"type":"turn/start","seq":0,"time":1783778297065,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1783778297066,"data":{"content":[{"type":"text","text":"Read nested/task.txt with the read tool, then reply DONE."}],"source":{"kind":"user"}},"surfaceOp":"append"} -{"type":"session/title","seq":2,"time":1783778297066,"data":{"title":"Read nested/task.txt with the read","messageSeqs":[1],"source":{"kind":"fallback"}}} -{"type":"step/start","seq":3,"time":1783778297069,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":4,"time":1783778297070,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}","messagePrefix":[{"role":"user","content":[{"type":"text","text":"\nThe following workspace instructions may be relevant to your work. Use them as guidance when applicable. More specific instructions take precedence over broader ones. They do not override system, developer, or direct user instructions.\n\nInstructions from: AGENTS.md\n\nRoot snapshot instruction.\n\n"}]}]},"reason":"initial"}} -{"type":"assistant/chunk","seq":5,"time":1783778297070,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":6,"time":1783778297070,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"call_workspace_read","name":"read","argumentsDelta":"{\"file_path\":\"nested/task.txt\"}"}}} -{"type":"assistant/chunk","seq":7,"time":1783778297070,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_workspace_read","name":"read","arguments":"{\"file_path\":\"nested/task.txt\"}"}}}} -{"type":"assistant/chunk","seq":8,"time":1783778297070,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} -{"type":"assistant/chunk","seq":9,"time":1783778297070,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":10,"time":1783778297070,"data":{"turn":1,"step":1,"content":[{"type":"tool-call","id":"call_workspace_read","name":"read","arguments":"{\"file_path\":\"nested/task.txt\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[5,6,7,8,9],"surfaceOp":"append"} -{"type":"tool/call","seq":11,"time":1783778297070,"data":{"turn":1,"step":1,"callId":"call_workspace_read","name":"read","arguments":"{\"file_path\":\"nested/task.txt\"}"}} -{"type":"tool/result","seq":12,"time":1783778297072,"data":{"turn":1,"step":1,"callId":"call_workspace_read","content":[{"type":"text","text":"{{cwd}}/nested/task.txt\nfile\n\n1: snapshot task\n\n(End of file - total 1 lines)\n"}],"isError":false},"sourceEventSeqs":[11],"surfaceOp":"append"} -{"type":"context/message","seq":13,"time":1783778297072,"data":{"content":[{"type":"text","text":"\nAdditional instructions from: nested/AGENTS.md\n\nThese instructions apply to work under `nested`. Use them as guidance when relevant; more specific instructions take precedence. They do not override system, developer, or direct user instructions.\n\nNested snapshot instruction.\n\n"}],"source":{"kind":"plugin","plugin":"workspace-context"},"meta":{"kind":"workspace-instructions","version":1,"changes":[{"action":"set","scope":"nested","path":"nested/AGENTS.md","digest":"c446df9a85c7e73a3055f394a4822a19ac9ead5a"}]}},"surfaceOp":"append"} -{"type":"step/end","seq":14,"time":1783778297072,"data":{"turn":1,"step":1}} -{"type":"step/start","seq":15,"time":1783778297072,"data":{"turn":1,"step":2}} -{"type":"assistant/chunk","seq":16,"time":1783778297073,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} -{"type":"assistant/chunk","seq":17,"time":1783778297073,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":0,"text":"DONE"}}} -{"type":"assistant/chunk","seq":18,"time":1783778297073,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DONE"}}}} -{"type":"assistant/chunk","seq":19,"time":1783778297073,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":2}}}} -{"type":"assistant/chunk","seq":20,"time":1783778297073,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":21,"time":1783778297073,"data":{"turn":1,"step":2,"content":[{"type":"text","text":"DONE"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":10,"outputTokens":2}},"sourceEventSeqs":[16,17,18,19,20],"surfaceOp":"append"} -{"type":"step/end","seq":22,"time":1783778297073,"data":{"turn":1,"step":2}} -{"type":"turn/end","seq":23,"time":1783778297073,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"step/start","seq":2,"time":1783778297069,"data":{"turn":1,"step":1}} +{"type":"request/header","seq":3,"time":1783778297070,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}","messagePrefix":[{"role":"user","content":[{"type":"text","text":"\nThe following workspace instructions may be relevant to your work. Use them as guidance when applicable. More specific instructions take precedence over broader ones. They do not override system, developer, or direct user instructions.\n\nInstructions from: AGENTS.md\n\nRoot snapshot instruction.\n\n"}]}]},"reason":"initial"}} +{"type":"assistant/chunk","seq":4,"time":1783778297070,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":5,"time":1783778297070,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"call_workspace_read","name":"read","argumentsDelta":"{\"file_path\":\"nested/task.txt\"}"}}} +{"type":"assistant/chunk","seq":6,"time":1783778297070,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_workspace_read","name":"read","arguments":"{\"file_path\":\"nested/task.txt\"}"}}}} +{"type":"assistant/chunk","seq":7,"time":1783778297070,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} +{"type":"assistant/chunk","seq":8,"time":1783778297070,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":9,"time":1783778297070,"data":{"turn":1,"step":1,"content":[{"type":"tool-call","id":"call_workspace_read","name":"read","arguments":"{\"file_path\":\"nested/task.txt\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[4,5,6,7,8],"surfaceOp":"append"} +{"type":"tool/call","seq":10,"time":1783778297070,"data":{"turn":1,"step":1,"callId":"call_workspace_read","name":"read","arguments":"{\"file_path\":\"nested/task.txt\"}"}} +{"type":"tool/result","seq":11,"time":1783778297072,"data":{"turn":1,"step":1,"callId":"call_workspace_read","content":[{"type":"text","text":"{{cwd}}/nested/task.txt\nfile\n\n1: snapshot task\n\n(End of file - total 1 lines)\n"}],"isError":false},"sourceEventSeqs":[10],"surfaceOp":"append"} +{"type":"context/message","seq":12,"time":1783778297072,"data":{"content":[{"type":"text","text":"\nAdditional instructions from: nested/AGENTS.md\n\nThese instructions apply to work under `nested`. Use them as guidance when relevant; more specific instructions take precedence. They do not override system, developer, or direct user instructions.\n\nNested snapshot instruction.\n\n"}],"source":{"kind":"plugin","plugin":"workspace-context"},"meta":{"kind":"workspace-instructions","version":1,"changes":[{"action":"set","scope":"nested\u0000AGENTS.md","path":"nested/AGENTS.md","digest":"c446df9a85c7e73a3055f394a4822a19ac9ead5a"}]}},"surfaceOp":"append"} +{"type":"step/end","seq":13,"time":1783778297072,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":14,"time":1783778297072,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":15,"time":1783778297073,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":16,"time":1783778297073,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":0,"text":"DONE"}}} +{"type":"assistant/chunk","seq":17,"time":1783778297073,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DONE"}}}} +{"type":"assistant/chunk","seq":18,"time":1783778297073,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":2}}}} +{"type":"assistant/chunk","seq":19,"time":1783778297073,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":20,"time":1783778297073,"data":{"turn":1,"step":2,"content":[{"type":"text","text":"DONE"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":10,"outputTokens":2}},"sourceEventSeqs":[15,16,17,18,19],"surfaceOp":"append"} +{"type":"step/end","seq":21,"time":1783778297073,"data":{"turn":1,"step":2}} +{"type":"turn/end","seq":22,"time":1783778297073,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/workspace-context.cordis.yml b/examples/acp-agent/workspace-context.cordis.yml index f9dadc8189..962a3334fe 100644 --- a/examples/acp-agent/workspace-context.cordis.yml +++ b/examples/acp-agent/workspace-context.cordis.yml @@ -22,3 +22,12 @@ You are a coding assistant powered by the {{model}} model. Your working directory is {{cwd}}. Verify your work by running the code or tests. Keep answers brief and factual. + - insert: + - id: fs-local + name: '@deepseek-ai/dsh-fs-local' + config: + cwd: !!js process.cwd() + - id: fs-policy + name: '@deepseek-ai/dsh-fs-policy' + - id: tool-fs + name: '@deepseek-ai/dsh-tool-fs' diff --git a/examples/cordis-agent/cordis.yml b/examples/cordis-agent/cordis.yml index 01dcfc50be..5947e42456 100644 --- a/examples/cordis-agent/cordis.yml +++ b/examples/cordis-agent/cordis.yml @@ -13,12 +13,15 @@ config: root: ['.'] -# The DeepSeek adapter. +# The DeepSeek adapter. Shipped default: full thinking at max effort on every +# request (wire-only defaults; they never enter the request header). - id: llm-deepseek name: '@deepseek-ai/dsh-llm-deepseek' config: apiKey: !!js process.env.DEEPSEEK_API_KEY baseURL: !!js process.env.DEEPSEEK_BASE_URL + thinking: enabled + reasoningEffort: max # Local bash executor for agent-spine-demo's tool-bash schema — gives the agent an # ordinary tool whose calls make the mounted listeners observably fire. @@ -52,7 +55,7 @@ name: '@deepseek-ai/dsh-tui-demo' config: provider: deepseek - model: deepseek-v4-flash + model: deepseek-v4-pro resumeSessionId: !!js process.env.RESUME_SESSION_ID persistenceRoot: './.sessions' workspaceContext: diff --git a/examples/tui-agent/README.md b/examples/tui-agent/README.md index f38d533813..e4197b22c4 100644 --- a/examples/tui-agent/README.md +++ b/examples/tui-agent/README.md @@ -1,23 +1,77 @@ # tui-agent -The full-screen interactive coding agent: DeepSeek V4, local bash and filesystem tools, compaction, subagents, workflows and fresh-agent Ralph iteration, `todo_write`, timeout/spill policy, and [`@deepseek-ai/dsh-tui-demo`](../../packages/examples/tui-demo). +The interactive coding agent: DeepSeek V4 + the `read`/`write`/`edit` filesystem tools + the bash tool suite + subagent delegation + workflows + compaction + JSONL persistence, on the full-screen `dsh-tui` front door, loaded from `cordis.yml`. The sibling [`headless-agent`](../headless-agent/README.md) runs the same capability class as a one-shot pipe-friendly task, and [`acp-agent`](../acp-agent/README.md) serves it over JSON-RPC. ## Run it ```sh +# repo root .env (gitignored) or exported env: +# DEEPSEEK_API_KEY=sk-… +# DEEPSEEK_BASE_URL=https://… # optional; defaults to the public API pnpm run demo:tui ``` -The command needs `DEEPSEEK_API_KEY` in the environment or gitignored repository-root `.env`. Set `RESUME_SESSION_ID` to reopen a persisted conversation under `./.sessions`. +Both the demo script and the installable `dsh` CLI ([`apps/cli`](../../apps/cli/README.md)) boot this example's `cordis.yml` as the shipped default config; `dsh` additionally applies the personal overlay from `~/.dsh` and uses the invoking directory as the workspace. -The TUI renders Markdown history, reasoning, tool-owned terminal/diff/generic cards, token totals, and the latest todo list. Long tool bodies keep a head/tail preview; Ctrl+O expands or collapses every card. Enter submits or steers while the agent runs, Ctrl+R toggles reasoning, Escape cancels, and `/help` lists commands. `/model` opens a keyboard selector for the current provider catalog; use Up/Down and Enter, or `/model ` and `/model /` for direct selection. `ask_user_question` opens a wide bottom-left keyboard panel with batch progress and numbered options. +Type a coding task. The agent works through the `read`/`write`/`edit` filesystem tools for ordinary file operations and `bash` (+ the generic `task_output` / `task_list` / `task_kill` for background tasks) for shell commands, searches, and test runs, each in a fresh `bash -c` (the system prompt tells the model to pass `workdir` instead of `cd`). Both the fs tools and bash resolve relative paths against the session workspace. It can also delegate with `subagent`/`subagent_fork`. -Run `pnpm run demo:code-mode tui` for the Code Mode overlay. +The `todo_write` task tracker is opt-in and not in the shipped config: add `@deepseek-ai/dsh-tool-todo` to `cordis.yml` (or a personal-config overlay under `~/.dsh`) to expose it. Once loaded, the model records a whole-list plan to the session log and the TUI renders it. -## Composition +The TUI renders Markdown history, reasoning, tool-owned terminal/diff/generic cards, token totals, and — when `todo_write` is loaded — the latest plan. Long tool bodies keep a head/tail preview; Ctrl+O expands or collapses every card. Enter submits or steers while the agent runs, Ctrl+R toggles reasoning, Escape cancels, and `/help` lists commands. `/model` opens a keyboard selector for the current provider catalog; use Up/Down and Enter, or `/model ` and `/model /` for direct selection. `ask_user_question` opens a wide bottom-left keyboard panel with batch progress and numbered options. -[`cordis.yml`](cordis.yml) owns the interactive coding composition directly. [`code-mode.cordis.yml`](code-mode.cordis.yml) includes that leaf and replaces the tool presentation mode while adding the code runtime. Non-interactive automation uses the sibling [headless-agent](../headless-agent/README.md) composition. +### Resuming a prior session + +Each run starts a fresh session by default (its event log lands under `./.sessions/`). To **continue** a previous conversation, pass its id to the installed `dsh` CLI — the `main` agent then rehydrates the persisted log instead of starting fresh, so the model sees the earlier turns as history: + +```sh +dsh --resume +``` + +The TUI prints this exact command on exit and lists it under `/resume`, so resuming is copy-paste. The flag sets `RESUME_SESSION_ID`, wired through `cordis.yml` (`resumeSessionId: !!js process.env.RESUME_SESSION_ID`); the env var still works directly for the uninstalled demo (`RESUME_SESSION_ID= pnpm run demo:tui`), and with neither set the agent starts a new session. A missing or unreadable id starts no agent and emits `agent-loop/config-start-failed`: the TUI prints the failure and exits nonzero. + +## Code Mode + +[`code-mode.cordis.yml`](code-mode.cordis.yml) overlays the same tree with the worker-thread runtime and `tools: { mode: code }`. The model receives one `run_code` transport plus a generated TypeScript SDK for the visible tools; only program output returns to model context. Use `mode: both` to expose native calls alongside `run_code`. See the [Code Mode Agent Note](../../.agents/notes/implemented/feature/2026-06-15-code-mode.md) for the execution contract. + +```sh +pnpm run demo:code-mode # this overlay under the TUI (default UI) +pnpm run demo:code-mode acp # the acp-agent example's same-shaped overlay +``` + +Try a task that spans several tool calls, e.g.: + +> Count the lines of every `*.md` file under docs/ and write the three largest to summary.txt. + +and watch the transcript: one `run_code` call, a program looping over tools, and a result the model curated instead of five round-trips of raw tool output. + +## What each leaf entry demonstrates + +This example is a thin leaf `cordis.yml`: it picks the swappable backends, loads one app package, and adds product tools that are intentionally outside the shared spine. The spine (sessions, system-prompt, tools, agents, invariants, `agent-loop`) and the front-door cluster (JSONL persistence, the pi-tui channel, the pre-created `main` agent) live inside the [`@deepseek-ai/dsh-tui-demo`](../../packages/examples/tui-demo) app and the [`@deepseek-ai/dsh-agent-spine-demo`](../../packages/examples/agent-spine-demo) bundle it loads; the leaf wires the backends and model-facing optional tools: + +| Entry | Demonstrates | +|---|---| +| `hmr` (`@cordisjs/plugin-hmr`) | the dev/demo edit-reload loop — a **leaf** entry (not baked into the app) because it is Loader-only and needs `node --expose-internals`, which `demo:tui` passes | +| `llm-deepseek` | real `LlmAdapter` via config (`!!js process.env.…` secrets); swap one line to `@deepseek-ai/dsh-llm-pi-ai` for the library-backed twin | +| `bash` (`dsh-bash-local`) | the executor implementation — the swappable half of the bash seam. The model-facing `bash` schema (`tool-bash`) and generic `task_*` controls (`tool-tasks`) come from `dsh-agent-spine-demo`, so only the executor is a leaf choice | +| `tui-agent` (`@deepseek-ai/dsh-tui-demo`) | the app bundle: the agent-spine demo + JSONL persistence + the pi-tui channel + a pre-created `main` agent | +| `subagent`, `subagent-spawn`, `subagent-fork` | the subagent provider registry plus the two in-process backends: a fresh child and a child seeded with the parent's completed-turn prefix | +| `tool-subagent`, `tool-subagent-fork` | two model-facing `dsh-tool-subagent` loads, each bound to a different provider and exposed under a distinct tool name (`subagent`, `subagent_fork`) | +| `workflow-workerthread`, `tool-workflow` | the worker-thread workflow engine and its model-facing `workflow` tool, with child calls routed through the spawn backend | +| `fs-local`, `fs-policy`, `tool-fs` | the filesystem stack: the local `ctx.fs` provider, the read-before-write/edit policy gate (on the `fs/*` event gate), and the model-facing `read`/`write`/`edit` tools. Relative paths resolve against the session workspace | + +## End-to-end tests (`pnpm run test:e2e`) + +The UI-independent with-key suites assemble the full stack programmatically through `tests/harness.ts` (no PTY, no Loader): + +- `tests/full-loop.e2e.ts` — the canary: real model runs `echo e2e-ok` through the real bash tool; asserts `tool/call`/`tool/result` session events and the final answer. +- `tests/coding-task.e2e.ts` — the swebench-style smoke: a temp dir holds `add.js` (with `a - b` where `a + b` belongs) and a failing `add.test.js`; the agent must fix the bug and verify. The test re-runs `node add.test.js` ITSELF and inspects the files — agent claims are not trusted. +- `tests/resume.e2e.ts` — durable continuity across processes: run 1 tells the real model a secret code and persists the turn to a temp JSONL root, then the whole context is disposed; run 2 is a fresh context over the same root that RESUMES the session id and asks the model to recall the code. The recall can only come from the rehydrated log. +- `tests/compaction.e2e.ts` — the compaction smoke: a real multi-step bash task runs with a deliberately tiny context window so the auto-compaction listener fires MID-SESSION. Verifies the WORLD — a `compact/start…end` pair landed in the real log, the surface shrank (a replace node shadowed older nodes), and the agent still produced a correct final answer after compaction. +- `tests/todo-write.e2e.ts` — loads the opt-in `todo_write` tool, then a real model drives it and the test verifies the resulting `todo/write` session event. +- `tests/code-mode.e2e.ts` — the with-key Code Mode proof: a real model, a two-tool task, asserting the wire tool list was exactly `[run_code]`, the `tool/code-dispatch` events landed under the parent call, and the curated answer came back. + +These self-skip without `DEEPSEEK_API_KEY`. The keyless `tests/tui-keyless-smoke.e2e.ts` boots the real Loader tree in a PTY (the one sanctioned PTY surface): the base boot + `/exit`, a scripted-LLM conversation with a question dialog and tool round-trip, the Code Mode overlay welcome line, and the resume-failure exit path. ## Snapshot tests -`tests/snapshots//session.jsonl` supplies recorded user prompts and model chunks; sibling child logs drive subagents and workflows. The keyless suite executes those scripts through the real loop and tools, then compares readable terminal cell/style output. Use `pnpm run test:snapshot:refresh` for presentation-only changes and `pnpm run test:snapshot:record` with a DeepSeek key when a recorded model journey changes. The implemented [TUI snapshot Agent Note](../../.agents/notes/implemented/testing/2026-07-18-tui-terminal-state-snapshots.md) owns the scenario matrix. +`tests/snapshots//session.jsonl` supplies recorded user prompts and model chunks; sibling child logs drive subagents and workflows. The keyless suite executes those scripts through the real loop and tool implementations, then compares readable expected terminal cell/style output. Use `pnpm run test:snapshot:refresh` for presentation-only changes and `pnpm run test:snapshot:record` with a DeepSeek key when a recorded model journey changes. The implemented [TUI snapshot Agent Note](../../.agents/notes/implemented/testing/2026-07-18-tui-terminal-state-snapshots.md) owns the scenario matrix and the split between recorded journeys, transient package snapshots, and PTY coverage. diff --git a/examples/tui-agent/code-mode.cordis.yml b/examples/tui-agent/code-mode.cordis.yml index a11e86a068..49dbe62645 100644 --- a/examples/tui-agent/code-mode.cordis.yml +++ b/examples/tui-agent/code-mode.cordis.yml @@ -9,9 +9,10 @@ name: '@deepseek-ai/dsh-tui-demo' config: provider: deepseek - model: deepseek-v4-flash + model: deepseek-v4-pro resumeSessionId: !!js process.env.RESUME_SESSION_ID persistenceRoot: './.sessions' + resumeCommand: 'dsh --resume {session}' workspaceContext: maxBytes: 65536 tools: diff --git a/examples/tui-agent/cordis.yml b/examples/tui-agent/cordis.yml index 88d52873f2..efbf67307d 100644 --- a/examples/tui-agent/cordis.yml +++ b/examples/tui-agent/cordis.yml @@ -1,52 +1,75 @@ -# Full-screen coding agent with swappable DeepSeek and local capability backends. -# `dsh-tui-demo` supplies the spine, workspace instructions, generic task controls, -# JSONL persistence, the TUI front door, and `main`. HMR remains a leaf because -# it requires Loader internals; `demo:tui` passes `--expose-internals`. +# Full-screen TUI coding agent with swappable DeepSeek and local-bash backends. +# `dsh-tui-demo` supplies the agent spine, workspace instructions, generic +# task controls, JSONL persistence, the pi-tui front door, and `main`. +# HMR remains a leaf because it requires Loader internals; `demo:tui` passes +# `--expose-internals`. The app bin loads the gitignored root `.env`; this file +# reads `DEEPSEEK_API_KEY` and optional `DEEPSEEK_BASE_URL` through `!!js`. +# Hot-module reload for the dev/demo loop (needs `node --expose-internals`). - id: hmr name: '@cordisjs/plugin-hmr' config: root: ['.'] +# The native DeepSeek adapter. Shipped default: full thinking at max effort on +# every request (wire-only defaults; they never enter the request header). - id: llm-deepseek name: '@deepseek-ai/dsh-llm-deepseek' config: apiKey: !!js process.env.DEEPSEEK_API_KEY baseURL: !!js process.env.DEEPSEEK_BASE_URL + thinking: enabled + reasoningEffort: max +# Local executor for the app bundle's bash tool. - id: bash name: '@deepseek-ai/dsh-bash-local' config: timeoutMs: 60000 +# The app bundle pre-creates the TUI's `main` agent. - id: tui-agent name: '@deepseek-ai/dsh-tui-demo' config: provider: deepseek - model: deepseek-v4-flash + model: deepseek-v4-pro + # Set RESUME_SESSION_ID to continue a prior persisted session (the ids live + # under ./.sessions); unset starts a fresh session each run. resumeSessionId: !!js process.env.RESUME_SESSION_ID persistenceRoot: './.sessions' + # Printed on exit and listed by `/resume`; `{session}` fills the live id. + # `dsh --resume ` sets RESUME_SESSION_ID above, so run it from this cwd. + resumeCommand: 'dsh --resume {session}' workspaceContext: maxBytes: 65536 - welcome: 'TUI agent ready. Give it a coding task.' ui: showReasoning: true maxToolOutputLines: 6 + # Rename the terminal after the first message with a short model-made title. + autoTitle: true + # Keep the persona to identity and behavior; tool plugins own tool guidance. + # The loop resolves {{model}} from this agent's configuration. persona: | You are a coding agent powered by the {{model}} model. Verify your work by running the code or tests. Keep answers brief and factual. +# Replay-aware request pressure with one service-wide context window. - id: token-meter name: '@deepseek-ai/dsh-token-meter' - id: tool-result-prune name: '@deepseek-ai/dsh-compact-tool-result-prune' +# Summarize an older range after measured pressure or a canonical provider overflow. +# Service-wide policy provides pressure, retention, and one overflow-retry default. - id: compact-basic name: '@deepseek-ai/dsh-compact-basic' +# Expose fresh-child `spawn` and completed-prefix `fork` through independent +# in-process backends. Each tool instance needs a distinct `toolName`; the registry +# rejects duplicates. These leaves follow the app because it provides `ctx.agents` and `ctx.tools`. - id: subagent name: '@deepseek-ai/dsh-subagent' @@ -72,6 +95,9 @@ provider: fork toolName: subagent_fork + +# The worker-thread workflow engine fans a model-written JavaScript script's +# `agent()` calls out through the spawn backend; the adjacent tool exposes it to the model. - id: workflow-workerthread name: '@deepseek-ai/dsh-workflow-workerthread' config: @@ -85,9 +111,8 @@ - id: tool-ralph name: '@deepseek-ai/dsh-tool-ralph' -- id: tool-todo - name: '@deepseek-ai/dsh-tool-todo' - +# Policy loads before the model-facing filesystem tools so writes and edits require +# an observed file. This single-session app resolves relative paths from the process cwd. - id: fs-local name: '@deepseek-ai/dsh-fs-local' config: @@ -99,12 +124,24 @@ - id: tool-fs name: '@deepseek-ai/dsh-tool-fs' +# Bash-backed discovery tools (glob/grep): fixed ripgrep commands through the +# local bash executor above — not ctx.fs. Capped results save the complete +# formatted list through the spill backend below (ctx.spillStore, optional). - id: tool-fs-search name: '@deepseek-ai/dsh-tool-fs-search' +# The tool-call timeout enforcer: arms each declared ToolDefinition.timeoutMs +# (the search tools above declare 30s) as a deadline on exec.signal. Without +# it a declared budget is advisory and only the bash executor's own timeout +# backstop applies. - id: timeout-policy name: '@deepseek-ai/dsh-timeout-policy' +# Tool-output spill stack: a local backend that saves oversized tool text under +# a private session-scoped dir, and the tools/post-execute policy that replaces +# an over-budget plain-text result with a preview + the spill locator/retrieval +# hint. A leaf pair after the app (needs ctx.tools). The policy is a no-op until +# a tool returns more than maxInlineBytes of plain text. - id: spill-local name: '@deepseek-ai/dsh-spill-local' diff --git a/examples/tui-agent/tests/fixtures/tui-scripted.cordis.yml b/examples/tui-agent/tests/fixtures/tui-scripted.cordis.yml index dbe22cb53b..1c29beb8df 100644 --- a/examples/tui-agent/tests/fixtures/tui-scripted.cordis.yml +++ b/examples/tui-agent/tests/fixtures/tui-scripted.cordis.yml @@ -27,3 +27,4 @@ persona: 'Scripted model {{model}}.' ui: showReasoning: true + autoTitle: true diff --git a/examples/tui-agent/tests/tui-keyless-smoke.e2e.ts b/examples/tui-agent/tests/tui-keyless-smoke.e2e.ts index d8ecb2a978..8a54c9a4a3 100644 --- a/examples/tui-agent/tests/tui-keyless-smoke.e2e.ts +++ b/examples/tui-agent/tests/tui-keyless-smoke.e2e.ts @@ -1,44 +1,247 @@ +import { spawn } from 'node:child_process' +import { mkdir, mkdtemp, readdir, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' import { fileURLToPath } from 'node:url' import { describe, expect, it } from 'vitest' -import { LOADER_SMOKE_TEST_TIMEOUT_MS } from '@deepseek-ai/dsh-loader-smoke' -import { runTuiPtySmoke } from './pty-harness.ts' +import { LOADER_SMOKE_TEST_TIMEOUT_MS, resolveExampleLaunch } from '@deepseek-ai/dsh-loader-smoke' const binScript = fileURLToPath(new URL('../../../packages/examples/tui-demo/src/bin.ts', import.meta.url)) +const dshBinScript = fileURLToPath(new URL('../../../apps/cli/src/bin.ts', import.meta.url)) const configPath = fileURLToPath(new URL('../cordis.yml', import.meta.url)) +const codeModeConfigPath = fileURLToPath(new URL('../code-mode.cordis.yml', import.meta.url)) const scriptedConfigPath = fileURLToPath(new URL('./fixtures/tui-scripted.cordis.yml', import.meta.url)) const tsconfigPath = fileURLToPath(new URL('../../../tsconfig.json', import.meta.url)) -describe('tui-agent keyless smoke (real Loader tree in a PTY)', () => { - it('boots pi-tui, renders the configured banner, accepts /exit, and restores the terminal', async () => { - const output = await runTuiPtySmoke({ - label: 'tui-agent boot', - tempDirPrefix: 'tui-agent-smoke-', - binScript, - configPath, +const PTY_DRIVER = String.raw` +import errno, json, os, pty, select, signal, sys, time +node, launch_args_json, launch_env_json, cwd, resume_session_id, scenario, boot_marker = sys.argv[1:] +env = os.environ.copy() +env.update(json.loads(launch_env_json)) +env.update({ + "COLUMNS": "100", + "LINES": "30", +}) +if resume_session_id: + env["RESUME_SESSION_ID"] = resume_session_id +pid, fd = pty.fork() +if pid == 0: + os.chdir(cwd) + os.execvpe(node, [node, *json.loads(launch_args_json)], env) + +output = bytearray() +answered_question = False +opened_selector = False +selected_model = False +sent_prompt = False +sent_exit = False +deadline = time.monotonic() + 25 +status = None +while time.monotonic() < deadline: + ready, _, _ = select.select([fd], [], [], 0.05) + if ready: + try: + chunk = os.read(fd, 65536) + except OSError as error: + if error.errno != errno.EIO: + raise + chunk = b"" + if chunk: + output.extend(chunk) + if scenario == "conversation" and not opened_selector and b"scripted TUI ready." in output: + os.write(fd, b"/model\r") + opened_selector = True + if scenario == "conversation" and opened_selector and not selected_model and b"Select model" in output: + os.write(fd, b"\x1b[B\r") + selected_model = True + if scenario == "conversation" and selected_model and not sent_prompt and b"Model selected: tui-scripted/tui-scripted-model-pro." in output: + os.write(fd, b"exercise the TUI\r") + sent_prompt = True + if scenario == "conversation" and sent_prompt and not answered_question and b"How should the scripted run proceed?" in output: + os.write(fd, b"\r") + answered_question = True + if scenario == "conversation" and answered_question and not sent_exit and b"Decision received. Scripted TUI run complete." in output: + os.write(fd, b"/exit\r") + sent_exit = True + if scenario == "skill" and not selected_model and b"scripted TUI ready." in output: + os.write(fd, b"/model tui-scripted/tui-scripted-model-pro\r") + selected_model = True + if scenario == "skill" and selected_model and not sent_prompt and b"Model selected: tui-scripted/tui-scripted-model-pro." in output: + os.write(fd, b"/skill:scripted-skill\r") + sent_prompt = True + if scenario == "skill" and sent_prompt and not sent_exit and b"Scripted skill body received." in output: + os.write(fd, b"/exit\r") + sent_exit = True + if scenario == "boot" and not sent_exit and boot_marker.encode() in output: + os.write(fd, b"/exit\r") + sent_exit = True + waited, candidate = os.waitpid(pid, os.WNOHANG) + if waited == pid: + status = candidate + break + +if status is None: + os.kill(pid, signal.SIGKILL) + _, status = os.waitpid(pid, 0) +sys.stdout.buffer.write(output) +if scenario == "resume-failure": + if b'ui-tui: session "missing-session" failed to start:' not in output: + sys.stderr.write("TUI did not render the startup failure before timeout\n") + sys.exit(126) + if not os.WIFEXITED(status) or os.WEXITSTATUS(status) != 1: + sys.stderr.write("TUI startup failure did not exit with status 1\n") + sys.exit(127) +elif scenario == "conversation": + if not sent_prompt: + sys.stderr.write("TUI did not render the scripted welcome marker before timeout\n") + sys.exit(128) + if not answered_question: + sys.stderr.write("TUI did not render the user-question dialog before timeout\n") + sys.exit(129) + if not sent_exit: + sys.stderr.write("TUI did not finish the scripted tool round-trip before timeout\n") + sys.exit(130) + if not os.WIFEXITED(status) or os.WEXITSTATUS(status) != 0: + sys.stderr.write("TUI scripted conversation did not exit cleanly\n") + sys.exit(131) +elif scenario == "skill": + if not sent_prompt: + sys.stderr.write("TUI did not render the scripted welcome marker before typing /skill:\n") + sys.exit(132) + if b"Scripted skill body received." not in output: + sys.stderr.write("TUI did not deliver the loaded skill body to the model before timeout\n") + sys.exit(133) + if not sent_exit: + sys.stderr.write("TUI did not reach idle to accept /exit after the skill turn\n") + sys.exit(134) + if not os.WIFEXITED(status) or os.WEXITSTATUS(status) != 0: + sys.stderr.write("TUI skill scenario did not exit cleanly\n") + sys.exit(135) +else: + if not sent_exit: + sys.stderr.write("TUI did not render its welcome marker before timeout\n") + sys.exit(124) + if not os.WIFEXITED(status) or os.WEXITSTATUS(status) != 0: + sys.stderr.write("TUI child did not exit cleanly\n") + sys.exit(125) +` + +interface TuiLoaderSmokeOptions { + config?: string + resumeSessionId?: string + scenario?: 'boot' | 'conversation' | 'resume-failure' | 'skill' + /** Welcome text the boot scenario waits for before sending `/exit`. */ + bootMarker?: string + /** Bin to boot; defaults to the tui-demo bin (the dsh CLI tests override). */ + srcBin?: string + /** Argument vector for the bin; defaults to `[config]`. */ + configArgs?: string[] + /** Files written into the isolated Harness home (`$DSH_HOME`) before launch. */ + personalFiles?: Record + /** Skill bundles written under the isolated agents home (`.agents/skills/`) before launch, keyed by path below that root. */ + skillFiles?: Record + /** Runs against the workspace `cwd` after a clean exit, before it is removed. */ + inspect?: (cwd: string) => Promise +} + +async function runTuiLoaderSmoke(options: TuiLoaderSmokeOptions = {}): Promise { + const cwd = await mkdtemp(join(tmpdir(), 'tui-agent-smoke-')) + try { + // Personal config is always isolated from the developer's real ~/.dsh; + // a test opts into an overlay by supplying files under the Harness home. + const dshHome = join(cwd, '.dsh') + for (const [name, content] of Object.entries(options.personalFiles ?? {})) { + await mkdir(dshHome, { recursive: true }) + await writeFile(join(dshHome, name), content) + } + // The child chdirs to this cwd and the scripted config roots fs-local here, + // so a skill dropped under DSH_AGENTS_HOME's `skills/` root is discoverable + // and its body readable through the same tree the model-facing stack uses. + const skillsRoot = join(cwd, '.agents', 'skills') + for (const [name, content] of Object.entries(options.skillFiles ?? {})) { + const file = join(skillsRoot, name) + await mkdir(dirname(file), { recursive: true }) + await writeFile(file, content) + } + const launch = resolveExampleLaunch({ + srcBin: options.srcBin ?? binScript, + configArgs: options.configArgs ?? [options.config ?? configPath], tsconfigPath, - env: { DEEPSEEK_API_KEY: 'keyless-tui-no-call' }, - actions: [{ waitFor: 'TUI agent ready.', send: '/exit\r' }], + exposeInternals: true, + env: { + DEEPSEEK_API_KEY: 'keyless-tui-no-call', + DSH_HOME: dshHome, + DSH_AGENTS_HOME: join(cwd, '.agents'), + }, }) + return await new Promise((resolve, reject) => { + const child = spawn('python3', [ + '-c', + PTY_DRIVER, + launch.command, + JSON.stringify(launch.args), + JSON.stringify(launch.env), + cwd, + options.resumeSessionId ?? '', + options.scenario ?? 'boot', + // With no configured welcome the borderless banner sweeps in; its + // detail line's session id (`main-session-`) renders only once + // the sweep reaches it, so it marks a settled banner. + options.bootMarker ?? 'main-session-', + ], { stdio: ['ignore', 'pipe', 'pipe'] }) + let stdout = '' + let stderr = '' + child.stdout.setEncoding('utf8') + child.stdout.on('data', (chunk: string) => { stdout += chunk }) + child.stderr.setEncoding('utf8') + child.stderr.on('data', (chunk: string) => { stderr += chunk }) + child.once('error', reject) + child.once('exit', (code) => { + if (code !== 0) { + reject(new Error(`TUI PTY smoke exited ${String(code)}. stdout:\n${stdout}\nstderr:\n${stderr}`)) + return + } + // Inspect the workspace before `finally` removes it (e.g. the session log). + void (options.inspect?.(cwd) ?? Promise.resolve()).then(() => { resolve(stdout) }, reject) + }) + }) + } finally { + await rm(cwd, { recursive: true, force: true }) + } +} + +/** The rendered system prompt from the first `request/header` in the workspace's persisted session log. */ +async function readLoggedSystemPrompt(cwd: string): Promise { + const sessionsDir = join(cwd, '.sessions') + const entries = await readdir(sessionsDir, { recursive: true }) + // A single keyless run writes one session log; the source section is global, so any log carries it. + const logRelPath = entries.find(name => name.endsWith('.jsonl')) + if (logRelPath === undefined) throw new Error(`no session log written under ${sessionsDir}`) + const lines = (await readFile(join(sessionsDir, logRelPath), 'utf8')).split('\n').filter(Boolean) + for (const line of lines) { + const event = JSON.parse(line) as { type: string; data: { header?: { system?: string } } } + if (event.type === 'request/header') return event.data.header?.system ?? '' + } + throw new Error(`session log ${logRelPath} has no request/header event`) +} + +describe('tui-agent keyless smoke (real Loader tree in a PTY)', () => { + it('boots pi-tui, sweeps the borderless banner in, accepts /exit, and restores the terminal', async () => { + const output = await runTuiLoaderSmoke() + // With no configured welcome the borderless banner sweeps in left-to-right; + // the boot scenario waits for the detail line's session id, which renders + // only once the sweep reaches it. expect(output).toContain('DEEPSEEK') - expect(output).toContain('TUI agent ready.') + expect(output).toContain('HARNESS') + expect(output).toContain('main-session-') + // Borderless: no box-drawing frame around the banner. + expect(output).not.toContain('╭') + expect(output).not.toContain('╮') expect(output).toContain('\u001B[?2004l') }, LOADER_SMOKE_TEST_TIMEOUT_MS) - it('switches models, streams a response, answers a user-question dialog, and exits cleanly', async () => { - const output = await runTuiPtySmoke({ - label: 'tui-agent conversation', - tempDirPrefix: 'tui-agent-conversation-', - binScript, - configPath: scriptedConfigPath, - tsconfigPath, - actions: [ - { waitFor: 'scripted TUI ready.', send: '/model\r' }, - { waitFor: 'Select model', send: '\x1b[B\r' }, - { waitFor: 'Model selected: tui-scripted/tui-scripted-model-pro.', send: 'exercise the TUI\r' }, - { waitFor: 'How should the scripted run proceed?', send: '\r' }, - { waitFor: 'Decision received. Scripted TUI run complete.', send: '/exit\r' }, - ], - }) + it('streams a response, answers a user-question dialog, completes the tool round-trip, and exits cleanly', async () => { + const output = await runTuiLoaderSmoke({ config: scriptedConfigPath, scenario: 'conversation' }) expect(output).toContain('I need one decision before I continue.') expect(output).toContain(String.raw`\x1b]2;MODEL_CONTROLLED\x07`) expect(output).toContain(String.raw`\x1b[999CMODEL_CURSOR`) @@ -46,23 +249,125 @@ describe('tui-agent keyless smoke (real Loader tree in a PTY)', () => { expect(output).not.toContain('\u001B]2;MODEL_CONTROLLED\u0007') expect(output).not.toContain('\u001B[999CMODEL_CURSOR') expect(output).not.toContain('\u009B31mMODEL_C1') + expect(output).toContain('How should the scripted run proceed?') expect(output).toContain('Safe') + expect(output).toContain('Decision received. Scripted TUI run complete.') + // Auto-title: the first user message drives a tool-less title call that the + // scripted adapter answers, and the TUI sets it via OSC 0. + expect(output).toContain('\u001B]0;scripted session title\u0007') + expect(output).toContain('\u001B[?2004l') + }, LOADER_SMOKE_TEST_TIMEOUT_MS) + + it('loads a local skill via /skill: and delivers its body to the model as a user turn', async () => { + // The whole manual-invocation path in one keyless boot: `ctx.get('skills')` + // resolves in the shipped tree, the client-side `/skill:` command parses, + // the local provider loads `scripted-skill` from the agents home, and the + // rendered `` block reaches the model — proven by the + // scripted adapter echoing the fixture's body marker only when it arrives. + const output = await runTuiLoaderSmoke({ + config: scriptedConfigPath, + scenario: 'skill', + skillFiles: { + 'scripted-skill/SKILL.md': [ + '---', + 'name: scripted-skill', + 'description: Keyless PTY proof that the skill command loads a local skill into the conversation.', + '---', + '', + 'SCRIPTED SKILL BODY MARKER', + '', + ].join('\n'), + }, + }) + expect(output).toContain('Scripted skill body received.') + expect(output).toContain('\u001B[?2004l') + }, LOADER_SMOKE_TEST_TIMEOUT_MS) + + it('boots the Code Mode overlay tree, renders its banner, and exits cleanly', async () => { + // The overlay's only keyless composition proof: the include+patch tree, + // worker code runtime, and one-tool registry all mount before the banner. + const output = await runTuiLoaderSmoke({ + config: codeModeConfigPath, + bootMarker: 'TUI Code Mode ready.', + }) + expect(output).toContain('TUI Code Mode ready.') expect(output).toContain('\u001B[?2004l') }, LOADER_SMOKE_TEST_TIMEOUT_MS) it('prints a config-resume failure and exits instead of leaving a blank terminal', async () => { - const output = await runTuiPtySmoke({ - label: 'tui-agent resume failure', - tempDirPrefix: 'tui-agent-resume-', - binScript, - configPath, - tsconfigPath, - env: { - DEEPSEEK_API_KEY: 'keyless-tui-no-call', - RESUME_SESSION_ID: 'missing-session', - }, - expectedExitCode: 1, - }) + const output = await runTuiLoaderSmoke({ resumeSessionId: 'missing-session', scenario: 'resume-failure' }) expect(output).toContain('ui-tui: session "missing-session" failed to start:') }, LOADER_SMOKE_TEST_TIMEOUT_MS) }) + +describe('dsh CLI keyless smoke (apps/cli through the same PTY)', () => { + it('boots the shipped default config with no arguments and no personal overlay', async () => { + const output = await runTuiLoaderSmoke({ srcBin: dshBinScript, configArgs: [] }) + expect(output).toContain('DEEPSEEK') + expect(output).toContain('main-session-') + expect(output).not.toContain('╭') + expect(output).not.toContain('╮') + expect(output).toContain('\u001B[?2004l') + }, LOADER_SMOKE_TEST_TIMEOUT_MS) + + it('applies the personal overlay: config.yaml patches the tree and .env feeds its !!js', async () => { + // The whole personal-config chain in one boot: the personal .env supplies + // the variable, config.yaml patches the tui-agent entry with a `!!js` + // reference to it, and the banner renders the patched welcome verbatim. + const output = await runTuiLoaderSmoke({ + srcBin: dshBinScript, + configArgs: [], + bootMarker: 'PERSONAL OVERLAY READY.', + personalFiles: { + '.env': 'DSH_PERSONAL_WELCOME=PERSONAL OVERLAY READY.\n', + 'config.yaml': [ + '- id: tui-agent', + " name: '@deepseek-ai/dsh-tui-demo'", + ' config:', + ' provider: deepseek', + ' model: deepseek-v4-flash', + ' workspaceContext: false', + ' welcome: !!js process.env.DSH_PERSONAL_WELCOME', + '', + ].join('\n'), + }, + }) + expect(output).toContain('PERSONAL OVERLAY READY.') + expect(output).toContain('\u001B[?2004l') + }, LOADER_SMOKE_TEST_TIMEOUT_MS) + + it('fails loud instead of booting when the personal config.yaml is invalid', async () => { + await expect(runTuiLoaderSmoke({ + srcBin: dshBinScript, + configArgs: [], + personalFiles: { 'config.yaml': 'id: not-a-list\n' }, + })).rejects.toThrow('must be a top-level YAML array of loader patch entries') + }, LOADER_SMOKE_TEST_TIMEOUT_MS) + + it('routes the --resume flag into the config resume intake, failing loud on a missing id', async () => { + // The flag path end to end: apps/cli parses `--resume missing-session` and + // sets RESUME_SESSION_ID (the PTY driver does NOT here), the shipped + // config's `!!js` reads it, and the resume fails loud — proving the printed + // `dsh --resume ` hint reaches the same intake as the env var. + const output = await runTuiLoaderSmoke({ + srcBin: dshBinScript, + configArgs: ['--resume', 'missing-session'], + scenario: 'resume-failure', + }) + expect(output).toContain('ui-tui: session "missing-session" failed to start:') + }, LOADER_SMOKE_TEST_TIMEOUT_MS) + + it('tells the model where its own source lives, in the system prompt it sends', async () => { + // The launcher resolves the checkout root three hops up from apps/cli/{src,lib}; + // this test file sits an equal depth under the same root, so the same hop applies. + const sourceRoot = fileURLToPath(new URL('../../..', import.meta.url)) + let loggedSystem = '' + await runTuiLoaderSmoke({ + srcBin: dshBinScript, + configArgs: [scriptedConfigPath], + scenario: 'conversation', + inspect: async (cwd) => { loggedSystem = await readLoggedSystemPrompt(cwd) }, + }) + expect(loggedSystem).toContain(`Your own source code is the checkout at ${sourceRoot}; you can read it there to learn how dsh works and how to extend it.`) + }, LOADER_SMOKE_TEST_TIMEOUT_MS) +}) diff --git a/knip.json b/knip.json index a0944233e9..0d84184cb9 100644 --- a/knip.json +++ b/knip.json @@ -55,7 +55,8 @@ "project": ["src/**/*.ts", "tests/**/*.ts"] }, "packages/util/brand": { - "project": ["src/**/*.ts"] + "project": ["src/**/*.ts"], + "ignoreDependencies": ["cordis"] }, "packages/util/timeout": { "entry": ["tests/**/*.spec.ts"], @@ -142,7 +143,7 @@ "project": ["src/**/*.ts", "tests/**/*.ts"] }, "packages/examples/tui-demo": { - "entry": ["tests/**/*.spec.ts"], + "entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts"], "project": ["src/**/*.ts", "tests/**/*.ts"] }, "packages/examples/cli-demo": { diff --git a/package.json b/package.json index c3998bad0e..42711da6e2 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ "workspaces": [ "vendor/*", "packages/*/*", + "apps/*", "website" ], "scripts": { @@ -81,7 +82,7 @@ "doc-sync": "tsx scripts/run-gates.ts doc-sync", "hygiene": "pnpm run knip && pnpm run publint && pnpm run constraints && pnpm run verify-package-invariants && pnpm run verify-built-package-invariants && pnpm run verify-cordis-config && pnpm run verify-node-next-types && pnpm run verify-runtime-closure", "demo:headless": "node --expose-internals --import tsx packages/examples/cli-demo/src/bin.ts --config examples/headless-agent/cordis.yml", - "demo:tui": "node --expose-internals --import tsx packages/examples/tui-demo/src/bin.ts examples/tui-agent/cordis.yml", + "demo:tui": "node --expose-internals --import tsx apps/cli/src/bin.ts", "demo:code-mode": "node scripts/demo-code-mode.mjs", "demo:cordis": "node --expose-internals --import tsx packages/examples/tui-demo/src/bin.ts examples/cordis-agent/cordis.yml", "demo:acp": "node --import tsx packages/examples/acp-demo/src/bin.ts --config examples/acp-agent/cordis.yml", diff --git a/packages/context/workspace-context/README.md b/packages/context/workspace-context/README.md index 7dd1e06931..a04c844e9b 100644 --- a/packages/context/workspace-context/README.md +++ b/packages/context/workspace-context/README.md @@ -42,7 +42,7 @@ These instructions apply to work under `packages/app`. Use them as guidance when A same-file edit starts with `Updated instructions from: ` and says to use the new content instead of the previously loaded content. A candidate switch additionally names the old path. When no candidate remains, the message is `Instructions removed: ` followed by `The previously loaded instructions from this file no longer apply.` Literal `` text inside an instruction file is escaped so file content cannot close the plugin-owned frame. -The plugin owns the complete `` framing, and every `context/message` (from this plugin or any other) reaches the model verbatim as a user-role message with no wrapping. +The core `context/message` envelope is disabled for these messages because the plugin already owns the complete `` framing. This is caller-selected with `envelope: 'raw'`; ordinary injected context still receives the canonical `` envelope. ## State And Refresh diff --git a/packages/context/workspace-context/src/index.ts b/packages/context/workspace-context/src/index.ts index 21ef979459..83bbfa9d26 100644 --- a/packages/context/workspace-context/src/index.ts +++ b/packages/context/workspace-context/src/index.ts @@ -92,6 +92,7 @@ export function apply(ctx: Context, config: Config): void { if (update !== undefined) { agent.inject(update.context.content, { source: update.context.source, + envelope: update.context.envelope, meta: update.context.meta, }) applyInstructionVersionUpdates(agent.session, update.versionUpdates, instructionVersions) diff --git a/packages/context/workspace-context/src/render.ts b/packages/context/workspace-context/src/render.ts index 910853b13b..34e427d0e3 100644 --- a/packages/context/workspace-context/src/render.ts +++ b/packages/context/workspace-context/src/render.ts @@ -163,12 +163,6 @@ function buildInstructionText( ): string { const marker = markerText(maxBytes, omitted, truncated) const body = [marker, style.intro, ...files.map(file => style.section(file))].filter(block => block.length > 0) - // Caller-owned framing: the plugin bakes the complete `` - // frame into the message content. The session surface projects context - // verbatim and does not wrap it, so any framing must live here in the - // producer's content (the pattern a future `meta`-driven renderer would - // generalize — see the deferred note in - // ../../../../.agents/notes/implemented/simplification/2026-07-20-unwrap-injected-content-envelopes.md). return [SYSTEM_REMINDER_OPEN, body.join('\n\n'), SYSTEM_REMINDER_CLOSE].join('\n') } diff --git a/packages/context/workspace-context/src/state.ts b/packages/context/workspace-context/src/state.ts index 0ab455349a..b86daeafcf 100644 --- a/packages/context/workspace-context/src/state.ts +++ b/packages/context/workspace-context/src/state.ts @@ -61,8 +61,9 @@ export interface ReconciledInstructionContext { versionUpdates: InstructionVersionUpdate[] } -/** Plugin-owned context with required replay metadata. */ +/** Plugin-owned raw context with required replay metadata. */ export interface WorkspaceHookContext extends HookContext { + envelope: 'raw' meta: JsonValue } @@ -75,7 +76,7 @@ function workspaceContextHook(text: string, changes: WorkspaceInstructionChange[ ...change.digest !== undefined ? { digest: change.digest } : {}, })) const meta: JsonValue = { kind: 'workspace-instructions', version: 1, changes: serializedChanges } - return { content: [{ type: 'text', text }], source: PLUGIN_SOURCE, meta } + return { content: [{ type: 'text', text }], source: PLUGIN_SOURCE, envelope: 'raw', meta } } /** diff --git a/packages/context/workspace-context/tests/workspace-context.spec.ts b/packages/context/workspace-context/tests/workspace-context.spec.ts index 9c5cb3b0d3..7efe77d992 100644 --- a/packages/context/workspace-context/tests/workspace-context.spec.ts +++ b/packages/context/workspace-context/tests/workspace-context.spec.ts @@ -183,6 +183,7 @@ function stubAgent(cwd?: string, seed: SessionEvent[] = []): Agent { session.append('context/message', { content, source: options?.source ?? { kind: 'user' }, + ...options?.envelope !== undefined ? { envelope: options.envelope } : {}, ...options?.meta !== undefined ? { meta: options.meta } : {}, }, { surfaceOp: 'append' }) }, @@ -211,6 +212,7 @@ function workspaceChangeContext(scope: string, digest: string): HookContext { return { content: [{ type: 'text', text: `instructions for ${scope}` }], source: { kind: 'plugin', plugin: 'workspace-context' }, + envelope: 'raw', meta: { kind: 'workspace-instructions', version: 1, @@ -225,6 +227,7 @@ function appendAdditionalContexts(agent: Agent, result: { additionalContexts?: H lastSeq = agent.session.append('context/message', { content: context.content, source: context.source, + ...context.envelope !== undefined ? { envelope: context.envelope } : {}, ...context.meta !== undefined ? { meta: context.meta } : {}, }, { surfaceOp: 'append' }).seq } @@ -1750,6 +1753,7 @@ describe('dynamic nested workspace context injection', () => { expect(result.isError).toBe(false) expect(workspaceContextOf(result)?.source).toEqual({ kind: 'plugin', plugin: 'workspace-context' }) + expect(workspaceContextOf(result)?.envelope).toBe('raw') expect(workspaceContextOf(result)?.meta).toMatchObject({ kind: 'workspace-instructions', version: 1, @@ -2560,6 +2564,7 @@ describe('dynamic nested workspace context injection', () => { expect(blocksText(result.content)).toBe('downstream replacement') expect(result.additionalContexts).toHaveLength(2) expect(workspaceContextOf(result)?.source).toEqual({ kind: 'plugin', plugin: 'workspace-context' }) + expect(workspaceContextOf(result)?.envelope).toBe('raw') expect(workspaceContextOf(result)?.meta).toMatchObject({ kind: 'workspace-instructions', changes: [{ action: 'set', scope: 'pkg', path: join('pkg', 'AGENTS.md') }], @@ -2572,8 +2577,7 @@ describe('dynamic nested workspace context injection', () => { }) const agent = stubAgent(root) appendAdditionalContexts(agent, result) - expect(blocksText(agent.session.deriveMessages()[1]?.content)).toContain('downstream context') - expect(blocksText(agent.session.deriveMessages()[1]?.content)).not.toContain('\ndownstream context\n') } finally { await rm(root, { recursive: true, force: true }) await rm(home, { recursive: true, force: true }) @@ -2919,6 +2923,7 @@ describe('workspace context pending state', () => { const otherWorkspaceEvent = agent.session.append('context/message', { content: otherContext.content, source: otherContext.source, + ...otherContext.envelope !== undefined ? { envelope: otherContext.envelope } : {}, ...otherContext.meta !== undefined ? { meta: otherContext.meta } : {}, }, { surfaceOp: 'append' }) observeInstructionSessionEvent(agent.session, otherWorkspaceEvent, pending, versions) @@ -2928,6 +2933,7 @@ describe('workspace context pending state', () => { const confirmed = agent.session.append('context/message', { content: context.content, source: context.source, + ...context.envelope !== undefined ? { envelope: context.envelope } : {}, ...context.meta !== undefined ? { meta: context.meta } : {}, }, { surfaceOp: 'append' }) observeInstructionSessionEvent(agent.session, confirmed, pending, versions) diff --git a/packages/examples/cli-demo/src/cli.ts b/packages/examples/cli-demo/src/cli.ts index 7a6dfee8eb..66ce345520 100644 --- a/packages/examples/cli-demo/src/cli.ts +++ b/packages/examples/cli-demo/src/cli.ts @@ -14,7 +14,7 @@ import { boot, loadEnv, resolveConfigPath } from '@deepseek-ai/dsh-app-boot' const CLI_NAME = 'dsh-cli-demo' const DEFAULT_CONFIG_PATH = './cordis.yml' const OUTPUT_FORMATS = ['text', 'json', 'stream-json'] as const -const USAGE = `Usage: ${CLI_NAME} [--config path] [--output-format text|json|stream-json] \n` +const USAGE = `Usage: ${CLI_NAME} [--config path] [--output-format text|json|stream-json] (-p | )\n` /** Supported CLI output encodings. */ export type OutputFormat = typeof OUTPUT_FORMATS[number] @@ -73,6 +73,7 @@ interface ParsedArguments { readonly config?: string readonly 'output-format'?: string readonly help?: boolean + readonly prompt?: string } readonly positionals: string[] } @@ -129,6 +130,7 @@ export function parseCliArgs(args: readonly string[]): CliCommand { config: { type: 'string' }, 'output-format': { type: 'string' }, help: { type: 'boolean' }, + prompt: { type: 'string', short: 'p' }, }, allowPositionals: true, strict: true, @@ -138,12 +140,16 @@ export function parseCliArgs(args: readonly string[]): CliCommand { } if (parsed.values.help === true) return { kind: 'help' } - if (parsed.positionals.length !== 1) { - throw new CliArgumentError(`expected exactly one positional task, received ${parsed.positionals.length}`) + const prompt = parsed.values.prompt + if (prompt !== undefined && parsed.positionals.length > 0) { + throw new CliArgumentError('-p/--prompt and a positional task are mutually exclusive') } - // Cardinality was checked above, so index zero exists. + if (prompt === undefined && parsed.positionals.length !== 1) { + throw new CliArgumentError(`expected exactly one positional task or -p, received ${parsed.positionals.length} positional(s)`) + } + // Cardinality was checked above, so the fallback index zero exists. // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const task = parsed.positionals[0]! + const task = prompt ?? parsed.positionals[0]! if (task.trim().length === 0) throw new CliArgumentError('task must not be blank') const requestedFormat = parsed.values['output-format'] ?? 'text' diff --git a/packages/examples/cli-demo/tests/built-bin.e2e.ts b/packages/examples/cli-demo/tests/built-bin.e2e.ts index 3dadd32215..b6227d9702 100644 --- a/packages/examples/cli-demo/tests/built-bin.e2e.ts +++ b/packages/examples/cli-demo/tests/built-bin.e2e.ts @@ -8,6 +8,15 @@ import { fileURLToPath } from 'node:url' import { zstdDecompress } from 'node:zlib' import { afterEach, describe, expect, it } from 'vitest' +/** + * Published-entry smoke: run `lib/bin.js` under plain Node in a symlinked external consumer. + * The consumer's mock model is an example-local TypeScript plugin (Node 22.19+ — the engines + * floor — strips types natively, so plain `node` loads it), its config carries a `disabled: + * true` unresolvable entry (the fail-loud entry-load guard must not mistake an intentionally + * fiber-less entry for a failed import), and the optional spill pair loads from the consumer + * install — so every passing boot proves all three alongside the CLI's own output contract. + */ + const repoRoot = fileURLToPath(new URL('../../../../', import.meta.url)) const cliBin = join(repoRoot, 'packages/examples/cli-demo/lib/bin.js') const decompress = promisify(zstdDecompress) @@ -17,6 +26,7 @@ const dshPackages = [ 'bash/bash-local', 'bash/tool-bash', 'support/invariants', 'ui/app-boot', 'session-persistence/session-persistence', 'session-persistence/session-persistence-jsonl', 'context/workspace-context', + 'spill/spill', 'spill/spill-local', 'spill/spill-policy', 'util/retention', ] const vendorPackages = ['cordis', 'loader', 'include', 'timer', 'schemastery', 'cosmokit'] @@ -35,15 +45,18 @@ async function makeConsumer(): Promise { const nodeModules = join(dir, 'node_modules') for (const rel of dshPackages) await linkPackage(join(repoRoot, 'packages', rel), nodeModules) for (const rel of vendorPackages) await linkPackage(join(repoRoot, 'vendor', rel), nodeModules) - await writeFile(join(dir, 'mock-llm.mjs'), [ - "import { LlmAdapter } from '@deepseek-ai/dsh-llm'", + await writeFile(join(dir, 'mock-llm.ts'), [ + // Real type annotations: this file exists to prove plain Node's type + // stripping loads an example-local TS plugin from a built consumer. + "import { LlmAdapter, type GenerateOptions, type StreamChunk } from '@deepseek-ai/dsh-llm'", + "import type { Context } from 'cordis'", 'class Mock extends LlmAdapter {', - ' async * stream(options) {', - " const text = options.messages.flatMap(message => message.content).filter(block => block.type === 'text').at(-1)?.text ?? ''", + ' async * stream(options: GenerateOptions): AsyncIterable {', + " const text: string = options.messages.flatMap(message => message.content).filter(block => block.type === 'text').at(-1)?.text ?? ''", " yield { type: 'block-start', index: 0, blockType: 'text' }", " if (text === 'hang') {", " yield { type: 'text-delta', index: 0, text: 'partial' }", - ' await new Promise((resolve, reject) => {', + ' await new Promise((resolve, reject) => {', " const timer = setTimeout(() => reject(new Error('hang timeout')), 30000)", " const onAbort = () => { clearTimeout(timer); reject(new Error('aborted')) }", ' if (options.signal.aborted) onAbort()', @@ -60,12 +73,12 @@ async function makeConsumer(): Promise { '}', "export const name = 'built-cli-mock'", "export const inject = ['llm']", - "export function apply(ctx) { ctx.llm.registerAdapter(['built-cli-mock'], new Mock()) }", + "export function apply(ctx: Context) { ctx.llm.registerAdapter(['built-cli-mock'], new Mock()) }", '', ].join('\n')) await writeFile(join(dir, 'cordis.yml'), [ '- id: mock-llm', - " name: './mock-llm.mjs'", + " name: './mock-llm.ts'", '- id: bash', " name: '@deepseek-ai/dsh-bash-local'", '- id: cli-agent', @@ -76,6 +89,18 @@ async function makeConsumer(): Promise { " persona: 'built CLI test'", " persistenceRoot: './.sessions'", ' workspaceContext: false', + '- id: spill-local', + " name: '@deepseek-ai/dsh-spill-local'", + '- id: spill-policy', + " name: '@deepseek-ai/dsh-spill-policy'", + ' config:', + ' maxInlineBytes: 50000', + // A `disabled: true` entry settles without a fiber by design; the fail-loud + // entry-load guard must not mistake it for a failed import. The nonexistent + // path makes that distinction observable while a clean run proves boot continued. + '- id: off', + " name: './does-not-exist.ts'", + ' disabled: true', '', ].join('\n')) return dir diff --git a/packages/examples/cli-demo/tests/cli.spec.ts b/packages/examples/cli-demo/tests/cli.spec.ts index f20697b827..e477924f3f 100644 --- a/packages/examples/cli-demo/tests/cli.spec.ts +++ b/packages/examples/cli-demo/tests/cli.spec.ts @@ -162,15 +162,19 @@ describe('parseCliArgs', () => { kind: 'run', configPath: 'custom.yml', outputFormat: 'stream-json', task: 'do it', }) expect(parseCliArgs(['--', '-task'])).toMatchObject({ task: '-task' }) + expect(parseCliArgs(['-p', 'flag task'])).toMatchObject({ task: 'flag task' }) + expect(parseCliArgs(['--prompt', 'long-flag task'])).toMatchObject({ task: 'long-flag task' }) expect(parseCliArgs(['--help', 'ignored'])).toEqual({ kind: 'help' }) }) it('rejects missing, blank, extra, invalid-format, and unsupported flags', () => { expect(() => parseCliArgs([])).toThrow('received 0') expect(() => parseCliArgs([' '])).toThrow('must not be blank') + expect(() => parseCliArgs(['-p', ' '])).toThrow('must not be blank') expect(() => parseCliArgs(['one', 'two'])).toThrow('received 2') + expect(() => parseCliArgs(['-p', 'task', 'positional'])).toThrow('mutually exclusive') expect(() => parseCliArgs(['--output-format', 'xml', 'task'])).toThrow('unsupported output format') - expect(() => parseCliArgs(['-p', 'task'])).toThrow('Unknown option') + expect(() => parseCliArgs(['-x', 'task'])).toThrow('Unknown option') }) }) diff --git a/packages/examples/tui-demo/src/index.ts b/packages/examples/tui-demo/src/index.ts index 53d6d304a7..d602a233e3 100644 --- a/packages/examples/tui-demo/src/index.ts +++ b/packages/examples/tui-demo/src/index.ts @@ -27,7 +27,6 @@ import * as uiTui from '@deepseek-ai/dsh-tui' export const name = 'tui-demo' const DEFAULT_PERSISTENCE_ROOT = './.sessions' -const DEFAULT_WELCOME = 'ready.' /** App config routed to the spine, TUI, configured agent, and JSONL backend. */ export interface Config { @@ -51,8 +50,15 @@ export interface Config { persistenceRoot?: string /** JSONL artifact encoding; defaults to checksummed Zstandard frames. */ persistenceCompression?: JsonlCompression - /** TUI subtitle rendered on start. Defaults to `ready.`. */ + /** TUI transcript's optional first line; absent renders nothing on start. */ welcome?: string + /** + * Shell command template the TUI prints on exit and lists under `/resume`, + * with `{session}` replaced by the live session id (forwarded to the front + * door). Set it to a command that resumes via this app's env var, e.g. + * `RESUME_SESSION_ID={session} dsh`. + */ + resumeCommand?: string /** Full-screen TUI presentation settings. */ ui?: uiTui.TuiConfig /** Skill registry, local-provider, and model-facing consumer config. */ @@ -84,7 +90,8 @@ export const Config: z = z.object({ sessionTitle: agentCore.SessionTitleConfigSchema, persistenceRoot: z.string().default(DEFAULT_PERSISTENCE_ROOT), persistenceCompression: JsonlCompressionSchema, - welcome: z.string().default(DEFAULT_WELCOME), + welcome: z.string(), + resumeCommand: z.string(), ui: uiTui.TuiConfigSchema, skills: agentCore.SkillConfigSchema, toolBash: agentCore.ToolBashConfigSchema, @@ -115,7 +122,8 @@ export function composeTuiApp(ctx: Context, config: Config): void { ctx.plugin(UserInteractionService) ctx.plugin(uiTui, { ...config.ui, - welcome: config.welcome ?? DEFAULT_WELCOME, + ...config.welcome === undefined ? {} : { welcome: config.welcome }, + ...config.resumeCommand === undefined ? {} : { resumeCommand: config.resumeCommand }, sessionId, }) ctx.plugin(agentCore, { diff --git a/packages/examples/tui-demo/tests/built-bin.e2e.ts b/packages/examples/tui-demo/tests/built-bin.e2e.ts new file mode 100644 index 0000000000..904c75784a --- /dev/null +++ b/packages/examples/tui-demo/tests/built-bin.e2e.ts @@ -0,0 +1,98 @@ +import { spawn } from 'node:child_process' +import { existsSync } from 'node:fs' +import { mkdtemp, mkdir, rm, symlink, readFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { afterEach, describe, expect, it } from 'vitest' + +/** + * Published-entry smoke: run `lib/bin.js` under plain Node in a symlinked external consumer. + * The TUI app owns no non-TTY fallback, so the piped subprocess must refuse to boot with a + * nonzero exit and a stderr pointer at the one-shot CLI — the bin guards BEFORE the Loader + * because a compose-time throw inside the tree is logged per-entry, not rethrown. The consumer + * links only the bin's import chain (dsh-app-boot and its vendored Loader stack): the refusal + * fires before any config is read, so no plugin tree is needed. Missing-config fail-loud and + * full-boot coverage for the shared dsh-app-boot glue live in cli-demo's built-bin suite; it + * skips before build, and interactive TTY behavior is PTY-covered by examples/tui-agent (the + * one sanctioned PTY surface). + */ + +const repoRoot = fileURLToPath(new URL('../../../../', import.meta.url)) +const tuiBin = join(repoRoot, 'packages/examples/tui-demo/lib/bin.js') + +// Symlink each package the bin imports at module load by package name so plain +// Node resolves its built `main`, matching an installed dependency rather than +// tsconfig paths. +const dshPackages = ['examples/tui-demo', 'ui/app-boot'] +const vendorPackages = ['cordis', 'loader', 'include', 'schemastery', 'cosmokit'] + +async function pkgName(absDir: string): Promise { + const json = JSON.parse(await readFile(join(absDir, 'package.json'), 'utf8')) as { name: string } + return json.name +} + +/** Build a temporary external consumer with built workspace/vendor links. */ +async function makeConsumer(): Promise { + const dir = await mkdtemp(join(tmpdir(), 'tui-built-bin-')) + const nm = join(dir, 'node_modules') + for (const rel of dshPackages) { + const abs = join(repoRoot, 'packages', rel) + const target = join(nm, await pkgName(abs)) + await mkdir(dirname(target), { recursive: true }) + await symlink(abs, target) + } + for (const v of vendorPackages) { + const abs = join(repoRoot, 'vendor', v) + const target = join(nm, await pkgName(abs)) + await mkdir(dirname(target), { recursive: true }) + await symlink(abs, target) + } + return dir +} + +/** Run the built bin in `cwd` with PIPED stdio; resolve with output + exit code. */ +function runBuiltBin(cwd: string): Promise<{ stdout: string; code: number; stderr: string }> { + return new Promise((resolve, reject) => { + // NO tsx — this is the published `node lib/bin.js` path (`--expose-internals` + // matches the demo command; the guard fires before the Loader needs it). + const child = spawn(process.execPath, ['--expose-internals', tuiBin, './cordis.yml'], { + cwd, + env: { ...process.env, DSH_HOME: join(cwd, '.dsh'), DSH_AGENTS_HOME: join(cwd, '.agents') }, + stdio: ['pipe', 'pipe', 'pipe'], + }) + let stdout = '' + let stderr = '' + child.stdout.setEncoding('utf8') + child.stdout.on('data', (c: string) => { stdout += c }) + child.stderr.setEncoding('utf8') + child.stderr.on('data', (c: string) => { stderr += c }) + const timer = setTimeout(() => { + child.kill('SIGKILL') + reject(new Error(`built bin did not exit within 25s. stdout:\n${stdout}\nstderr:\n${stderr}`)) + }, 25_000) + child.on('exit', (code) => { clearTimeout(timer); resolve({ stdout, code: code ?? -1, stderr }) }) + child.on('error', (err) => { clearTimeout(timer); reject(err) }) + child.stdin.end() + }) +} + +let consumer: string | undefined + +afterEach(async () => { + // Windows can briefly retain released handles after exit; retry removal. + if (consumer !== undefined) await rm(consumer, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 }) + consumer = undefined +}) + +describe.skipIf(!existsSync(tuiBin))('dsh-tui-demo BUILT bin (node lib/bin.js, no tsx)', () => { + it('refuses pipes LOUD (non-zero exit + stderr) before booting the Loader', async () => { + consumer = await makeConsumer() + const { stdout, code, stderr } = await runBuiltBin(consumer) + expect(code).not.toBe(0) + expect(stderr).toContain('requires stdin and stdout to be interactive TTYs') + expect(stderr).toContain('dsh-cli-demo') + // The refusal happens before any plugin mounts: stdout stays silent. + expect(stdout).toBe('') + }, 30_000) +}) diff --git a/packages/support/loader-smoke/src/index.ts b/packages/support/loader-smoke/src/index.ts index 36ab137f32..aa04e05958 100644 --- a/packages/support/loader-smoke/src/index.ts +++ b/packages/support/loader-smoke/src/index.ts @@ -138,8 +138,6 @@ export interface LoaderSmokeOptions { readonly mode?: ExampleMode /** Environment overrides layered over the parent and isolated DSH homes. */ readonly env?: Readonly - /** Lines written to stdin before EOF; omitted means immediate EOF. */ - readonly stdinLines?: readonly string[] /** Process deadline override for harness tests. */ readonly processTimeoutMs?: number /** Optional world-state setup run in the isolated cwd before process start. */ @@ -157,10 +155,10 @@ export interface LoaderSmokeResult { } /** - * Boot one real Loader tree from an isolated cwd, write the requested stdin - * script, close stdin, and await a clean exit. The helper owns process kill and - * temp-directory cleanup on every outcome, and picks src/lib via {@link resolveExampleLaunch}. - * @param options - example paths, mode, environment, stdin, and diagnostic identity. + * 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}. + * @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 { @@ -220,7 +218,7 @@ export async function runLoaderSmoke(options: LoaderSmokeOptions): Promise `${line}\n`).join('')) + child.stdin.end() }) await options.inspect?.(cwd) return result diff --git a/packages/support/loader-smoke/tests/loader-smoke.spec.ts b/packages/support/loader-smoke/tests/loader-smoke.spec.ts index 1c80f9ee22..d1f764c69b 100644 --- a/packages/support/loader-smoke/tests/loader-smoke.spec.ts +++ b/packages/support/loader-smoke/tests/loader-smoke.spec.ts @@ -11,7 +11,7 @@ const fixture = (name: string): string => fileURLToPath(new URL(`./fixtures/${na const canonicalTempPath = (path: string): string => path.replace(/^\/private(?=\/var\/)/, '') describe('runLoaderSmoke', () => { - it('isolates the process, writes stdin, captures output, and removes the cwd', async () => { + it('isolates the process, closes stdin, captures output, and removes the cwd', async () => { const result = await runLoaderSmoke({ label: 'success fixture', tempDirPrefix: 'loader-smoke-success-', @@ -20,7 +20,6 @@ describe('runLoaderSmoke', () => { tsconfigPath, mode: 'src', env: { LOADER_SMOKE_MARKER: 'present' }, - stdinLines: ['one', 'two'], }) const output = JSON.parse(result.stdout) as { configPath: string @@ -35,7 +34,7 @@ describe('runLoaderSmoke', () => { configPath, args: [configPath], marker: 'present', - input: 'one\ntwo\n', + input: '', }) expect(canonicalTempPath(output.dshHome)).toBe(canonicalTempPath(join(output.cwd, '.dsh'))) expect(canonicalTempPath(output.agentsHome)).toBe(canonicalTempPath(join(output.cwd, '.agents'))) diff --git a/packages/ui/app-boot/README.md b/packages/ui/app-boot/README.md index d874d8c154..24840b7fda 100644 --- a/packages/ui/app-boot/README.md +++ b/packages/ui/app-boot/README.md @@ -5,10 +5,14 @@ Shared boot glue for the app bins ([`dsh-tui-demo`](../../examples/tui-demo/READ | Export | Role | |---|---| | `resolveConfigPath(path, snapshotMode, cwd?)` | Absolute config path; `snapshotMode === 'replay'` swaps a `cordis.yml`/`.yaml` basename for its sibling `cordis.snapshot.yml` | +| `parseResumeArg(argv)` | Split the `--resume ` / `--resume=` flag out of the arguments, returning `{ resumeSessionId, rest }`; a valueless, empty, or repeated flag throws so a mistyped resume fails loud instead of silently starting fresh | | `loadEnv(binName, dir?, warn?)` | Load the gitignored `.env` (Node `process.loadEnvFile`); absent file is fine, an unloadable one warns a single labelled line (default: stderr) | | `installFailLoud(binName, proc?)` | Turn a post-`boot()` unhandled Loader rejection into one labelled stderr line + `exit(1)`; returns the uninstaller (for tests) | | `assertEntriesLoaded(ctx, binName)` | Throw when a settled tree holds an enabled entry with no fiber (a plugin module that failed to import) | -| `boot(binName, absoluteConfigPath)` | Mount the Loader, mount the statically imported include plugin as the `cordis:include` builtin (so the config may live outside `node_modules` reach), include the config by absolute `file://` URL, await the whole tree, assert entries loaded, return the root context | +| `loadPersonalPatches(binName, dir?)` | Parse the optional `config.yaml` in the Harness home (default [`resolveDshHome()`](../../util/paths/README.md): `$DSH_HOME`, else `~/.dsh`) — a top-level YAML array of include `PatchOptions` (id-targeted config overrides, `insert` lists, `!!js` allowed); absent file → `undefined`, an unreadable/unparsable/non-array file throws | +| `boot(binName, absoluteConfigPath, patches?)` | Mount the Loader, mount the statically imported include plugin as the `cordis:include` builtin (so the config may live outside `node_modules` reach), include the config by absolute `file://` URL with the optional overlay patches, await the whole tree, assert entries loaded, return the root context | +| `addHarnessSourceSection(ctx, sourceRoot)` | Add a global `harness:source` prompt section (ordered just after the harness identity, before the persona) telling the agent the on-disk path to its own source checkout; a no-op returning `undefined` when the booted tree has no `systemPrompt` service. The section is registered against that service's fiber, so a dev HMR reload of the system prompt drops it until the next boot | +| `HARNESS_SOURCE_SECTION` | The `'harness:source'` section name `addHarnessSourceSection` registers under | Two failure classes the guards handle: `loader.await()` swallows init rejections (`Promise.allSettled`) — Node still exits non-zero on the resulting unhandled rejection, and `installFailLoud` replaces the noisy dump with one labelled line and a guaranteed `exit(1)`; a failed plugin IMPORT is only logged by the Loader (the process would otherwise exit 0 on a usable config typo), leaving a fiber-less entry that `assertEntriesLoaded` turns into a `boot()` rejection. @@ -16,16 +20,27 @@ Bare plugin specifiers in a config (`@deepseek-ai/dsh-*`, npm packages) resolve This package carries no loader hooks and no dev-mode surface: the `dsh-scripts` launcher ([`sdk/scripts`](../../sdk/scripts/README.md), with the shared project model in [`sdk/helper`](../../sdk/helper/README.md)) owns process startup, tsx registration, and local-plugin source resolution, and consumes these helpers for the boot sequence itself. +## Personal config + +A developer's machine-local preferences live outside every repository in the Harness home (default `~/.dsh`, overridable via `$DSH_HOME`; the single root [`resolveDshHome`](../../util/paths/README.md) resolves), consumed by the `dsh` CLI's TUI surface ([`apps/cli`](../../../apps/cli/README.md)); the demo bins boot their committed trees verbatim. Two optional files: + +- **`.env`** — loaded after the invoking directory's `.env`; `process.loadEnvFile` never overrides, so precedence is ambient environment > project `.env` > personal `.env`. +- **`config.yaml`** — loader overlay patches applied over the shipped default config, with the same semantics as an include entry's `patches` (the committed Code Mode overlay is the template): an id-targeted patch replaces the named entry's whole `config` (restate unchanged fields), `insert` adds entries, and `!!js` expressions interpolate at mount — so a personal `apiKey` can reference the personal `.env`. A patch naming an entry id absent from the booted tree is skipped with a loader warning. An empty or comments-only file throws (it parses to nothing, not to a list); disable the overlay with `[]` or by deleting the file. + +Subprocess test launchers point `DSH_HOME` at an isolated per-test directory so a developer's personal overlay can never leak into fixtures. + ## Model Experience -Indirectly, through the plugin tree it loads, which determines the prompts, schemas, messages, and model adapter in the resulting application. +Indirectly, through the plugin tree it loads, which determines the prompts, schemas, messages, and model adapter in the resulting application; the one export that contributes model-visible text, `addHarnessSourceSection`, does so only when a consumer calls it after boot. #### KV Cache effect -No direct invalidation; the named consumer owns any request-prefix changes. +No direct invalidation from `boot()`; a consumer that calls `addHarnessSourceSection` places one short line near the system prompt's head, before per-request content, so it does not invalidate the cache across turns, and any other request-prefix change is owned by the named consumer. ## Known Limitations and Deferred Work - **Bare package specifiers depend on Loader internals** — production bins need `node --expose-internals` or the Loader's optional native fallback; an in-process caller without either must use resolvable relative/file specifiers or tsx path mapping. - **Snapshot replay swapping is basename-specific** — only a config ending in `cordis.yml` or `cordis.yaml` maps to the sibling `cordis.snapshot.yml`; custom config names require caller-managed selection. - **Environment loading is cwd-scoped and optional** — the helper loads one `.env` file and warns on failure; it does not search parents, merge profiles, or validate required variables. +- **Personal config is patch-shaped** — an id-targeted patch replaces the entry's whole `config` rather than deep-merging, so a personal override restates the base fields it keeps. +- **Personal patches see only the booted file's own entries** — an overlay leaf that reaches its base through a nested include entry (the Code Mode configs) resolves personal patch ids against the overlay's top-level entries, not the included subtree. diff --git a/packages/ui/app-boot/src/index.ts b/packages/ui/app-boot/src/index.ts index e2413fa736..c150b4a1c1 100644 --- a/packages/ui/app-boot/src/index.ts +++ b/packages/ui/app-boot/src/index.ts @@ -1,15 +1,21 @@ /** * Shared boot glue for the app bins (`dsh-tui-demo`, `dsh-cli-demo`, `dsh-acp-demo`): load the gitignored - * `.env`, install the fail-loud Loader guards, resolve the config path (snapshot-aware), and - * drive the cordis Loader against a leaf `cordis.yml` until the whole tree has settled. + * `.env`, install the fail-loud Loader guards, resolve the config path (snapshot-aware), load the + * optional personal overlay patches from the Harness home (`~/.dsh`), and drive the cordis Loader + * against a leaf `cordis.yml` until the whole tree has settled. * @module @deepseek-ai/dsh-app-boot */ import { pathToFileURL } from 'node:url' -import { basename, dirname, resolve } from 'node:path' +import { readFileSync } from 'node:fs' +import { basename, dirname, join, resolve } from 'node:path' +import * as yaml from 'js-yaml' import { Context } from 'cordis' import Loader from '@cordisjs/plugin-loader' -import Include from '@cordisjs/plugin-include' +import Include, { type PatchOptions } from '@cordisjs/plugin-include' +import { resolveDshHome } from '@deepseek-ai/dsh-paths' +// Side-effect type import: resolves `ctx.get('systemPrompt')` to the service. +import type {} from '@deepseek-ai/dsh-system-prompt' /** * Resolve the config to boot. Replay swaps a `cordis.yml` basename for @@ -30,6 +36,48 @@ export function resolveConfigPath( return resolve(dir, replayName) } +/** CLI flag the interactive surface accepts to resume a persisted session by id. */ +const RESUME_FLAG = '--resume' + +/** + * Split a leading `--resume ` / `--resume=` flag out of a CLI argument + * vector, returning the resumed session id (when the flag is present) and the + * remaining arguments with the flag and its value removed — so a positional + * config path stays readable regardless of the flag's position. A `--resume` + * with no following id, an empty id (`--resume=`), or a repeated `--resume` + * throws: a mistyped resume must fail loud, never silently start a fresh + * session. The id is not validated here; an unknown id fails loud downstream + * when the session cannot load. + * @param argv - the CLI arguments after subcommand dispatch. + * @returns the parsed resume id (or `undefined`) and the flag-stripped arguments. + */ +export function parseResumeArg( + argv: readonly string[], +): { resumeSessionId: string | undefined; rest: string[] } { + const rest: string[] = [] + let resumeSessionId: string | undefined + let skipNext = false + for (const [i, arg] of argv.entries()) { + if (skipNext) { + skipNext = false + continue + } + const inlineValue = arg.startsWith(`${RESUME_FLAG}=`) + if (arg === RESUME_FLAG || inlineValue) { + if (resumeSessionId !== undefined) throw new Error(`${RESUME_FLAG} may be given only once`) + const value = inlineValue ? arg.slice(RESUME_FLAG.length + 1) : argv[i + 1] + if (value === undefined || value === '') { + throw new Error(`${RESUME_FLAG} requires a session id (e.g. ${RESUME_FLAG} )`) + } + resumeSessionId = value + skipNext = !inlineValue // the space form consumed the following token as its value + continue + } + rest.push(arg) + } + return { resumeSessionId, rest } +} + /** * Load the optional gitignored `.env` from `dir`. Missing files fall back to the * ambient environment; other read failures are reported through `warn`. @@ -51,6 +99,62 @@ export function loadEnv( } } +/** File inside the Harness home holding the personal loader overlay patches. */ +export const PERSONAL_CONFIG_FILENAME = 'config.yaml' + +// The include's YAML dialect: `!!js` scalars become expression nodes the +// Loader interpolates against each entry's context at mount time. Personal +// patches are parsed with the same schema so they may reference `process.env`. +// Load-only: this schema never dumps, so no `predicate`/`represent`. +const jsExprType = new yaml.Type('tag:yaml.org,2002:js', { + kind: 'scalar', + resolve: data => typeof data === 'string', + construct: data => ({ __jsExpr: String(data) }), +}) +const personalPatchesSchema = yaml.JSON_SCHEMA.extend(jsExprType) + +/** + * Load the optional personal overlay patches (`config.yaml` under the Harness + * home). The file is a top-level YAML array of loader patch entries + * (`@cordisjs/plugin-include`'s `PatchOptions`): id-targeted config overrides + * and `insert` lists, with `!!js` expressions allowed. A missing file means + * "no personal overlay"; an unreadable, unparsable, or non-array file throws — + * a present personal config that cannot apply is a misconfiguration and must + * fail loud at boot, never be silently skipped. + * @param binName - the diagnostic prefix on the thrown error. + * @param dir - the Harness home; defaults to {@link resolveDshHome} (`$DSH_HOME` or `~/.dsh`). + * @returns the parsed patches, or `undefined` when the file does not exist. + */ +export function loadPersonalPatches( + binName: string, dir: string = resolveDshHome(), +): PatchOptions[] | undefined { + const file = join(dir, PERSONAL_CONFIG_FILENAME) + let content: string + try { + content = readFileSync(file, 'utf8') + } catch (error) { + if ((error as NodeJS.ErrnoException | null)?.code === 'ENOENT') return undefined + throw new Error(`${binName}: failed to read personal patches ${file}: ${String(error)}`) + } + let parsed: unknown + try { + parsed = yaml.load(content, { schema: personalPatchesSchema }) + } catch (error) { + throw new Error(`${binName}: failed to parse personal patches ${file}: ${String(error)}`) + } + if (!Array.isArray(parsed)) { + throw new Error(`${binName}: personal patches ${file} must be a top-level YAML array of loader patch entries`) + } + // A present personal config that cannot apply is a misconfiguration and must + // fail loud here — the include only warns per entry at mount. + parsed.forEach((entry, index) => { + if (typeof entry !== 'object' || entry === null || Array.isArray(entry)) { + throw new Error(`${binName}: personal patches entry ${index + 1} in ${file} must be a mapping (a loader patch entry)`) + } + }) + return parsed as PatchOptions[] +} + /** * The slice of `process` {@link installFailLoud} needs — injectable so tests * exercise the handler without registering on (or exiting) the real process. @@ -109,18 +213,52 @@ export function assertEntriesLoaded(ctx: Context, binName: string): void { * @param binName - the diagnostic prefix for load-failure errors. * @param absoluteConfigPath - the config to include; must already be absolute * (see {@link resolveConfigPath}). + * @param patches - optional overlay patches applied over the included tree + * (see {@link loadPersonalPatches}); an empty list mounts none. * @returns the root context once every entry has started. */ -export async function boot(binName: string, absoluteConfigPath: string): Promise { +export async function boot( + binName: string, absoluteConfigPath: string, patches?: PatchOptions[], +): Promise { const ctx = new Context() ctx.baseUrl = pathToFileURL(dirname(absoluteConfigPath)).href + '/' await ctx.plugin(Loader) ctx.loader.builtins.include = Include await ctx.loader.create({ name: 'cordis:include', - config: { path: pathToFileURL(absoluteConfigPath).href }, + config: { + path: pathToFileURL(absoluteConfigPath).href, + ...patches !== undefined && patches.length > 0 ? { patches } : {}, + }, }) await ctx.loader.await() assertEntriesLoaded(ctx, binName) return ctx } + +/** Prompt-section name for the harness-source location line an app bin adds after boot. */ +export const HARNESS_SOURCE_SECTION = 'harness:source' + +/** + * Add a global prompt section naming the on-disk path to the harness source + * checkout the running bin was launched from, so the agent knows where its own + * source lives (the self-referential `dsh-tool-cordis` toolset reads and edits + * it). Call once on the settled boot context ({@link boot}); the section orders + * just after the harness identity opener (`-100`) and before the deployment + * persona (`0`). A booted tree with no `systemPrompt` service has no prompt to + * augment, so this is then a no-op that returns `undefined`. The section is + * registered against the `systemPrompt` service's fiber, so a dev HMR reload of + * that plugin drops it until the next boot. + * @param ctx - the settled boot context whose global system prompt to augment. + * @param sourceRoot - the absolute path to the harness checkout root. + * @returns the section disposer, or `undefined` when no `systemPrompt` service is mounted. + */ +export function addHarnessSourceSection(ctx: Context, sourceRoot: string): (() => void) | undefined { + const systemPrompt = ctx.get('systemPrompt') + if (systemPrompt === undefined) return undefined + return systemPrompt.section({ + name: HARNESS_SOURCE_SECTION, + order: -99, + text: `Your own source code is the checkout at ${sourceRoot}; you can read it there to learn how dsh works and how to extend it.`, + }) +} diff --git a/packages/ui/tui/README.md b/packages/ui/tui/README.md index 05c1a442b5..d11712367f 100644 --- a/packages/ui/tui/README.md +++ b/packages/ui/tui/README.md @@ -1,12 +1,12 @@ # @deepseek-ai/dsh-tui -The interactive terminal front door for DeepSeek Harness agents, built on [`@earendil-works/pi-tui`](https://www.npmjs.com/package/@earendil-works/pi-tui). It requires stdin and stdout TTYs; scripts and Loader pipes should use the headless [`@deepseek-ai/dsh-cli-demo`](../../examples/cli-demo/README.md) app instead. +The interactive terminal front door for DeepSeek Harness agents, built on [`@earendil-works/pi-tui`](https://www.npmjs.com/package/@earendil-works/pi-tui). It requires stdin and stdout TTYs; scripts and Loader pipes should use the one-shot [`@deepseek-ai/dsh-cli-demo`](../../examples/cli-demo/README.md) app instead. The implemented [TUI feature Agent Note](../../../.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.md) owns the front-door decision; the [terminal-state snapshot Agent Note](../../../.agents/notes/implemented/testing/2026-07-18-tui-terminal-state-snapshots.md) owns its verification strategy. Interactive terminals on macOS, Linux, and Windows are supported. Windows uses pi-tui's native console VT-input handling, and the [Windows support Agent Note](../../../.agents/notes/implemented/feature/2026-07-20-windows-tui-support.md) owns the platform decision and ConPTY process verification. -This package owns interactive terminal presentation and input only. It injects `agents`, [`commands`](../commands/README.md), `llm`, `systemPrompt`, `tokenMeter`, `tools`, and `userInteraction`, then drives an agent created or resumed by app or developer code. Agent lifecycle, persistence, and the model-facing [`ask_user_question`](../tool-ask-user/README.md) tool remain separate composition entries. +This package owns interactive terminal presentation and input only. It injects `agents`, [`commands`](../commands/README.md), `llm`, `systemPrompt`, `tokenMeter`, `tools`, and `userInteraction`, optionally reads a `skills` service (present only when one is mounted), then drives an agent created or resumed by app or developer code. Agent lifecycle, persistence, and the model-facing [`ask_user_question`](../tool-ask-user/README.md) tool remain separate composition entries. The TUI rebuilds resumed history from the active session surface, renders Markdown responses and reasoning, applies each tool's `presentCall` / `presentResult` intent to terminal, diff, or generic cards, keeps the latest `todo/write` plan above the editor, and presents `ctx.userInteraction` questions in a wide bottom-left keyboard panel with progress, numbered options, and aligned descriptions. The latest logged session title becomes the header subtitle, with `welcome` before a title exists, and the terminal window title becomes ``. A durable `llm/retry` event retracts the failed step's live chunks and renders the scheduled retry count, delay, and failure in the transcript; success, exhaustion, and cancellation then settle through ordinary session events. The footer totals each logged model step's usage once, including failed attempts, while treating committed-message usage as a fallback for logs without a usage chunk. Its idle view compares token-meter pressure with `ctx.llm.resolveModelContext()` for the current route, displays `context unknown` when the adapter has no capacity metadata, and also shows tool-card mode and the current model with reasoning state; while the agent runs, an elapsed working indicator and `esc interrupt` replace that summary. Surface replacement events rebuild the transcript so compacted history does not reappear. @@ -14,15 +14,21 @@ An embedding may provide `TuiRuntime.formatCwd` when its logical workspace label Before model output, session events, tool presenters, questions, configuration, or diagnostics reach pi-tui's ANSI-aware renderers or the terminal title, the TUI renders C0 and C1 controls other than line feeds as visible `\xNN` text. Those sources cannot add terminal control sequences; the TUI and pi-tui retain ownership of terminal rendering and styling. -While the agent is running, ordinary editor submissions call `agent.steer()`; otherwise they call `agent.send()`. A slash at the start of the submitted line enters `ctx.commands` instead: known commands execute directly, unknown commands produce a warning, and neither path reaches the model. The TUI registers `/help`, `/model`, `/clear`, `/cancel`, `/reasoning`, `/tools`, `/redraw`, and `/exit` as agent-scoped definitions; every other effective command joins autocomplete and `/help` dynamically. Ctrl+C or Escape cancels a running turn. Tool cards collapse long bodies into a configurable head/tail preview; Ctrl+O toggles every card between its preview and full output. Ctrl+R toggles reasoning, Ctrl+L redraws, and Ctrl+D exits while idle. +While the agent is running, ordinary editor submissions call `agent.steer()`; otherwise they call `agent.send()`. A slash at the start of the submitted line enters `ctx.commands` instead: known commands execute directly, unknown commands produce a warning, and neither path reaches the model. The TUI registers `/help`, `/model`, `/clear`, `/reasoning`, `/tools`, `/redraw`, `/reload`, `/resume`, and `/exit` as agent-scoped definitions; every other effective command joins autocomplete and `/help` dynamically, as do `/skill:` completions. A status line above the editor reports the turn phase the TUI derives from session events — waiting for the first token, thinking, responding, or executing tools — with the elapsed time in that phase and the running step total, refreshed each second, and ends with the `Enter sends steering, Esc cancels` hint; while steering messages wait to reach the model it inserts a `N queued ·` badge before the hint that clears as each drains. Ctrl+C or Escape cancels a running turn. Tool cards collapse long bodies into a configurable head/tail preview; Ctrl+O toggles every card between its preview and full output. Ctrl+R toggles reasoning, Ctrl+L redraws, and Ctrl+D exits while idle. `/model` opens the advisory `ctx.llm` catalog as a keyboard selector: Up/Down moves, Enter selects, and Escape closes it. `/model ` still selects an unambiguous model id directly, while `/model /` selects an exact target. The configured target or latest logged request header initializes the selector, and an unlisted current model remains visible because catalogs are advisory. Selection is local to this TUI session. Prompt assembly snapshots the target for one step, replaces `{{provider}}` and `{{model}}`, and applies the same pair through `agent/request`; a switch during assembly therefore starts with a later step. The request header durably records targets that reach the model, while an unused selection remains process-local. +`/reload` (EXPERIMENTAL, dev-only) re-reads every file-backed loader config tree and applies the diff to the running app — the HMR watcher's config path, invoked manually; it needs the cordis Loader in the context and degrades to a warning without one, runs only while the agent is idle, and refuses re-entry while a reload is in flight. Module-source hot reload remains watcher-owned. When a `skills` service is mounted, `/skill: [instructions]` loads that skill's instructions into the conversation as a user turn; autocomplete lists the model-invocable skills, and any skill (including a model-disabled one) is loadable by its exact name. + +The footer sums the session's reported usage as `↑`, followed by `cache %` once any input has been billed — the share of billed prompt tokens (uncached input plus cache reads and writes) served from the provider cache, rounded to a percent. It also compares token-meter pressure with `ctx.llm.resolveModelContext()` for the current route (omitting the context share when the adapter has no capacity metadata) and shows the current model and tool-card mode; the right side clips first when the footer is narrow. + +When `resumeCommand` is set and a `sessionPersistence` backend is mounted, exiting prints the resume command for the current session (once it has been persisted, so an abandoned session yields no hint), and `/resume` lists this workspace's persisted sessions newest-first, each with its resume command and a marker on the current one. `{session}` in the template expands to the session id; the TUI only prints commands to copy and never resumes in place. + ## Config | Key | Default | Meaning | |---|---|---| -| `welcome` | `ready.` | Header subtitle until the session has a logged title. | +| `welcome` | — | Banner subtitle line until the session has a logged title; unset, the banner sweeps in with no subtitle | | `sessionId` | `main` | Exact shared agent/session identity driven by the terminal | | `showReasoning` | `true` | Render reasoning blocks | | `maxToolOutputLines` | `6` | Output lines retained across a collapsed tool card's head/tail preview | @@ -35,6 +41,8 @@ While the agent is running, ordinary editor submissions call `agent.steer()`; ot | `showHardwareCursor` | `false` | Show the hardware cursor at pi-tui's IME marker | | `color` | `true` | Apply the built-in ANSI palette (see [Color](#color)) | | `title` | `DeepSeek Harness` | Product suffix for the terminal window title. | +| `resumeCommand` | — | Shell command template for the exit hint and `/resume`, with `{session}` expanded to the session id; unset disables both. Needs a `sessionPersistence` backend | +| `autoTitle` | `true` | Replace `title` with a short model-made title derived from the session's first user message; a resumed session re-derives it from that stored message on mount (needs an `llm` service and an agent provider/model) | ```yaml - id: terminal @@ -82,6 +90,20 @@ The selector adds no messages. A target change may alter interpolated system-pro Changing provider or model enters that target's cache domain; no cache reuse across distinct targets is assumed. +### Manual skill invocation + +#### What the model sees + +A `/skill: [instructions]` submission loads the named skill and delivers one text block: a `` element wrapping the skill's instructions — preceded, when the provider exposes a resource base, by a line locating the skill's relative resources — followed by any trailing instructions the user typed. Delivery follows the same send-while-idle / steer-while-running rule as ordinary input. The command, not the model, chooses the skill; model-disabled skills are omitted from autocomplete but stay loadable by exact name. + +#### Token effect + +The rendered skill block and trailing instructions are retained as one user turn under the agent loop's normal session-history and compaction rules; a repeated invocation appends the body again. + +#### KV Cache effect + +Append-only; newly visible content follows the reusable request prefix and does not invalidate existing KV-cache entries. + ### Interactive user-question answers #### What the model sees @@ -100,4 +122,5 @@ Append-only; newly visible content follows the reusable request prefix and does - **One configured session owns the transcript and editor** — questions from other agents can still use the shared overlay provider, but session rendering and prompt input remain bound to `sessionId`. - **Tool cards are text terminal presentations** — terminal, diff, and generic cards use tool-owned titles/content, but session content currently has no image block for inline image rendering. -- **Non-TTY operation is intentionally unsupported** — automation must use the headless app rather than expecting an internal fallback. +- **Non-TTY operation is intentionally unsupported** — app bundles that need automation must compose a one-shot or server front door (`dsh-cli-demo`, `dsh-acp`) rather than expecting an internal fallback. +- **Manual `/skill:` invocation always reloads the full skill body** — the TUI does not detect a skill already present in the conversation, so repeated invocations append its instructions again. diff --git a/packages/ui/tui/src/index.ts b/packages/ui/tui/src/index.ts index d8bbf6657f..0b93f32660 100644 --- a/packages/ui/tui/src/index.ts +++ b/packages/ui/tui/src/index.ts @@ -13,6 +13,7 @@ import { Editor, Input, Key, + Loader, Markdown, Spacer, Text, @@ -29,6 +30,7 @@ import { type MarkdownTheme, type OverlayHandle, type SelectListTheme, + type SlashCommand, type Terminal, type TerminalColorScheme, } from '@earendil-works/pi-tui' @@ -44,16 +46,21 @@ import { import type {} from '@deepseek-ai/dsh-agent-loop' import type {} from '@deepseek-ai/dsh-token-meter' import type {} from '@deepseek-ai/dsh-commands' -import { errorChain } from '@deepseek-ai/dsh-llm' +import { assertNever, BlockAssembler, errorChain } from '@deepseek-ai/dsh-llm' import type { ContentBlock, + GenerateOptions, LlmModelInfo, StreamChunk, TokenUsage, } from '@deepseek-ai/dsh-llm' import type {} from '@deepseek-ai/dsh-llm-retry' -import { SessionId, type Session, type SessionEvent, type TodoItem } from '@deepseek-ai/dsh-session' +import { SessionId, type Session, type SessionEvent, type SessionHeader, type TodoItem } from '@deepseek-ai/dsh-session' import { foldSessionTitle } from '@deepseek-ai/dsh-session-title' +// Side-effect type import: declaration-merges the optional `sessionPersistence` +// service onto `Context` so `ctx.get('sessionPersistence')` is typed. +import type {} from '@deepseek-ai/dsh-session-persistence' +import type { SkillDefinition, SkillResourceBase, SkillService } from '@deepseek-ai/dsh-skill' import type { FileDiff, TerminalCallView, @@ -94,8 +101,22 @@ export interface TuiConfig { showHardwareCursor?: boolean /** Apply the built-in ANSI color palette. */ color?: boolean + /** + * Paint the startup banner's product name in the DeepSeek brand gradient + * using 24-bit truecolor. Requires {@link TuiConfig.color}; falls back to the + * flat accent color when either is off. Unset auto-detects `COLORTERM` at the + * process boundary, so most deployments leave it unset. + */ + truecolor?: boolean /** Terminal window title while the UI is mounted. */ title?: string + /** + * Replace {@link TuiConfig.title} with a short model-generated title derived + * from the session's first user message; a resumed session re-derives it from + * that stored message on mount. No-op without an `llm` service or an agent + * provider/model. On by default. + */ + autoTitle?: boolean } const showReasoningSchema = z.boolean().default(true) @@ -108,7 +129,10 @@ const modelDialogWidthSchema = z.number().step(1).min(20).default(72) const modelDialogMaxHeightSchema = z.number().step(1).min(6).default(20) const showHardwareCursorSchema = z.boolean().default(false) const colorSchema = z.boolean().default(true) +// No default: an unset value auto-detects truecolor from COLORTERM in `apply`. +const truecolorSchema = z.boolean() const titleSchema = z.string().default('DeepSeek Harness') +const autoTitleSchema = z.boolean().default(true) /** Schemastery schema for presentation settings embedded by app bundles. */ export const TuiConfigSchema: z = z.object({ @@ -122,20 +146,31 @@ export const TuiConfigSchema: z = z.object({ modelDialogMaxHeight: modelDialogMaxHeightSchema, showHardwareCursor: showHardwareCursorSchema, color: colorSchema, + truecolor: truecolorSchema, title: titleSchema, + autoTitle: autoTitleSchema, }) /** Serializable plugin configuration. */ export interface Config extends TuiConfig { - /** Header subtitle. Defaults to `ready.`. */ + /** Banner subtitle line. When absent, the banner has no subtitle and sweeps in on start. */ welcome?: string /** Exact shared agent/session identity driven by this terminal. Defaults to `main`. */ sessionId?: string + /** + * Shell command template shown for resuming this session: printed on exit and + * listed by `/resume`, with every `{session}` occurrence replaced by the live + * session id. Absent disables both surfaces. Deployments set it only when a + * persistence backend makes the session resumable (e.g. + * `RESUME_SESSION_ID={session} dsh`). + */ + resumeCommand?: string } export const Config: z = z.object({ - welcome: z.string().default('ready.'), + welcome: z.string(), sessionId: z.string().default('main'), + resumeCommand: z.string(), showReasoning: showReasoningSchema, maxToolOutputLines: maxToolOutputLinesSchema, maxQuestionOptions: maxQuestionOptionsSchema, @@ -146,7 +181,9 @@ export const Config: z = z.object({ modelDialogMaxHeight: modelDialogMaxHeightSchema, showHardwareCursor: showHardwareCursorSchema, color: colorSchema, + truecolor: truecolorSchema, title: titleSchema, + autoTitle: autoTitleSchema, }) /** Fully defaulted TUI presentation settings. */ @@ -161,7 +198,9 @@ export interface ResolvedTuiConfig { modelDialogMaxHeight: number showHardwareCursor: boolean color: boolean + truecolor: boolean title: string + autoTitle: boolean } /** Runtime boundary used by the interactive TUI. */ @@ -198,7 +237,9 @@ export function resolveTuiConfig(config: TuiConfig | undefined): ResolvedTuiConf modelDialogMaxHeight: config?.modelDialogMaxHeight ?? 20, showHardwareCursor: config?.showHardwareCursor ?? false, color: config?.color ?? true, + truecolor: config?.truecolor ?? false, title: config?.title ?? 'DeepSeek Harness', + autoTitle: config?.autoTitle ?? true, } } @@ -269,6 +310,60 @@ function createPalette(enabled: boolean, scheme: TerminalColorScheme = 'dark'): } } +/** + * DeepSeek brand gradient stops (indigo → light blue) taken from the + * deepseek.com logo, painted across the startup banner's product name on + * truecolor terminals. Fixed brand identity, deliberately outside the + * theme-adaptive {@link Palette}. + */ +const BRAND_GRADIENT = [ + [77, 107, 254], // #4D6BFE + [57, 130, 255], // #3982FF + [36, 152, 255], // #2498FF +] as const + +/** + * Sample {@link BRAND_GRADIENT} at fraction `t` via piecewise-linear + * interpolation across its stops. + * + * @param t - Position along the gradient; clamped to [0, 1]. + * @returns The interpolated `[r, g, b]` channels, each rounded to 0–255. + */ +function brandColorAt(t: number): readonly [number, number, number] { + const span = Math.min(Math.max(t, 0), 1) * (BRAND_GRADIENT.length - 1) + const index = Math.min(Math.floor(span), BRAND_GRADIENT.length - 2) + const local = span - index + // `index` is clamped to a valid adjacent pair, so both lookups are in-bounds. + const from = BRAND_GRADIENT[index] as readonly [number, number, number] + const to = BRAND_GRADIENT[index + 1] as readonly [number, number, number] + return [ + Math.round(from[0] + (to[0] - from[0]) * local), + Math.round(from[1] + (to[1] - from[1]) * local), + Math.round(from[2] + (to[2] - from[2]) * local), + ] +} + +/** + * Paint `text` left-to-right in the DeepSeek brand gradient with per-character + * 24-bit foreground codes, resetting to the default foreground at the end. + * Foreground-only, so it stays legible on any terminal background; the caller + * gates it on truecolor support and wraps it in bold. + * + * @param text - Text to colorize; sampled once per character. + * @returns `text` wrapped in truecolor SGR foreground codes. + */ +function gradientText(text: string): string { + // The sole caller passes the ASCII product name, so UTF-16 unit iteration + // samples exactly one color per visible letter. + const last = Math.max(1, text.length - 1) + let painted = '' + for (let index = 0; index < text.length; index += 1) { + const [r, g, b] = brandColorAt(index / last) + painted += `\x1b[38;2;${r};${g};${b}m${text.charAt(index)}` + } + return `${painted}\x1b[39m` +} + function markdownTheme(palette: Palette): MarkdownTheme { return { heading: text => palette.accent(text), @@ -331,6 +426,24 @@ function contentText(content: readonly ContentBlock[]): string { return parts.join('') } +/** Longest auto-generated title kept before the tail is elided; fits common tmux/tab widths. */ +const AUTO_TITLE_MAX_LENGTH = 40 + +/** Task framing for the auto-title model call; written from the model's view, not the UI's. */ +const AUTO_TITLE_SYSTEM_PROMPT = [ + "Summarize the user's request as a short title of 2 to 5 lowercase words.", + 'Use no punctuation or quotation marks. Reply with only the title.', +].join('\n') + +/** First non-empty line of the model's reply, trimmed and capped for a terminal title. */ +function titleLine(text: string): string { + const line = text.split('\n').map(part => part.trim()).find(part => part.length > 0) ?? '' + return line.length > AUTO_TITLE_MAX_LENGTH ? `${line.slice(0, AUTO_TITLE_MAX_LENGTH - 1)}…` : line +} + +/** Auto-title is best-effort: a stream error or shutdown abort leaves the current title unchanged. */ +const ignoreTitleFailure = (): void => {} + function textBlocks(content: readonly ContentBlock[], type: 'text' | 'reasoning'): string { return content .filter((block): block is Extract => block.type === type) @@ -378,33 +491,125 @@ async function readModelChoices( return groups.flat() } +/** Milliseconds between banner sweep-reveal frames (~60 fps). */ +const BANNER_REVEAL_INTERVAL_MS = 15 + +/** Number of sweep frames the banner reveal spreads the terminal width over. */ +const BANNER_REVEAL_STEPS = 24 + +/** + * Borderless startup banner: product title, an optional configured subtitle, + * and the model/session detail line. No box frame — each line renders as plain + * left-padded text (matching transcript notices) so it reads on any theme. + */ class HeaderComponent implements Component { + /** Columns of the banner currently revealed; `undefined` renders it whole. */ + private revealWidth: number | undefined + constructor( private readonly agent: Agent, - private readonly subtitle: () => string, + private readonly welcome: string | undefined, private readonly palette: Palette, + private readonly gradient: boolean, private readonly currentModel: () => string | undefined, ) {} + /** Clip the banner to `width` columns (the sweep reveal); `undefined` restores it. */ + setRevealWidth(width: number | undefined): void { + this.revealWidth = width + } + invalidate(): void {} render(width: number): string[] { - const usable = Math.max(1, width - 4) - const title = `${this.palette.bold(this.palette.accent('DEEPSEEK'))} ${this.palette.bold('HARNESS')}` + const usable = Math.max(1, width - 2) + const name = this.gradient + ? this.palette.bold(gradientText('DEEPSEEK')) + : this.palette.bold(this.palette.accent('DEEPSEEK')) + const title = `${name} ${this.palette.bold('HARNESS')}` const model = displayText(this.currentModel() ?? 'model unset') const detail = `${model} • ${displayText(this.agent.session.id)}` - const top = this.palette.accent(`╭${'─'.repeat(Math.max(0, width - 2))}╮`) - const bottom = this.palette.accent(`╰${'─'.repeat(Math.max(0, width - 2))}╯`) - const lines = [title, this.palette.muted(displayText(this.subtitle())), this.palette.dim(detail)] + const lines = [ + title, + ...this.welcome === undefined ? [] : [this.palette.muted(displayText(this.welcome))], + this.palette.dim(detail), + ] .flatMap(line => wrapTextWithAnsi(line, usable)) - .map((line) => { - const clipped = truncateToWidth(line, usable, '') - return `${this.palette.accent('│')} ${clipped}${' '.repeat(Math.max(0, usable - visibleWidth(clipped)))} ${this.palette.accent('│')}` - }) - return [top, ...lines, bottom] + .map(line => ` ${truncateToWidth(line, usable, '')}`) + if (this.revealWidth === undefined) return lines + const revealed = this.revealWidth + return lines.map(line => truncateToWidth(line, revealed, '')) } } +/** Milliseconds between elapsed-time refreshes of the running status line. */ +const STATUS_ELAPSED_INTERVAL_MS = 1000 + +/** Steering/cancel affordance shown on every running status line. */ +const STATUS_HINT = 'Enter sends steering, Esc cancels' + +/** + * Fine-grained activity of a running turn, derived in the TUI from session + * lifecycle events for the status line. It is presentation-only, not a durable + * agent state: `waiting` spans a step from its `step/start` until the first + * reasoning or text chunk, `thinking`/`responding` track reasoning/text deltas, + * and `executing` covers tool calls until the next step begins. + */ +type TurnPhase = 'waiting' | 'thinking' | 'responding' | 'executing' + +/** + * Live controller for the running status line: its {@link Loader}, the derived + * {@link TurnPhase}, the elapsed-time baselines the label reads, and the timer + * that refreshes it. Present only while the turn runs; `undefined` when idle. + */ +interface RunningStatus { + loader: Loader + phase: TurnPhase + phaseStartedAt: number + stepStartedAt: number + timer: ReturnType +} + +/** Status-line label for each {@link TurnPhase}. */ +const TURN_PHASE_LABELS: Record = { + waiting: 'Waiting for the first token', + thinking: 'Thinking', + responding: 'Responding', + executing: 'Executing tools', +} + +/** + * Format a non-negative elapsed span as a compact status duration: whole + * seconds under a minute (`8s`), else minutes and zero-padded seconds + * (`1m05s`). + * @param elapsedMs - Elapsed time in milliseconds; negatives clamp to zero. + * @returns The compact duration string. + */ +function formatStatusDuration(elapsedMs: number): string { + const total = Math.floor(Math.max(0, elapsedMs) / 1000) + if (total < 60) return `${total}s` + return `${Math.floor(total / 60)}m${(total % 60).toString().padStart(2, '0')}s` +} + +/** + * Compose the running status-line text from the current phase, its timers, and + * the queued-steering badge. The waiting phase spans the whole step so it shows + * one duration; later phases show time in the phase plus the running step + * total, and a non-zero `queued` count surfaces as a badge before the hint. + * @param phase - The current turn phase. + * @param phaseMs - Elapsed time in the current phase, in milliseconds. + * @param stepMs - Elapsed time in the current step, in milliseconds. + * @param queued - Count of pending steering messages; zero hides the badge. + * @returns The status-line text, including the steering/cancel hint. + */ +function formatTurnStatus(phase: TurnPhase, phaseMs: number, stepMs: number, queued: number): string { + const timing = phase === 'waiting' + ? formatStatusDuration(stepMs) + : `${formatStatusDuration(phaseMs)} · total ${formatStatusDuration(stepMs)}` + const badge = queued > 0 ? `${queued} queued · ` : '' + return `${TURN_PHASE_LABELS[phase]} ${timing} — ${badge}${STATUS_HINT}` +} + /** * Groups children behind a colored left-gutter bar (`▌`). Foreground-only, so * it renders legibly on any terminal background — unlike a filled block whose @@ -709,9 +914,16 @@ function formatCwd(cwd: string | undefined): string { return cwd } +/** + * Running token totals for the footer, keyed per turn/step so replayed or + * re-emitted usage replaces rather than double-counts; `input` is uncached + * input, cache buckets are disjoint. + */ interface SessionTokenTotals { input: number output: number + cacheRead: number + cacheWrite: number readonly byStep: Map } @@ -721,10 +933,14 @@ function recordTokenUsage(totals: SessionTokenTotals, turn: number, step: number if (previous !== undefined) { totals.input -= previous.inputTokens totals.output -= previous.outputTokens + totals.cacheRead -= previous.cacheReadTokens ?? 0 + totals.cacheWrite -= previous.cacheWriteTokens ?? 0 } totals.byStep.set(key, usage) totals.input += usage.inputTokens totals.output += usage.outputTokens + totals.cacheRead += usage.cacheReadTokens ?? 0 + totals.cacheWrite += usage.cacheWriteTokens ?? 0 } function recordEventUsage(totals: SessionTokenTotals, event: SessionEvent): void { @@ -735,8 +951,19 @@ function recordEventUsage(totals: SessionTokenTotals, event: SessionEvent): void } } +/** + * Share of billed input (prompt) tokens served from the provider cache, as an + * integer percent, or `undefined` before any input is billed (avoids 0/0 and a + * meaningless rate on an empty session). + */ +function cacheHitRate(totals: SessionTokenTotals): number | undefined { + const billedInput = totals.input + totals.cacheRead + totals.cacheWrite + if (billedInput === 0) return undefined + return Math.round((totals.cacheRead / billedInput) * 100) +} + function sessionTokens(session: Session): SessionTokenTotals { - const totals: SessionTokenTotals = { input: 0, output: 0, byStep: new Map() } + const totals: SessionTokenTotals = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, byStep: new Map() } for (const event of session.events) { recordEventUsage(totals, event) } @@ -748,47 +975,31 @@ class FooterComponent implements Component { private readonly agent: Agent, private readonly palette: Palette, private readonly toolsExpanded: () => boolean, - private readonly showReasoning: () => boolean, - private readonly tokens: () => { input: number; output: number }, + private readonly tokens: () => SessionTokenTotals, private readonly cwdFormatter: TuiRuntime['formatCwd'], private readonly currentModel: () => string | undefined, private readonly contextPercent: () => number | undefined, - private readonly runningSeconds: () => number, ) {} invalidate(): void {} render(width: number): string[] { - if (this.agent.status === 'running') { - const interrupt = this.palette.dim('esc interrupt') - const activityAvailable = Math.max(0, width - visibleWidth(interrupt) - 1) - const activity = truncateToWidth(this.palette.accent(`◒ Working · ${this.runningSeconds()}s`), activityAvailable, '') - const gap = ' '.repeat(Math.max(0, width - visibleWidth(activity) - visibleWidth(interrupt))) - return [`${activity}${gap}${interrupt}`] - } - const { input, output } = this.tokens() - const counters = `↑${formatTokens(input)} ↓${formatTokens(output)}` + const totals = this.tokens() const model = displayText(this.currentModel() ?? 'model unset') - const modelState = `${model}(reasoning:${this.showReasoning() ? 'on' : 'off'})` - const contextPercent = this.contextPercent() - const context = contextPercent === undefined ? 'context unknown' : `${contextPercent}% context` - const fullRight = `${context} tools:${this.toolsExpanded() ? 'expanded' : 'compact'} ${modelState}` - const compactRight = `${context} ${modelState}` + const rate = cacheHitRate(totals) + const cache = rate === undefined ? '' : ` cache ${rate}%` const formattedCwd = displayText( this.cwdFormatter?.(this.agent.session.header.cwd) ?? formatCwd(this.agent.session.header.cwd), ) - if (visibleWidth(counters) + visibleWidth(compactRight) + 1 > width) { - const compact = truncateToWidth(compactRight, width, '') - return [`${' '.repeat(Math.max(0, width - visibleWidth(compact)))}${this.palette.dim(compact)}`] - } - const rightAvailable = width - visibleWidth(counters) - 1 - const right = visibleWidth(fullRight) <= rightAvailable ? fullRight : compactRight - const rightClipped = truncateToWidth(right, rightAvailable, '') - const cwdAvailable = Math.max(0, width - visibleWidth(counters) - visibleWidth(rightClipped) - 3) - const cwd = truncateToWidth(formattedCwd, cwdAvailable, '') - const left = [cwd, counters].filter(Boolean).join(' ') - const gap = ' '.repeat(Math.max(0, width - visibleWidth(left) - visibleWidth(rightClipped))) - return [`${this.palette.dim(left)}${gap}${this.palette.dim(rightClipped)}`] + const left = `${model} ${formattedCwd} ↑${formatTokens(totals.input)} ↓${formatTokens(totals.output)}${cache}` + const contextPercent = this.contextPercent() + const context = contextPercent === undefined ? '' : `${contextPercent}% context ` + const right = `${context}tools:${this.toolsExpanded() ? 'expanded' : 'collapsed'}` + const leftStyled = this.palette.dim(left) + const available = Math.max(0, width - visibleWidth(left) - 2) + const rightClipped = truncateToWidth(right, available, '') + const gap = ' '.repeat(Math.max(1, width - visibleWidth(left) - visibleWidth(rightClipped))) + return [truncateToWidth(`${leftStyled}${gap}${this.palette.dim(rightClipped)}`, width, '')] } } @@ -1020,6 +1231,65 @@ export interface TuiController { dispose(): Promise } +/** Prefix that marks an editor submission as a manual skill invocation. */ +const SKILL_COMMAND_PREFIX = '/skill:' + +/** Parsed `/skill: [instructions]` submission; `name` is empty when the prefix carries no name. */ +interface ParsedSkillCommand { + /** Skill name typed after `/skill:`, up to the first space. */ + name: string + /** Trimmed text after the name; empty when none was typed. */ + instructions: string +} + +/** + * Split a `/skill: [instructions]` submission into its name and trailing instructions. + * @param text - trimmed submission that starts with {@link SKILL_COMMAND_PREFIX}. + * @returns the skill name and any trailing instructions. + */ +function parseSkillCommand(text: string): ParsedSkillCommand { + const rest = text.slice(SKILL_COMMAND_PREFIX.length) + const spaceIndex = rest.indexOf(' ') + if (spaceIndex === -1) return { name: rest, instructions: '' } + return { name: rest.slice(0, spaceIndex), instructions: rest.slice(spaceIndex + 1).trim() } +} + +/** Model-visible line locating a manually invoked skill's relative resources, or `undefined` when the provider has no base. */ +function skillResourceReference(base: SkillResourceBase | undefined): string | undefined { + if (base === undefined) return undefined + switch (base.kind) { + case 'directory': + return `References in this skill are relative to ${base.path}.` + case 'url': + return `References in this skill are relative to ${base.url}.` + case 'opaque': + return base.description + default: + return assertNever(base, 'SkillResourceBase.kind') + } +} + +/** + * Render a manually invoked skill into the model-visible user-message text. The + * `` block carries the body and, when the provider supplies one, its + * resource base; the trimmed `instructions` follow the block as the user's + * request for this turn. The name is registry-validated kebab-case + * ({@link SkillService} rejects any other) and the resource base is trusted + * same-process provider prose, so — unlike the model-facing `dsh-tool-skill` + * result, which escapes for a tool channel — this user turn is assembled raw. + * @param skill - the loaded skill definition. + * @param instructions - trimmed text typed after `/skill:`; empty when absent. + * @returns the user-message text delivered to the agent. + */ +export function renderSkillInvocation(skill: SkillDefinition, instructions: string): string { + const lines = [``] + const reference = skillResourceReference(skill.resourceBase) + if (reference !== undefined) lines.push(reference, '') + lines.push(skill.content, '') + const block = lines.join('\n') + return instructions === '' ? block : `${block}\n\n${instructions}` +} + function activeSurfaceSeqs(session: Session): Set { return new Set(session.surface.nodes) } @@ -1050,12 +1320,14 @@ export function createTuiChat( const sessionId = SessionId(config.sessionId ?? 'main') const agent = ctx.agents.get(sessionId) if (agent === undefined) throw new Error(`ui-tui: session "${sessionId}" is not running`) + const persistence = ctx.get('sessionPersistence') const resolved = resolveTuiConfig(config) const palette = createPalette(resolved.color) const mdTheme = markdownTheme(palette) const ui = new TUI(runtime.terminal, resolved.showHardwareCursor) const chat = new Container() const todoContainer = new Container() + const statusContainer = new Container() const editor = new Editor(ui, { borderColor: palette.dim, selectList: selectTheme(palette), @@ -1064,10 +1336,28 @@ export function createTuiChat( let showReasoning = resolved.showReasoning let toolsExpanded = false let streaming: StreamingAssistantComponent | undefined - let runningStartedAt: number | undefined - let statusTicker: ReturnType | undefined + let runningStatus: RunningStatus | undefined + // Steering messages the user queued during the running turn that the loop has + // not yet drained, shown as a badge on the status line. Reset on the + // running→idle status transition, which also absorbs a cancellation that + // clears the queue without logging drains; the status line exists only while + // running, so idle carries no badge to keep current. + let pendingSteering = 0 let disposed = false let shuttingDown: Promise | undefined + // Optional: skills mount conditionally, so read the global service store + // rather than declaring an injection that would make the TUI require them. + const skills = ctx.get('skills') + const cwd = agent.session.header.cwd ?? process.cwd() + const skillAbort = new AbortController() + // Auto-title replaces the static title with a short model-generated title + // derived from the session's first user message. A resumed session re-derives + // it from that stored message on mount (see below); a fresh session derives it + // when the first message arrives. It is already settled — keeping the static + // title — only when the feature is off. The abort cancels an in-flight title + // stream at shutdown. + const titleAbort = new AbortController() + let titleSettled = !resolved.autoTitle const tokens = sessionTokens(agent.session) const toolCards = new Map() const allToolCards = new Set() @@ -1085,24 +1375,30 @@ export function createTuiChat( let modelCommands = Promise.resolve() const now = (): number => runtime.now?.() ?? Date.now() - const welcome = config.welcome ?? 'ready.' + // A configured subtitle renders as a banner line; when absent, the banner has + // no subtitle. The banner itself sweeps in on start (see startBannerReveal). let sessionTitle = foldSessionTitle(agent.session.events)?.title - const header = new HeaderComponent(agent, () => sessionTitle ?? welcome, palette, () => target.current?.model) + const header = new HeaderComponent( + agent, + config.welcome, + palette, + resolved.color && resolved.truecolor, + () => target.current?.model, + ) const footer = new FooterComponent( agent, palette, () => toolsExpanded, - () => showReasoning, () => tokens, runtime.formatCwd, () => target.current?.model, () => contextWindow === undefined ? undefined : Math.min(100, Math.round(ctx.tokenMeter.measure(agent.session).totalTokens / contextWindow * 100)), - () => runningStartedAt === undefined ? 0 : Math.max(0, Math.floor((now() - runningStartedAt) / 1_000)), ) ui.addChild(header) ui.addChild(chat) + ui.addChild(statusContainer) todoContainer.addChild(todo) ui.addChild(todoContainer) ui.addChild(editor) @@ -1237,25 +1533,134 @@ export function createTuiChat( }) } + // Fire-and-forget a title request. The prompt is the trimmed first-message + // text; an empty one is skipped without consuming the one-shot slot. The `llm` + // service is optional, so a deployment without it (or without an agent + // provider/model) silently keeps the static title. + const generateTitle = (prompt: string): void => { + if (titleSettled || prompt.length === 0) return + titleSettled = true + const llm = ctx.get('llm') + const { provider, model } = agent.options + if (llm === undefined || !provider || !model) return + const options: GenerateOptions = { + provider, + model, + system: AUTO_TITLE_SYSTEM_PROMPT, + messages: [{ role: 'user', content: [{ type: 'text', text: prompt }] }], + sessionId: agent.session.id, + signal: titleAbort.signal, + } + const applyTitle = async (): Promise => { + const assembler = new BlockAssembler() + for await (const chunk of llm.stream(options)) assembler.push(chunk) + const title = titleLine(contentText(assembler.message().content)) + if (!disposed && title.length > 0) { + sessionTitle = title + header.invalidate() + updateTerminalTitle() + requestRender() + } + } + void applyTitle().catch(ignoreTitleFailure) + } + + // Resume: derive the title from the session's already-logged first user + // message. A fresh session has none here and titles from the live message via + // the session-event listener instead. + const firstUserMessage = agent.session.events.find( + (event): event is Extract => event.type === 'user/message', + ) + if (firstUserMessage !== undefined) generateTitle(contentText(firstUserMessage.data.content).trim()) + const clearStatus = (): void => { - if (statusTicker !== undefined) clearInterval(statusTicker) - statusTicker = undefined - runningStartedAt = undefined + if (runningStatus !== undefined) { + clearInterval(runningStatus.timer) + runningStatus.loader.stop() + runningStatus = undefined + } + statusContainer.clear() runtime.terminal.setProgress(false) } + // Refresh the status line's elapsed timers and queued badge from the + // controller's phase and the current steering count. + const renderStatus = (running: RunningStatus): void => { + const at = now() + running.loader.setMessage(formatTurnStatus(running.phase, at - running.phaseStartedAt, at - running.stepStartedAt, pendingSteering)) + } + + // Move to a derived phase, resetting the phase timer on a genuine change and + // the step timer when a new step begins; ignored unless a turn is running. + const enterPhase = (phase: TurnPhase, resetStep: boolean): void => { + const running = runningStatus + if (running === undefined) return + const at = now() + if (resetStep) running.stepStartedAt = at + if (phase !== running.phase || resetStep) running.phaseStartedAt = at + running.phase = phase + renderStatus(running) + } + const setStatus = (status: AgentStatus): void => { + // A running→running rebuild (a mid-turn palette swap re-derives the border) + // carries the derived phase and both elapsed baselines across; only a fresh + // idle→running turn starts at `waiting`. + const prior = runningStatus clearStatus() editor.borderColor = status === 'running' ? text => palette.accent(text) : text => palette.dim(text) if (status === 'running') { - runningStartedAt = now() - statusTicker = setInterval(requestRender, 1_000) - statusTicker.unref() + const at = now() + const phase = prior?.phase ?? 'waiting' + const phaseStartedAt = prior?.phaseStartedAt ?? at + const stepStartedAt = prior?.stepStartedAt ?? at + const message = formatTurnStatus(phase, at - phaseStartedAt, at - stepStartedAt, pendingSteering) + const loader = new Loader(ui, text => palette.accent(text), text => palette.muted(text), message) + statusContainer.addChild(loader) + const running: RunningStatus = { + loader, + phase, + phaseStartedAt, + stepStartedAt, + timer: setInterval(() => { renderStatus(running) }, STATUS_ELAPSED_INTERVAL_MS), + } + runningStatus = running runtime.terminal.setProgress(true) } requestRender() } + // Refresh the running status line's queued-steering badge from the current + // count; a no-op when idle because the controller only exists while running. + const refreshStatus = (): void => { + if (runningStatus !== undefined) renderStatus(runningStatus) + requestRender() + } + + // Derive the status-line phase from live session lifecycle events. The event + // map is merge-extensible, so unhandled types fall through the default. + const advanceTurnPhase = (event: SessionEvent): void => { + switch (event.type) { + case 'step/start': + enterPhase('waiting', true) + break + case 'assistant/chunk': { + const chunk = event.data.chunk + if (chunk.type === 'reasoning-delta' || (chunk.type === 'block-start' && chunk.blockType === 'reasoning')) { + enterPhase('thinking', false) + } else if (chunk.type === 'text-delta' || (chunk.type === 'block-start' && chunk.blockType === 'text')) { + enterPhase('responding', false) + } + break + } + case 'tool/call': + enterPhase('executing', false) + break + default: + break + } + } + const parsedTool = (event: Extract): ToolCardComponent => { const parsed = parseArguments(event.data.arguments) const card = new ToolCardComponent( @@ -1489,10 +1894,43 @@ export function createTuiChat( }, }) + /** + * Persisted sessions for this workspace, newest first. Empty when no + * persistence backend is mounted or a listing failure would otherwise block + * exit or crash `/resume`; the resume hint is best-effort convenience. + */ + const listWorkspaceSessions = async (): Promise => { + if (persistence === undefined) return [] + let all: readonly SessionHeader[] + try { + all = await persistence.list() + } catch { + // A listing failure must never block terminal exit or crash `/resume`. + return [] + } + return all + .filter(header => header.cwd === agent.session.header.cwd) + .sort((a, b) => b.createdAt - a.createdAt) + } + + /** + * The resume command for the current session — the configured template with + * every `{session}` filled — but only once the session is durably persisted, + * so a session abandoned before its first flush yields no hint (resuming that + * id would fail to load). + */ + const currentResumeCommand = async (): Promise => { + if (config.resumeCommand === undefined) return undefined + const sessions = await listWorkspaceSessions() + if (!sessions.some(header => header.id === agent.session.id)) return undefined + return config.resumeCommand.replaceAll('{session}', agent.session.id) + } + const shutdown = (exitProcess: boolean): Promise => { shuttingDown ??= (async () => { disposed = true contextResolution = undefined + titleAbort.abort() clearStatus() modelOverlay?.hide() modelOverlay = undefined @@ -1507,7 +1945,13 @@ export function createTuiChat( disposeUserInteraction() await runtime.terminal.drainInput(100, 20) ui.stop() - if (exitProcess) runtime.exit(0) + if (exitProcess) { + const command = await currentResumeCommand() + if (command !== undefined) { + runtime.terminal.write(`${palette.muted('To resume this session:')} ${displayText(command)}\n`) + } + runtime.exit(0) + } })() return shuttingDown } @@ -1528,6 +1972,7 @@ export function createTuiChat( currentScheme = scheme Object.assign(palette, createPalette(resolved.color, scheme)) Object.assign(mdTheme, markdownTheme(palette)) + // `setStatus` below re-derives `editor.borderColor` from the new palette. rebuildTranscript(false) setStatus(agent.status) requestRender() @@ -1577,22 +2022,51 @@ export function createTuiChat( 'Ctrl+C cancel while running; clear input or exit while idle • Ctrl+D exit', '', ...commandLines, + '/skill: [instructions] — load a skill into the conversation', ].map(line => palette.muted(line)).join('\n'), 1, 0)) requestRender() } + // Skill listing is async while `createTuiChat` is synchronous, so the + // completions rebuild once the catalog resolves. Disabled-for-model skills + // are absent from `list()`, so they never appear as completions; a user can + // still invoke one by typing its exact name. + let skillCommands: SlashCommand[] = [] const refreshCommandAutocomplete = (): void => { editor.setAutocompleteProvider(new CombinedAutocompleteProvider( - ctx.commands.list(agent).map(command => ({ - name: command.name, - description: command.description, - })), + [ + ...ctx.commands.list(agent).map(command => ({ + name: command.name, + description: command.description, + })), + ...skillCommands, + ], agent.session.header.cwd ?? process.cwd(), )) } const disposeCommandChanges = ctx.on('commands/change', refreshCommandAutocomplete) refreshCommandAutocomplete() + const loadSkillCommands = (service: SkillService): void => { + service.list({ cwd, signal: skillAbort.signal }).then( + (summaries) => { + if (disposed || summaries.length === 0) return + skillCommands = summaries.map(skill => ({ + name: `skill:${skill.name}`, + description: skill.description, + argumentHint: '[instructions]', + })) + refreshCommandAutocomplete() + requestRender() + }, + () => { + // Discovery failed or was aborted on dispose; keep the base slash + // commands so autocomplete still works without skill entries. + }, + ) + } + if (skills !== undefined) loadSkillCommands(skills) + // The agent scope is minted by agent-loop and intentionally inherits only // that core plugin's dependencies. A child command producer declares its own // UI-service dependency while retaining the parent agent scope and lifetime. @@ -1616,15 +2090,6 @@ export function createTuiChat( description: 'Clear the transcript view (session history is unchanged)', handler: () => { chat.clear(); requestRender(); return { kind: 'success' } }, }) - commandCtx.commands.register({ - name: 'cancel', - description: 'Cancel the active turn', - handler: () => { - if (agent.status !== 'running') return { kind: 'error', text: 'The agent is already idle.' } - agent.cancel({ kind: 'user' }) - return { kind: 'success', text: 'Cancellation requested.' } - }, - }) commandCtx.commands.register({ name: 'reasoning', description: 'Toggle reasoning blocks', @@ -1640,6 +2105,16 @@ export function createTuiChat( description: 'Invalidate components and redraw the terminal', handler: () => { ui.invalidate(); ui.requestRender(true); return { kind: 'success' } }, }) + commandCtx.commands.register({ + name: 'reload', + description: 'EXPERIMENTAL (dev): re-read loader config files and apply the diff (idle only)', + handler: () => { runReload(); return { kind: 'success' } }, + }) + commandCtx.commands.register({ + name: 'resume', + description: 'List this workspace\'s resumable sessions', + handler: () => { showResume(); return { kind: 'success' } }, + }) commandCtx.commands.register({ name: 'exit', description: 'Exit after the active turn reaches idle', @@ -1667,22 +2142,140 @@ export function createTuiChat( ).finally(() => { commandControllers.delete(controller) }) } + /** Deliver a user turn to the agent: steer while running, send while idle, or report a disposed agent. */ + const deliver = (payload: string): void => { + if (agent.status === 'disposed') { + appendNotice(`Agent "${agent.id}" is disposed.`, 'error') + } else if (agent.status === 'running') { + agent.steer([{ type: 'text', text: payload }]) + } else { + agent.send([{ type: 'text', text: payload }]) + } + } + + /** Load a manually invoked skill and deliver its rendered body as a user turn, reporting lookup outcomes as notices. */ + const invokeSkill = (name: string, instructions: string): void => { + if (skills === undefined) { + appendNotice('Skills are not available in this session.', 'warning') + return + } + skills.get(name, { cwd, signal: skillAbort.signal }).then( + (skill) => { + if (disposed) return + if (skill === undefined) { + appendNotice(`Unknown skill: ${name}`, 'warning') + return + } + deliver(renderSkillInvocation(skill, instructions)) + }, + (error: unknown) => { + if (disposed) return + appendNotice(`Skill "${name}" failed to load: ${errorChain(error)}`, 'error') + }, + ) + } + + // EXPERIMENTAL, dev-only: manually re-read every file-backed loader config + // tree and apply the diff to the running app — the same path the HMR + // watcher's config-change branch drives, minus the watcher. Useful when the + // watcher misses an edit (replace-by-rename saves) or HMR is not mounted. + // Module-source hot reload stays watcher-owned; this refreshes configs only. + let reloadInFlight = false + const runReload = (): void => { + // Idle-only: a reload can dispose and re-mount entries mid-flight; doing + // that under an active turn could tear tools or the adapter out from + // under in-flight calls. Idleness is advisory (a send can race in after + // the check), but it removes the common footgun. + if (agent.status !== 'idle') { + appendNotice(`/reload requires an idle agent (status: ${agent.status}).`, 'warning') + return + } + // Re-entrancy guard: concurrent refreshes over a genuinely changed file + // would race unmutexed tree updates (create/remove interleaving); one + // reload at a time keeps the update pass single-writer. + if (reloadInFlight) { + appendNotice('A config reload is already running.', 'warning') + return + } + + // Optional-service lookup: the TUI must not depend on the Loader (tests + // and embedders run without one), so `loader` stays out of `inject` and + // is read through the non-throwing `ctx.get` accessor — a bare `ctx.loader` + // proxy read would throw `cannot get property without inject` in a fiber. + const loader = ctx.get('loader') as { entries(): Iterable<{ subtree?: { refresh?(): Promise } }> } | undefined + if (loader === undefined) { + appendNotice('/reload needs the cordis Loader; this runtime has none.', 'warning') + return + } + const refreshes: Promise[] = [] + for (const entry of loader.entries()) { + if (entry.subtree?.refresh !== undefined) refreshes.push(entry.subtree.refresh()) + } + reloadInFlight = true + appendNotice(`Reloading ${refreshes.length} config tree(s)… (experimental)`) + // refresh() never rejects (it warns and keeps the running tree), so the + // join can only fulfill; the catch arm guards a future contract change. + void Promise.all(refreshes).then(() => { + appendNotice('Config reload complete. Unchanged files were skipped; invalid files keep the running tree (see logs).') + }).catch((error: unknown) => { + appendNotice(`Config reload failed: ${errorChain(error)}`, 'error') + }).finally(() => { + reloadInFlight = false + }) + } + + /** + * List this workspace's resumable sessions, newest first, each with its + * resume command and a marker on the current one. Warns when resume is not + * configured or no persistence backend is mounted; notes when nothing is + * persisted yet. The listing is asynchronous (a persistence scan), so the + * transcript updates once it resolves. + */ + const showResume = (): void => { + const template = config.resumeCommand + if (template === undefined) { + appendNotice('Resume is not configured for this app.', 'warning') + return + } + if (persistence === undefined) { + appendNotice('Resume is not available: no persistence backend is mounted.', 'warning') + return + } + void listWorkspaceSessions().then((sessions) => { + if (sessions.length === 0) { + appendNotice('No resumable sessions found for this workspace yet.', 'info') + return + } + chat.addChild(new Spacer(1)) + chat.addChild(new Text(palette.bold(palette.accent('Resumable sessions')), 1, 0)) + const lines = sessions.map((header) => { + const when = new Date(header.createdAt).toISOString().slice(0, 16).replace('T', ' ') + const marker = header.id === agent.session.id ? palette.success(' (current)') : '' + return `${palette.muted(when)}${marker}\n ${displayText(template.replaceAll('{session}', header.id))}` + }) + chat.addChild(new Text(lines.join('\n'), 1, 0)) + requestRender() + }) + } + editor.onSubmit = (value: string) => { const text = value.trim() if (text === '') return editor.addToHistory(text) editor.setText('') + // `/skill:` carries a colon, which the command registry's name + // grammar rejects, so it is intercepted before generic command routing. + if (text.startsWith(SKILL_COMMAND_PREFIX)) { + const { name, instructions } = parseSkillCommand(text) + if (name === '') appendNotice('Usage: /skill: [instructions]', 'warning') + else invokeSkill(name, instructions) + return + } if (value.startsWith('/')) { runCommand(value) return } - if (agent.status === 'disposed') { - appendNotice(`Agent "${agent.id}" is disposed.`, 'error') - } else if (agent.status === 'running') { - agent.steer([{ type: 'text', text }]) - } else { - agent.send([{ type: 'text', text }]) - } + deliver(text) } const removeInputListener = ui.addInputListener((data) => { @@ -1725,6 +2318,15 @@ export function createTuiChat( const disposeSessionEvents = ctx.on('session/event', (session, event) => { if (session !== agent.session) return recordEventUsage(tokens, event) + advanceTurnPhase(event) + if (event.type === 'user/message') generateTitle(contentText(event.data.content).trim()) + if (event.type === 'steering/message' && pendingSteering > 0) { + // A queued steering message reached the model as it drained; drop it from + // the badge. Clamped because loop-authored steering (e.g. continuation + // reasons) also logs here without a matching user-queued increment. + pendingSteering -= 1 + refreshStatus() + } if ('surfaceOp' in event && typeof event.surfaceOp === 'object') { rebuildTranscript(false) return @@ -1732,8 +2334,17 @@ export function createTuiChat( renderEvent(event, { addHistory: false, renderChunks: true }) requestRender() }) + const disposeQueued = ctx.on('agent/queued', (subject, _content, info) => { + if (subject !== agent || !info.steering) return + pendingSteering += 1 + refreshStatus() + }) const disposeStatus = ctx.on('agent/status', (subject, status) => { if (subject !== agent) return + // Leaving 'running' ends the turn's status line; clear any badge so the + // next running turn starts from zero (and a cancellation, which discards + // the queue without logging drains, cannot strand a stale count). + if (status !== 'running') pendingSteering = 0 setStatus(status) }) const disposeError = ctx.on('agent/error', (subject, turn, step, error) => { @@ -1750,9 +2361,12 @@ export function createTuiChat( }) const detachListeners = (): void => { + skillAbort.abort() removeInputListener() disposeCommandChanges() + stopBannerReveal() disposeSessionEvents() + disposeQueued() disposeStatus() disposeError() disposeAgent() @@ -1760,6 +2374,34 @@ export function createTuiChat( disposeTargetListeners() } + // Sweep reveal of the whole banner: the header wipes in left-to-right over + // ~BANNER_REVEAL_STEPS frames (started after `ui.start()` succeeds). + // Configured subtitles skip it so deployments (and snapshot fixtures) stay + // frame-deterministic. + let revealTimer: ReturnType | undefined + const stopBannerReveal = (): void => { + if (revealTimer === undefined) return + clearInterval(revealTimer) + revealTimer = undefined + header.setRevealWidth(undefined) + } + const startBannerReveal = (): void => { + if (config.welcome !== undefined) return + const total = Math.max(1, runtime.terminal.columns) + const step = Math.max(1, Math.ceil(total / BANNER_REVEAL_STEPS)) + let shown = 0 + header.setRevealWidth(0) + revealTimer = setInterval(() => { + shown += step + if (shown >= total) { + stopBannerReveal() + } else { + header.setRevealWidth(shown) + } + requestRender() + }, BANNER_REVEAL_INTERVAL_MS) + } + rebuildTranscript(true) setStatus(agent.status) try { @@ -1778,6 +2420,7 @@ export function createTuiChat( ui.stop() throw error } + startBannerReveal() return { async dispose(): Promise { @@ -1833,9 +2476,12 @@ export function mountTui(ctx: Context, config: Config, runtime: TuiRuntime): voi and the tui-agent PTY smoke covers the real entry */ export function apply(ctx: Context, config: Config): void { if (!process.stdin.isTTY || !process.stdout.isTTY) { - throw new Error('ui-tui: both stdin and stdout must be TTYs; use @deepseek-ai/dsh-cli-demo for non-interactive runs') + throw new Error('ui-tui: both stdin and stdout must be TTYs; use the one-shot @deepseek-ai/dsh-cli-demo app for pipes') } - mountTui(ctx, config, { + // Truecolor is a terminal capability, so detect it here at the process + // boundary from COLORTERM; an explicit `truecolor` config value still wins. + const truecolor = config.truecolor ?? ['truecolor', '24bit'].includes(process.env.COLORTERM ?? '') + mountTui(ctx, Object.assign({}, config, { truecolor }), { terminal: new ProcessTerminal(), exit: code => process.exit(code), }) diff --git a/packages/ui/tui/tests/tui.spec.ts b/packages/ui/tui/tests/tui.spec.ts index fc4f92dfad..3575724552 100644 --- a/packages/ui/tui/tests/tui.spec.ts +++ b/packages/ui/tui/tests/tui.spec.ts @@ -1335,7 +1335,7 @@ describe('terminal mounting', () => { expect(terminal.output).toBe('') expect(exit).not.toHaveBeenCalled() ctx.emit('agent-loop/config-start-failed', SessionId('main-session'), new Error('resume \u001b]2;failure-controlled\u0007')) - expect(terminal.output).toBe('ui-tui: session "main-session" failed to start: resume \\x1b]2;failure-controlled\\x07\n') + expect(terminal.output).toBe('ui-tui: session "main-session" failed to start: Error: resume \\x1b]2;failure-controlled\\x07\n') expect(exit).toHaveBeenCalledWith(1) const session = ctx.sessions.create(SessionId('main-session')) @@ -1365,7 +1365,7 @@ describe('terminal mounting', () => { }) expect(terminal.started).toBe(0) - expect(terminal.output).toBe('ui-tui: session "main-session" failed to start: \n') + expect(terminal.output).toBe('ui-tui: session "main-session" failed to start: \n') expect(exit).toHaveBeenCalledWith(1) await ctx.fiber.dispose() }) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e4b786c053..95373d7396 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -90,6 +90,15 @@ importers: specifier: ^4.1.8 version: 4.1.8(@types/node@22.20.0)(@vitest/coverage-v8@4.1.8)(jsdom@29.1.1)(vite@8.0.16(@types/node@22.20.0)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) + apps/cli: + dependencies: + '@deepseek-ai/dsh-app-boot': + specifier: workspace:^ + version: link:../../packages/ui/app-boot + '@deepseek-ai/dsh-paths': + specifier: workspace:^ + version: link:../../packages/util/paths + examples: dependencies: '@cordisjs/plugin-hmr': @@ -2700,6 +2709,10 @@ importers: version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) packages/ui/app-boot: + dependencies: + js-yaml: + specifier: ^4.2.0 + version: 4.2.0 devDependencies: '@cordisjs/plugin-include': specifier: workspace:^ @@ -2710,6 +2723,15 @@ importers: '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants + '@deepseek-ai/dsh-paths': + specifier: workspace:^ + version: link:../../util/paths + '@deepseek-ai/dsh-system-prompt': + specifier: workspace:^ + version: link:../../core/system-prompt + '@types/js-yaml': + specifier: ^4.0.9 + version: 4.0.9 cordis: specifier: ^4.0.0-rc.7 version: 4.0.0-rc.7(@cordisjs/plugin-include@vendor+include)(@cordisjs/plugin-loader@vendor+loader) @@ -2857,9 +2879,15 @@ importers: '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session + '@deepseek-ai/dsh-session-persistence': + specifier: workspace:^ + version: link:../../session-persistence/session-persistence '@deepseek-ai/dsh-session-title': specifier: workspace:^ version: link:../../session-title/session-title + '@deepseek-ai/dsh-skill': + specifier: workspace:^ + version: link:../../skill/skill '@deepseek-ai/dsh-system-prompt': specifier: workspace:^ version: link:../../core/system-prompt diff --git a/python/sdk-runtime/package.json b/python/sdk-runtime/package.json index 32f2fa072b..5d98cee538 100644 --- a/python/sdk-runtime/package.json +++ b/python/sdk-runtime/package.json @@ -43,7 +43,6 @@ "@deepseek-ai/dsh-paths": "workspace:^", "@deepseek-ai/dsh-repeat-tool-guard": "workspace:^", "@deepseek-ai/dsh-sandbox": "workspace:^", - "@deepseek-ai/dsh-sandbox-policy": "workspace:^", "@deepseek-ai/dsh-scope": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-persistence": "workspace:^", diff --git a/scripts/gen-tool-catalog.ts b/scripts/gen-tool-catalog.ts index 867e67542b..4650a0b753 100644 --- a/scripts/gen-tool-catalog.ts +++ b/scripts/gen-tool-catalog.ts @@ -14,8 +14,6 @@ import AgentRegistry from '@deepseek-ai/dsh-agent' import GoalService from '@deepseek-ai/dsh-goal' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry, { type Config as ToolsConfig } from '@deepseek-ai/dsh-tools' -import { BashExecutor } from '@deepseek-ai/dsh-bash' -import type { BashExecRequest, BashExecSpec, BashProcess, BashRunResult } from '@deepseek-ai/dsh-bash' import LocalBashExecutor from '@deepseek-ai/dsh-bash-local' import LocalFileSystem from '@deepseek-ai/dsh-fs-local' import UserInteractionService from '@deepseek-ai/dsh-user-interaction' @@ -46,44 +44,6 @@ import * as ToolWorkflow from '@deepseek-ai/dsh-tool-workflow' const root = resolve(import.meta.dirname, '..') const OUT = 'docs/tool-catalog.md' -const CATALOG_RG_PROBE_COMMAND = 'command -v rg >/dev/null 2>&1' - -/** - * Minimal bash service for harvesting `dsh-tool-fs-search` schemas. The search - * plugin now probes `rg` at registration time, but the generated catalog must - * remain independent of the host PATH and never execute a real search. - */ -class CatalogSearchBashExecutor extends BashExecutor { - override resolve(request: BashExecRequest): BashExecSpec { - return { - command: request.command, - workdir: request.workdir ?? root, - timeoutMs: request.timeoutMs ?? 60_000, - stdoutMaxBytes: request.stdoutMaxBytes ?? 64_000, - signal: request.signal, - sandboxMode: request.sandboxMode, - } - } - - override run(spec: BashExecSpec): Promise { - if (spec.command !== CATALOG_RG_PROBE_COMMAND) { - throw new Error(`gen-tool-catalog: unexpected search bash command during schema harvest: ${spec.command}`) - } - return Promise.resolve({ - exitCode: 0, - signal: null, - timedOut: false, - aborted: false, - timeoutMs: spec.timeoutMs, - stdout: { text: '', truncated: false }, - stderr: { text: '', truncated: false }, - }) - } - - override start(): BashProcess { - throw new Error('gen-tool-catalog: search schema harvest must not start background processes') - } -} /** * Register the descriptor needed to mount schema-producing consumers. Declares @@ -220,15 +180,14 @@ const TOOL_PACKAGES: ToolPackage[] = [ writes: ['tool/call', 'tool/result'], async mount(ctx) { // The tools inject `bash` (search executes fixed `rg` commands through - // the executor seam, not ctx.fs). Use a catalog-only executor so the - // registration-time `rg` probe stays deterministic and the generator - // never depends on the host PATH. `ctx.spillStore` is optional (read via - // ctx.get) and does not affect the schemas, so no spill backend is mounted. - await ctx.plugin(CatalogSearchBashExecutor) + // the executor seam, not ctx.fs); boot the local executor to satisfy it. + // `ctx.spillStore` is optional (read via ctx.get) and does not affect the + // schemas, so no spill backend is mounted. + await ctx.plugin(LocalBashExecutor) await ctx.plugin(ToolFsSearch) }, note: - 'glob and grep are conditional bash-backed discovery tools: they register only when ctx.bash can find `rg`, then run fixed ripgrep commands through ctx.bash as ordinary foreground calls (never background tasks). Capped results save the complete formatted list through the optional ctx.spillStore backend; returned locators are follow-up-readable/searchable when the backend exposes local paths in co-located deployments.', + 'glob and grep are bash-backed discovery tools: they run fixed ripgrep commands through ctx.bash as ordinary foreground calls (never background tasks). Capped results save the complete formatted list through the optional ctx.spillStore backend; returned locators are follow-up-readable/searchable when the backend exposes local paths in co-located deployments.', }, { pkg: '@deepseek-ai/dsh-tool-goal', diff --git a/scripts/verify-package-readme-model-experience.ts b/scripts/verify-package-readme-model-experience.ts index 11eef97698..f492319cf7 100644 --- a/scripts/verify-package-readme-model-experience.ts +++ b/scripts/verify-package-readme-model-experience.ts @@ -48,14 +48,12 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly> = { 'packages/examples/agent-spine-demo': { kind: 'indirect', reason: 'The bundle only mounts model-facing child plugins.' }, 'packages/fs/fs': { kind: 'indirect', reason: 'The service interface delegates model rendering to dsh-tool-fs.' }, 'packages/fs/fs-local': { kind: 'indirect', reason: 'The provider backend delegates model rendering to dsh-tool-fs.' }, - 'packages/fs/fs-sandbox': { kind: 'indirect', reason: 'The provider backend delegates model rendering to dsh-tool-fs.' }, 'packages/hooks/hook-protocol': { kind: 'indirect', reason: 'Only the hook bridge plugins render decoded hook output to a model.' }, 'packages/llm/llm': { kind: 'none', reason: 'The adapter registry forwards already-assembled requests unchanged.' }, 'packages/llm/token-meter': { kind: 'indirect', reason: 'The measurement service leaves model-visible changes to its consumers.' }, 'packages/lsp/lsp': { kind: 'indirect', reason: 'The provider registry delegates model rendering to dsh-tool-lsp.' }, 'packages/lsp/lsp-local': { kind: 'indirect', reason: 'The provider backend delegates model rendering to dsh-tool-lsp.' }, 'packages/sandbox/sandbox-local': { kind: 'indirect', reason: 'The provider backend delegates model rendering to dsh-bash-sandbox and dsh-tool-bash.' }, - 'packages/sandbox/sandbox-policy': { kind: 'indirect', reason: 'The policy service holds the mode dsh-tool-bash and dsh-tool-fs render in their denial markers.' }, 'packages/sdk/create-sdk': { kind: 'indirect', reason: 'The initializer only writes project files; selected runtime plugins provide the generated project model surface.' }, 'packages/sdk/helper': { kind: 'none', reason: 'The project domain edits files and registers no live agent or model surface.' }, 'packages/sdk/scripts': { kind: 'indirect', reason: 'The launcher delegates model context to the loaded project plugin tree.' }, diff --git a/tsconfig.build.json b/tsconfig.build.json index e332336315..71e7a8d165 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -57,13 +57,11 @@ { "path": "./packages/bash/bash-local" }, { "path": "./packages/sandbox/sandbox" }, { "path": "./packages/sandbox/sandbox-local" }, - { "path": "./packages/sandbox/sandbox-policy" }, { "path": "./packages/bash/bash-sandbox" }, { "path": "./packages/bash/tool-bash" }, { "path": "./packages/fs/fs" }, { "path": "./packages/fs/fs-local" }, { "path": "./packages/fs/fs-policy" }, - { "path": "./packages/fs/fs-sandbox" }, { "path": "./packages/fs/tool-fs" }, { "path": "./packages/fs/tool-fs-search" }, { "path": "./packages/web/web" }, diff --git a/tsconfig.json b/tsconfig.json index 295e2a6365..99b7fe8b35 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -68,13 +68,11 @@ { "path": "./packages/bash/bash-local" }, { "path": "./packages/sandbox/sandbox" }, { "path": "./packages/sandbox/sandbox-local" }, - { "path": "./packages/sandbox/sandbox-policy" }, { "path": "./packages/bash/bash-sandbox" }, { "path": "./packages/bash/tool-bash" }, { "path": "./packages/fs/fs" }, { "path": "./packages/fs/fs-local" }, { "path": "./packages/fs/fs-policy" }, - { "path": "./packages/fs/fs-sandbox" }, { "path": "./packages/fs/tool-fs" }, { "path": "./packages/fs/tool-fs-search" }, { "path": "./packages/compact/compact" }, From a409f8b4bae0e93733083b4732d932033b747a87 Mon Sep 17 00:00:00 2001 From: Turtle Date: Wed, 22 Jul 2026 10:55:17 +0800 Subject: [PATCH 27/90] feat(tui): detect terminal color scheme and apply light-optimised palette Squash of the linearized fix/tui-color-scheme-v2 merge and the follow-up catalog regeneration. Renders error cause chains at every diagnostic seam (origin/fetch-failed-diagnostics) and adds color scheme detection with a light-terminal palette. --- .../feature/2026-06-24-workspace-context.md | 4 +- eslint.config.mjs | 1 + .../code-mode-workspace-context.cordis.yml | 12 +- examples/acp-agent/fs.cordis.snapshot.yml | 13 +- .../acp-agent/workspace-context.cordis.yml | 9 - packages/context/workspace-context/README.md | 2 +- .../context/workspace-context/src/index.ts | 1 - .../context/workspace-context/src/render.ts | 6 + .../context/workspace-context/src/state.ts | 5 +- .../tests/workspace-context.spec.ts | 10 +- packages/ui/tui/tests/tui.spec.ts | 889 +++++++++++++++++- python/sdk-runtime/package.json | 1 + scripts/gen-tool-catalog.ts | 51 +- .../verify-package-readme-model-experience.ts | 2 + tsconfig.build.json | 2 + tsconfig.json | 2 + 16 files changed, 950 insertions(+), 60 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-06-24-workspace-context.md b/.agents/notes/implemented/feature/2026-06-24-workspace-context.md index dc21fb919d..a3eec63b8f 100644 --- a/.agents/notes/implemented/feature/2026-06-24-workspace-context.md +++ b/.agents/notes/implemented/feature/2026-06-24-workspace-context.md @@ -40,7 +40,7 @@ After a successful first-party `read`, `write`, or `edit` call, the `tools/post- A content edit appends `Updated instructions from: `, states that the new content replaces the previous content, and includes the complete current file. If precedence changes from one candidate to another, the message also names the previous path and says it no longer applies. If no candidate remains, the plugin appends `Instructions removed: ` and states that the previously loaded instructions no longer apply. -Dynamic messages use a raw `context/message` envelope because the plugin owns the complete system-reminder framing. Core context injection therefore supports `envelope: 'raw'`; callers that omit it retain the canonical `` wrapper. `context/message.meta` carries opaque JSON state that is persisted but never rendered to the model. +Dynamic messages carry their complete system-reminder framing in `content`, and every `context/message` reaches the model verbatim as a user-role message (there is no core wrapper to opt out of). `context/message.meta` carries opaque JSON state that is persisted but never rendered to the model. Shell commands are not discovery triggers. Local bash calls start fresh shells, and inferring reached paths from arbitrary command strings would require shell semantics the prompt plugin does not own. @@ -76,7 +76,7 @@ There is intentionally no watcher. Detection occurs at the next successful struc ## Consequences -Workspace guidance is isolated per session and shared by both product front doors and every tool presentation mode. Initial instructions benefit from stable prefix caching, while nested and changed content remains durable and replayable. The generic session/agent context contract includes optional raw framing and JSON metadata, both propagated through prompt-submit and post-tool `additionalContexts` arrays without flattening entries. +Workspace guidance is isolated per session and shared by both product front doors and every tool presentation mode. Initial instructions benefit from stable prefix caching, while nested and changed content remains durable and replayable. The generic session/agent context contract carries JSON metadata propagated through prompt-submit and post-tool `additionalContexts` arrays without flattening entries. Repository text remains untrusted input. Lower-authority user-role framing, explicit precedence language, delimiter escaping, and symlink rejection reduce risk but do not eliminate prompt injection. Permission and sandbox layers treat workspace files as data rather than authority. diff --git a/eslint.config.mjs b/eslint.config.mjs index dd40c005d7..03dcf7edac 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -14,6 +14,7 @@ export default tseslint.config( '**/.doc-typecheck-*/**', 'website/.generated/**', 'vendor/**', // vendored source keeps upstream style and idioms + 'native/**', // imported landlock-run subtree: self-contained workspace with its own gates (native/README.md) '**/*.js', '**/*.mjs', '*.config.ts', // root tool configs (vitest, tsdown) — no project service diff --git a/examples/acp-agent/code-mode-workspace-context.cordis.yml b/examples/acp-agent/code-mode-workspace-context.cordis.yml index f2f24ca3a5..807de9b3c3 100644 --- a/examples/acp-agent/code-mode-workspace-context.cordis.yml +++ b/examples/acp-agent/code-mode-workspace-context.cordis.yml @@ -1,5 +1,5 @@ -# Code Mode workspace-context snapshot recording overlay. The scenario needs -# filesystem tools to trigger nested instruction discovery after a read. +# Code Mode workspace-context snapshot recording overlay. The default filesystem +# tools trigger nested instruction discovery after a read. - id: base name: '@cordisjs/plugin-include' config: @@ -21,13 +21,5 @@ Verify your work by running the code or tests. Keep answers brief and factual. - insert: - - id: fs-local - name: '@deepseek-ai/dsh-fs-local' - config: - cwd: !!js process.cwd() - - id: fs-policy - name: '@deepseek-ai/dsh-fs-policy' - - id: tool-fs - name: '@deepseek-ai/dsh-tool-fs' - id: code-runtime name: '@deepseek-ai/dsh-code-runtime-worker' diff --git a/examples/acp-agent/fs.cordis.snapshot.yml b/examples/acp-agent/fs.cordis.snapshot.yml index da0b2ca59b..d55521a8dc 100644 --- a/examples/acp-agent/fs.cordis.snapshot.yml +++ b/examples/acp-agent/fs.cordis.snapshot.yml @@ -1,5 +1,6 @@ -# Keyless filesystem snapshots apply the filesystem and replay overlays directly -# because include patches cannot target entries behind a nested include. +# Keyless filesystem snapshots apply the spill and replay overlays directly +# because include patches cannot target entries behind a nested include. The +# sandboxed filesystem stack already lives in the base cordis.yml. - id: base name: '@cordisjs/plugin-include' config: @@ -9,14 +10,6 @@ name: '@deepseek-ai/dsh-llm-deepseek' disabled: true - insert: - - id: fs-local - name: '@deepseek-ai/dsh-fs-local' - config: - cwd: !!js process.cwd() - - id: fs-policy - name: '@deepseek-ai/dsh-fs-policy' - - id: tool-fs - name: '@deepseek-ai/dsh-tool-fs' - id: spill-local name: '@deepseek-ai/dsh-spill-local' config: diff --git a/examples/acp-agent/workspace-context.cordis.yml b/examples/acp-agent/workspace-context.cordis.yml index 962a3334fe..f9dadc8189 100644 --- a/examples/acp-agent/workspace-context.cordis.yml +++ b/examples/acp-agent/workspace-context.cordis.yml @@ -22,12 +22,3 @@ You are a coding assistant powered by the {{model}} model. Your working directory is {{cwd}}. Verify your work by running the code or tests. Keep answers brief and factual. - - insert: - - id: fs-local - name: '@deepseek-ai/dsh-fs-local' - config: - cwd: !!js process.cwd() - - id: fs-policy - name: '@deepseek-ai/dsh-fs-policy' - - id: tool-fs - name: '@deepseek-ai/dsh-tool-fs' diff --git a/packages/context/workspace-context/README.md b/packages/context/workspace-context/README.md index a04c844e9b..7dd1e06931 100644 --- a/packages/context/workspace-context/README.md +++ b/packages/context/workspace-context/README.md @@ -42,7 +42,7 @@ These instructions apply to work under `packages/app`. Use them as guidance when A same-file edit starts with `Updated instructions from: ` and says to use the new content instead of the previously loaded content. A candidate switch additionally names the old path. When no candidate remains, the message is `Instructions removed: ` followed by `The previously loaded instructions from this file no longer apply.` Literal `` text inside an instruction file is escaped so file content cannot close the plugin-owned frame. -The core `context/message` envelope is disabled for these messages because the plugin already owns the complete `` framing. This is caller-selected with `envelope: 'raw'`; ordinary injected context still receives the canonical `` envelope. +The plugin owns the complete `` framing, and every `context/message` (from this plugin or any other) reaches the model verbatim as a user-role message with no wrapping. ## State And Refresh diff --git a/packages/context/workspace-context/src/index.ts b/packages/context/workspace-context/src/index.ts index 83bbfa9d26..21ef979459 100644 --- a/packages/context/workspace-context/src/index.ts +++ b/packages/context/workspace-context/src/index.ts @@ -92,7 +92,6 @@ export function apply(ctx: Context, config: Config): void { if (update !== undefined) { agent.inject(update.context.content, { source: update.context.source, - envelope: update.context.envelope, meta: update.context.meta, }) applyInstructionVersionUpdates(agent.session, update.versionUpdates, instructionVersions) diff --git a/packages/context/workspace-context/src/render.ts b/packages/context/workspace-context/src/render.ts index 34e427d0e3..910853b13b 100644 --- a/packages/context/workspace-context/src/render.ts +++ b/packages/context/workspace-context/src/render.ts @@ -163,6 +163,12 @@ function buildInstructionText( ): string { const marker = markerText(maxBytes, omitted, truncated) const body = [marker, style.intro, ...files.map(file => style.section(file))].filter(block => block.length > 0) + // Caller-owned framing: the plugin bakes the complete `` + // frame into the message content. The session surface projects context + // verbatim and does not wrap it, so any framing must live here in the + // producer's content (the pattern a future `meta`-driven renderer would + // generalize — see the deferred note in + // ../../../../.agents/notes/implemented/simplification/2026-07-20-unwrap-injected-content-envelopes.md). return [SYSTEM_REMINDER_OPEN, body.join('\n\n'), SYSTEM_REMINDER_CLOSE].join('\n') } diff --git a/packages/context/workspace-context/src/state.ts b/packages/context/workspace-context/src/state.ts index b86daeafcf..0ab455349a 100644 --- a/packages/context/workspace-context/src/state.ts +++ b/packages/context/workspace-context/src/state.ts @@ -61,9 +61,8 @@ export interface ReconciledInstructionContext { versionUpdates: InstructionVersionUpdate[] } -/** Plugin-owned raw context with required replay metadata. */ +/** Plugin-owned context with required replay metadata. */ export interface WorkspaceHookContext extends HookContext { - envelope: 'raw' meta: JsonValue } @@ -76,7 +75,7 @@ function workspaceContextHook(text: string, changes: WorkspaceInstructionChange[ ...change.digest !== undefined ? { digest: change.digest } : {}, })) const meta: JsonValue = { kind: 'workspace-instructions', version: 1, changes: serializedChanges } - return { content: [{ type: 'text', text }], source: PLUGIN_SOURCE, envelope: 'raw', meta } + return { content: [{ type: 'text', text }], source: PLUGIN_SOURCE, meta } } /** diff --git a/packages/context/workspace-context/tests/workspace-context.spec.ts b/packages/context/workspace-context/tests/workspace-context.spec.ts index 7efe77d992..9c5cb3b0d3 100644 --- a/packages/context/workspace-context/tests/workspace-context.spec.ts +++ b/packages/context/workspace-context/tests/workspace-context.spec.ts @@ -183,7 +183,6 @@ function stubAgent(cwd?: string, seed: SessionEvent[] = []): Agent { session.append('context/message', { content, source: options?.source ?? { kind: 'user' }, - ...options?.envelope !== undefined ? { envelope: options.envelope } : {}, ...options?.meta !== undefined ? { meta: options.meta } : {}, }, { surfaceOp: 'append' }) }, @@ -212,7 +211,6 @@ function workspaceChangeContext(scope: string, digest: string): HookContext { return { content: [{ type: 'text', text: `instructions for ${scope}` }], source: { kind: 'plugin', plugin: 'workspace-context' }, - envelope: 'raw', meta: { kind: 'workspace-instructions', version: 1, @@ -227,7 +225,6 @@ function appendAdditionalContexts(agent: Agent, result: { additionalContexts?: H lastSeq = agent.session.append('context/message', { content: context.content, source: context.source, - ...context.envelope !== undefined ? { envelope: context.envelope } : {}, ...context.meta !== undefined ? { meta: context.meta } : {}, }, { surfaceOp: 'append' }).seq } @@ -1753,7 +1750,6 @@ describe('dynamic nested workspace context injection', () => { expect(result.isError).toBe(false) expect(workspaceContextOf(result)?.source).toEqual({ kind: 'plugin', plugin: 'workspace-context' }) - expect(workspaceContextOf(result)?.envelope).toBe('raw') expect(workspaceContextOf(result)?.meta).toMatchObject({ kind: 'workspace-instructions', version: 1, @@ -2564,7 +2560,6 @@ describe('dynamic nested workspace context injection', () => { expect(blocksText(result.content)).toBe('downstream replacement') expect(result.additionalContexts).toHaveLength(2) expect(workspaceContextOf(result)?.source).toEqual({ kind: 'plugin', plugin: 'workspace-context' }) - expect(workspaceContextOf(result)?.envelope).toBe('raw') expect(workspaceContextOf(result)?.meta).toMatchObject({ kind: 'workspace-instructions', changes: [{ action: 'set', scope: 'pkg', path: join('pkg', 'AGENTS.md') }], @@ -2577,7 +2572,8 @@ describe('dynamic nested workspace context injection', () => { }) const agent = stubAgent(root) appendAdditionalContexts(agent, result) - expect(blocksText(agent.session.deriveMessages()[1]?.content)).toContain('\ndownstream context\n') + expect(blocksText(agent.session.deriveMessages()[1]?.content)).toContain('downstream context') + expect(blocksText(agent.session.deriveMessages()[1]?.content)).not.toContain(' { const otherWorkspaceEvent = agent.session.append('context/message', { content: otherContext.content, source: otherContext.source, - ...otherContext.envelope !== undefined ? { envelope: otherContext.envelope } : {}, ...otherContext.meta !== undefined ? { meta: otherContext.meta } : {}, }, { surfaceOp: 'append' }) observeInstructionSessionEvent(agent.session, otherWorkspaceEvent, pending, versions) @@ -2933,7 +2928,6 @@ describe('workspace context pending state', () => { const confirmed = agent.session.append('context/message', { content: context.content, source: context.source, - ...context.envelope !== undefined ? { envelope: context.envelope } : {}, ...context.meta !== undefined ? { meta: context.meta } : {}, }, { surfaceOp: 'append' }) observeInstructionSessionEvent(agent.session, confirmed, pending, versions) diff --git a/packages/ui/tui/tests/tui.spec.ts b/packages/ui/tui/tests/tui.spec.ts index 3575724552..00a8f25ab7 100644 --- a/packages/ui/tui/tests/tui.spec.ts +++ b/packages/ui/tui/tests/tui.spec.ts @@ -4,9 +4,10 @@ import { describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' import type { Terminal } from '@earendil-works/pi-tui' import AgentRegistry, { agentEvents, assembleContextFor, type Agent } from '@deepseek-ai/dsh-agent' -import type { LlmCallConfig } from '@deepseek-ai/dsh-llm' +import LlmService, { LlmAdapter, type GenerateOptions, type LlmCallConfig, type StreamChunk } from '@deepseek-ai/dsh-llm' import CommandService, { type CommandInvocation } from '@deepseek-ai/dsh-commands' -import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' +import SessionStore, { SessionId, type SessionHeader } from '@deepseek-ai/dsh-session' +import SkillService, { type SkillDefinition, type SkillSummary } from '@deepseek-ai/dsh-skill' import type {} from '@deepseek-ai/dsh-session-title' import type { ToolDefinition } from '@deepseek-ai/dsh-tools' import UserInteractionService from '@deepseek-ai/dsh-user-interaction' @@ -14,6 +15,7 @@ import type {} from '@deepseek-ai/dsh-llm-retry' import { createTuiChat, mountTui, + renderSkillInvocation, resolveTuiConfig, type TuiRuntime, } from '../src/index.ts' @@ -135,7 +137,9 @@ describe('TUI config', () => { modelDialogMaxHeight: 20, showHardwareCursor: false, color: true, + truecolor: false, title: 'DeepSeek Harness', + autoTitle: true, }) expect(resolveTuiConfig({ showReasoning: false, @@ -148,7 +152,9 @@ describe('TUI config', () => { modelDialogMaxHeight: 16, showHardwareCursor: true, color: false, + truecolor: true, title: 'DSH', + autoTitle: false, })).toEqual({ showReasoning: false, maxToolOutputLines: 2, @@ -160,11 +166,122 @@ describe('TUI config', () => { modelDialogMaxHeight: 16, showHardwareCursor: true, color: false, + truecolor: true, title: 'DSH', + autoTitle: false, }) }) }) +describe('resume command and /resume', () => { + const RESUME = 'RESUME_SESSION_ID={session} dsh' + const header = (id: string, createdAt: number, cwd: string): SessionHeader => + ({ version: 0, id: SessionId(id), createdAt, cwd }) + + it('prints the resume command on exit once the session is persisted', async () => { + const result = await setup({ + cwd: '/workspace', + config: { resumeCommand: RESUME }, + sessionPersistence: { list: async () => [header('main-session', 1000, '/workspace')] }, + }) + result.terminal.send('/exit') + result.terminal.send('\r') + await tick() + expect(result.terminal.output).toContain('To resume this session: RESUME_SESSION_ID=main-session dsh') + expect(result.exit).toHaveBeenCalledWith(0) + await dispose(result) + }) + + it('omits the exit hint when the session is not yet persisted', async () => { + const result = await setup({ cwd: '/workspace', config: { resumeCommand: RESUME } }) + result.terminal.send('/exit') + result.terminal.send('\r') + await tick() + expect(result.terminal.output).not.toContain('To resume this session') + expect(result.exit).toHaveBeenCalledWith(0) + await dispose(result) + }) + + it('omits the exit hint when the session listing fails', async () => { + const result = await setup({ + cwd: '/workspace', + config: { resumeCommand: RESUME }, + sessionPersistence: { list: () => Promise.reject(new Error('disk gone')) }, + }) + result.terminal.send('/exit') + result.terminal.send('\r') + await tick() + expect(result.terminal.output).not.toContain('To resume this session') + expect(result.exit).toHaveBeenCalledWith(0) + await dispose(result) + }) + + it('lists this workspace\'s sessions newest-first and marks the current one', async () => { + const result = await setup({ + cwd: '/workspace', + config: { resumeCommand: RESUME }, + sessionPersistence: { + list: async () => [ + header('main-session', 1000, '/workspace'), + header('older-session', 500, '/workspace'), + header('newer-session', 2000, '/workspace'), + header('foreign-session', 3000, '/elsewhere'), + ], + }, + }) + result.terminal.send('/resume') + result.terminal.send('\r') + await tick() + const output = result.terminal.output + expect(output).toContain('Resumable sessions') + expect(output).toContain('RESUME_SESSION_ID=main-session dsh') + expect(output).toContain('(current)') + expect(output).toContain('RESUME_SESSION_ID=newer-session dsh') + expect(output).not.toContain('foreign-session') + // Newest-first: the newer session's command precedes the current session's. + // Match the full resume command, not the bare id: the banner detail line + // echoes the current session id (`main-session`) above the listing. + expect(output.indexOf('RESUME_SESSION_ID=newer-session')).toBeLessThan( + output.indexOf('RESUME_SESSION_ID=main-session'), + ) + expect(output.indexOf('RESUME_SESSION_ID=main-session')).toBeLessThan( + output.indexOf('RESUME_SESSION_ID=older-session'), + ) + await dispose(result) + }) + + it('warns from /resume when resume is not configured', async () => { + const result = await setup({ cwd: '/workspace' }) + result.terminal.send('/resume') + result.terminal.send('\r') + await tick() + expect(result.terminal.output).toContain('Resume is not configured') + await dispose(result) + }) + + it('warns from /resume when no persistence backend is mounted', async () => { + const result = await setup({ cwd: '/workspace', config: { resumeCommand: RESUME } }) + result.terminal.send('/resume') + result.terminal.send('\r') + await tick() + expect(result.terminal.output).toContain('no persistence backend is mounted') + await dispose(result) + }) + + it('notes from /resume when no workspace sessions are persisted yet', async () => { + const result = await setup({ + cwd: '/workspace', + config: { resumeCommand: RESUME }, + sessionPersistence: { list: async () => [header('foreign-session', 10, '/elsewhere')] }, + }) + result.terminal.send('/resume') + result.terminal.send('\r') + await tick() + expect(result.terminal.output).toContain('No resumable sessions found') + await dispose(result) + }) +}) + describe('pi-tui chat lifecycle and transcript', () => { it('uses the latest log-backed title for the header subtitle and terminal window', async () => { const result = await setup({ @@ -322,8 +439,7 @@ describe('pi-tui chat lifecycle and transcript', () => { ) await tick() - expect(result.terminal.output).toContain('◒ Working · 8s') - expect(result.terminal.output).toContain('esc interrupt') + expect(result.terminal.output).toContain('Enter sends steering, Esc cancels') expect(result.terminal.output).toContain('Steering') expect(result.terminal.output).toContain('user context') expect(result.terminal.output).toContain('Prompt blocked') @@ -415,6 +531,155 @@ describe('pi-tui chat lifecycle and transcript', () => { await dispose(result) }) + it('badges queued steering on the running status line and clears it as each drains', async () => { + // Pin a cwd free of the substring under test; the footer renders the path. + const result = await setup({ status: 'running', cwd: '/workspace' }) + // Running with nothing queued: the plain steering hint, no badge. + expect(result.terminal.output).toContain('— Enter sends steering, Esc cancels') + expect(result.terminal.output).not.toContain('queued') + + const queueSteering = (text: string): void => { + result.ctx.emit('agent/queued', result.agent, [{ type: 'text', text }], { source: { kind: 'user' }, steering: true }) + } + const drainSteering = (text: string): void => { + result.session.append('steering/message', { turn: 1, content: [{ type: 'text', text }], source: { kind: 'user' } }, { surfaceOp: 'append' }) + } + + // A steering queue for a different agent never touches this status line. + const other = { ...result.agent, id: SessionId('other') } as Agent + result.terminal.output = '' + result.ctx.emit('agent/queued', other, [{ type: 'text', text: 'elsewhere' }], { source: { kind: 'user' }, steering: true }) + await tick() + expect(result.terminal.output).not.toContain('queued') + + // Two steering messages queue while the turn runs. + queueSteering('first') + result.terminal.output = '' + queueSteering('second') + await tick() + expect(result.terminal.output).toContain('2 queued · Enter sends steering, Esc cancels') + + // A non-steering queue (an idle-style send) leaves the badge untouched. + result.terminal.output = '' + result.ctx.emit('agent/queued', result.agent, [{ type: 'text', text: 'sent' }], { source: { kind: 'user' }, steering: false }) + drainSteering('first') + await tick() + expect(result.terminal.output).toContain('1 queued') + expect(result.terminal.output).not.toContain('2 queued') + + // Draining the last queued message returns the plain hint. + result.terminal.output = '' + drainSteering('second') + await tick() + expect(result.terminal.output).toContain('— Enter sends steering, Esc cancels') + expect(result.terminal.output).not.toContain('queued') + + // A loop-authored steering drain past zero clamps rather than underflowing. + result.terminal.output = '' + drainSteering('continuation') + queueSteering('after') + await tick() + expect(result.terminal.output).toContain('1 queued') + + // The turn ending resets the badge, so the next running turn starts clean. + result.agent.status = 'idle' + result.ctx.emit('agent/status', result.agent, 'idle') + result.agent.status = 'running' + result.terminal.output = '' + result.ctx.emit('agent/status', result.agent, 'running') + await tick() + expect(result.terminal.output).toContain('— Enter sends steering, Esc cancels') + expect(result.terminal.output).not.toContain('queued') + + await dispose(result) + }) + + it('derives the fine-grained turn phase from session lifecycle events', async () => { + // A live event before the turn runs has no status controller to move. + const idle = await setup() + // A steering queue arriving while idle has no status line to badge, so the + // refresh is a no-op beyond requesting a render. + idle.ctx.emit('agent/queued', idle.agent, [{ type: 'text', text: 'early' }], { source: { kind: 'user' }, steering: true }) + idle.session.append('tool/call', { turn: 1, step: 0, callId: 'pre' as never, name: 'bash', arguments: '{}' }) + await tick() + expect(idle.terminal.output).not.toContain('Executing tools') + expect(idle.terminal.output).not.toContain('queued') + await dispose(idle) + + const result = await setup({ status: 'running' }) + expect(result.terminal.output).toContain('Waiting for the first token') + + result.terminal.output = '' + result.session.append('assistant/chunk', { turn: 1, step: 0, chunk: { type: 'block-start', index: 0, blockType: 'reasoning' } }) + result.session.append('assistant/chunk', { turn: 1, step: 0, chunk: { type: 'reasoning-delta', index: 0, text: 'mull it over' } }) + await tick() + expect(result.terminal.output).toContain('Thinking') + + result.terminal.output = '' + result.session.append('assistant/chunk', { turn: 1, step: 0, chunk: { type: 'block-start', index: 1, blockType: 'text' } }) + result.session.append('assistant/chunk', { turn: 1, step: 0, chunk: { type: 'text-delta', index: 1, text: 'answering' } }) + await tick() + expect(result.terminal.output).toContain('Responding') + + result.terminal.output = '' + result.session.append('tool/call', { turn: 1, step: 0, callId: 'c1' as never, name: 'bash', arguments: '{}' }) + await tick() + expect(result.terminal.output).toContain('Executing tools') + + // The next step reopens the wait window and resets the executing label. + result.terminal.output = '' + result.session.append('step/start', { turn: 1, step: 1 }) + await tick() + expect(result.terminal.output).toContain('Waiting for the first token') + expect(result.terminal.output).not.toContain('Executing tools') + + await dispose(result) + }) + + it('refreshes the running status elapsed time on its own timer', async () => { + const result = await setup({ status: 'running' }) + result.terminal.output = '' + // The loader repaints "0s" until the controller's own interval fires; a + // non-zero elapsed proves the refresh, not just the loader's animation. + await new Promise(resolve => setTimeout(resolve, 1_300)) + expect(result.terminal.output).toMatch(/Waiting for the first token [1-9]s/) + await dispose(result) + }) + + it('shows minutes and seconds once a step passes a minute', async () => { + const result = await setup({ status: 'running' }) + const base = Date.now() + const nowSpy = vi.spyOn(Date, 'now').mockReturnValue(base + 95_000) + result.terminal.output = '' + result.session.append('assistant/chunk', { turn: 1, step: 0, chunk: { type: 'text-delta', index: 0, text: 'hi' } }) + await tick() + expect(result.terminal.output).toContain('total 1m') + nowSpy.mockRestore() + await dispose(result) + }) + + it('preserves the turn phase and elapsed time across a mid-turn color-scheme change', async () => { + const result = await setup({ status: 'running' }) + const base = Date.now() + const nowSpy = vi.spyOn(Date, 'now').mockReturnValue(base) + // Advance into `responding`, anchoring the phase clock at `base`. + result.session.append('assistant/chunk', { turn: 1, step: 0, chunk: { type: 'text-delta', index: 0, text: 'answering' } }) + await tick() + + // Four seconds later the terminal reports a light color scheme, rebuilding + // the status loader; the phase and its elapsed time must survive the rebuild. + nowSpy.mockReturnValue(base + 4_000) + result.terminal.output = '' + result.terminal.send('\x1b[?997;2n') + await tick() + await tick() + expect(result.terminal.output).toContain('Responding 4s') + expect(result.terminal.output).not.toContain('Waiting for the first token') + + nowSpy.mockRestore() + await dispose(result) + }) + it('renders the ANSI palette and every markdown/content style', async () => { const result = await setup({ cwd: '/workspace', @@ -518,6 +783,41 @@ describe('pi-tui chat lifecycle and transcript', () => { await dispose(logicalResult) }) + it('shows the session cache hit rate in the footer and updates it live', async () => { + // Empty session: no input billed yet, so the cache segment is hidden. + // A cwd without "cache" in it keeps the negative assertion unambiguous. + const empty = await setup({ cwd: '/opt' }) + expect(empty.terminal.output).toContain('↑0 ↓0') + expect(empty.terminal.output).not.toContain('cache') + await dispose(empty) + + const result = await setup({ + // Pin a short cwd so the footer never clips the cache segment: the + // default is process.cwd(), and a deep worktree path truncates + // `cache 60%` at the terminal width. + cwd: '/opt', + beforeMount(session) { + // Cold call: 10 billed input tokens, none served from cache. + appendAssistant(session, [{ type: 'text', text: 'cold' }], { inputTokens: 10, outputTokens: 5 }) + }, + }) + expect(result.terminal.output).toContain('cache 0%') + + result.terminal.output = '' + // Warm call lands live: 5 uncached + 30 cache-read + 5 cache-write billed + // input, so 30 of the 50 total prompt tokens are hits → 60%. + appendAssistant(result.session, [{ type: 'text', text: 'warm' }], { + inputTokens: 5, + outputTokens: 5, + cacheReadTokens: 30, + cacheWriteTokens: 5, + }) + await tick() + expect(result.terminal.output).toContain('cache 60%') + expect(result.terminal.output).not.toContain('cache 0%') + await dispose(result) + }) + it('sends, steers, handles commands, global keys, and disposed-agent input', async () => { const result = await setup() @@ -539,17 +839,15 @@ describe('pi-tui chat lifecycle and transcript', () => { result.terminal.send('\x03') result.terminal.send('\x12') result.terminal.send('\x0f') - result.terminal.send('/cancel') - result.terminal.send('\r') expect(result.agent.cancelled).toContainEqual({ kind: 'user' }) result.agent.status = 'idle' - for (const command of ['/help', '/reasoning', '/tools', '/redraw']) { + for (const command of ['/help', '/reasoning', '/tools', '/redraw', '/reload']) { result.terminal.send(command) result.terminal.send('\r') await tick() } - for (const command of ['/clear', '/cancel', '/wat']) { + for (const command of ['/clear', '/wat']) { result.terminal.send(command) result.terminal.send('\r') } @@ -562,8 +860,9 @@ describe('pi-tui chat lifecycle and transcript', () => { expect(result.terminal.output).toContain('Keyboard shortcuts') expect(result.terminal.output).toContain('Reasoning blocks') expect(result.terminal.output).toContain('Tool cards') - expect(result.terminal.output).toContain('already idle') expect(result.terminal.output).toContain('Unknown command') + // /reload without a Loader in the context degrades to a warning. + expect(result.terminal.output).toContain('/reload needs the cordis Loader') expect(result.exit).toHaveBeenCalledWith(0) await result.controller.dispose() await result.ctx.fiber.dispose() @@ -917,6 +1216,153 @@ describe('pi-tui chat lifecycle and transcript', () => { }) }) +describe('skill slash command', () => { + const withSkills = async (ctx: Context): Promise => { + ctx.provide('tools', { get() { return undefined } } as never) + await ctx.plugin(SkillService) + const skills = ctx.get('skills') + if (skills === undefined) throw new Error('skills service not mounted') + skills.register({ name: 'demo-skill', description: 'Demo skill for tests', source: 'runtime', provider: 'runtime', content: 'Demo instructions body.' }) + skills.register({ name: 'hidden-skill', description: 'Model-hidden skill', source: 'runtime', provider: 'runtime', content: 'Hidden instructions body.', disableModelInvocation: true }) + } + + it('offers non-hidden skills as slash completions and hides model-disabled ones', async () => { + const result = await setup({ configureContext: withSkills }) + result.terminal.send('/skill') + await tick() + expect(result.terminal.output).toContain('demo-skill') + expect(result.terminal.output).not.toContain('hidden-skill') + await dispose(result) + }) + + it('loads a skill as a user turn, appending typed instructions', async () => { + const result = await setup({ configureContext: withSkills }) + result.terminal.send('/skill:demo-skill') + result.terminal.send('\r') + await tick() + expect(result.agent.sent).toEqual([[{ type: 'text', text: '\nDemo instructions body.\n' }]]) + + result.agent.status = 'running' + result.terminal.send('/skill:demo-skill focus on tests') + result.terminal.send('\r') + await tick() + expect(result.agent.steered).toEqual([[{ type: 'text', text: '\nDemo instructions body.\n\n\nfocus on tests' }]]) + await dispose(result) + }) + + it('invokes a model-disabled skill by its exact name', async () => { + const result = await setup({ configureContext: withSkills }) + result.terminal.send('/skill:hidden-skill') + result.terminal.send('\r') + await tick() + expect(result.agent.sent).toEqual([[{ type: 'text', text: '\nHidden instructions body.\n' }]]) + await dispose(result) + }) + + it('reports an unknown skill and an empty skill name without sending', async () => { + const result = await setup({ configureContext: withSkills }) + result.terminal.send('/skill:does-not-exist') + result.terminal.send('\r') + await tick() + result.terminal.send('/skill:') + result.terminal.send('\r') + await tick() + // A space right after the colon parses to an empty name, not a name of + // "focus"; the documented syntax puts the name immediately after the colon. + result.terminal.send('/skill: focus') + result.terminal.send('\r') + await tick() + expect(result.terminal.output).toContain('Unknown skill: does-not-exist') + expect(result.terminal.output).toContain('Usage: /skill:') + expect(result.agent.sent).toEqual([]) + await dispose(result) + }) + + it('warns when no skill service is mounted', async () => { + const result = await setup() + result.terminal.send('/skill:demo-skill') + result.terminal.send('\r') + await tick() + expect(result.terminal.output).toContain('Skills are not available') + expect(result.agent.sent).toEqual([]) + await dispose(result) + }) + + it('surfaces skill lookup failures as an error notice', async () => { + const result = await setup({ + configureContext: async (ctx) => { + ctx.provide('tools', { get() { return undefined } } as never) + ctx.provide('skills', { + list: () => Promise.reject(new Error('list boom')), + get: () => Promise.reject(new Error('get boom')), + } as never) + }, + }) + result.terminal.send('/skill:demo-skill') + result.terminal.send('\r') + await tick() + expect(result.terminal.output).toContain('failed to load') + expect(result.terminal.output).toContain('get boom') + await dispose(result) + }) + + it('drops skill list and lookup results that settle after disposal', async () => { + const pendingList: Array<(value: SkillSummary[]) => void> = [] + const pendingGet: Array<{ resolve: (value: SkillDefinition | undefined) => void; reject: (error: unknown) => void }> = [] + const result = await setup({ + configureContext: async (ctx) => { + ctx.provide('tools', { get() { return undefined } } as never) + ctx.provide('skills', { + list: () => new Promise((resolve) => { pendingList.push(resolve) }), + get: () => new Promise((resolve, reject) => { pendingGet.push({ resolve, reject }) }), + } as never) + }, + }) + result.terminal.send('/skill:demo-skill') + result.terminal.send('\r') + await tick() + result.terminal.send('/skill:other-skill') + result.terminal.send('\r') + await tick() + await dispose(result) + + for (const resolve of pendingList) resolve([{ name: 'late', description: 'late', source: 'runtime', provider: 'runtime' }]) + pendingGet[0]?.resolve({ name: 'demo-skill', description: 'late', source: 'runtime', provider: 'runtime', content: 'late body' }) + pendingGet[1]?.reject(new Error('late failure')) + await tick() + expect(result.agent.sent).toEqual([]) + expect(result.terminal.output).not.toContain('late failure') + expect(result.terminal.output).not.toContain('late body') + }) +}) + +describe('renderSkillInvocation', () => { + const skill: SkillDefinition = { + name: 'demo-skill', + description: 'Demo skill', + source: 'runtime', + provider: 'runtime', + content: 'Body text.', + } + + it('renders directory, url, opaque, and absent resource bases', () => { + expect(renderSkillInvocation({ ...skill, resourceBase: { kind: 'directory', path: '/skills/demo' } }, '')).toBe( + '\nReferences in this skill are relative to /skills/demo.\n\nBody text.\n', + ) + expect(renderSkillInvocation({ ...skill, resourceBase: { kind: 'url', url: 'https://x/y' } }, 'go')).toBe( + '\nReferences in this skill are relative to https://x/y.\n\nBody text.\n\n\ngo', + ) + expect(renderSkillInvocation({ ...skill, resourceBase: { kind: 'opaque', description: 'held in memory' } }, '')).toBe( + '\nheld in memory\n\nBody text.\n', + ) + expect(renderSkillInvocation(skill, '')).toBe('\nBody text.\n') + }) + + it('throws on an unknown resource base kind', () => { + expect(() => renderSkillInvocation({ ...skill, resourceBase: { kind: 'future' } as never }, '')).toThrow('unreachable variant') + }) +}) + describe('tool cards and surface replay', () => { const tools: Record = { bash: { @@ -1289,6 +1735,37 @@ describe('terminal mounting', () => { await ctx.fiber.dispose() }) + it('degrades /reload to a warning when mounted as a real plugin without a Loader', async () => { + // Production shape: the TUI runs inside a plugin fiber, where a bare + // `ctx.loader` proxy read would THROW `cannot get property without + // inject` — only the non-throwing `ctx.get` lookup degrades gracefully. + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(AgentRegistry) + await ctx.plugin(UserInteractionService) + ctx.provide('tools', { get: () => undefined } as never) + const session = ctx.sessions.create(SessionId('main')) + ctx.agents.register({ + id: session.id, options: {}, session, status: 'idle', ctx, + send() {}, steer() {}, inject() {}, cancel() {}, whenIdle: () => Promise.resolve(), + }) + const terminal = new FakeTerminal() + // Mirror dsh-tui's own inject (minus loader, the absence under test). + await ctx.plugin({ + inject: ['agents', 'userInteraction', 'tools'], + apply: (pluginCtx: Context) => { + mountTui(pluginCtx, { color: false }, { terminal, exit: vi.fn() }) + }, + }) + await tick() + expect(terminal.started).toBe(1) + terminal.send('/reload') + terminal.send('\r') + await tick() + expect(terminal.output).toContain('/reload needs the cordis Loader') + await ctx.fiber.dispose() + }) + it('waits for its configured agent before starting the TUI', async () => { const ctx = new Context() provideTokenMeter(ctx) @@ -1335,7 +1812,7 @@ describe('terminal mounting', () => { expect(terminal.output).toBe('') expect(exit).not.toHaveBeenCalled() ctx.emit('agent-loop/config-start-failed', SessionId('main-session'), new Error('resume \u001b]2;failure-controlled\u0007')) - expect(terminal.output).toBe('ui-tui: session "main-session" failed to start: Error: resume \\x1b]2;failure-controlled\\x07\n') + expect(terminal.output).toBe('ui-tui: session "main-session" failed to start: resume \\x1b]2;failure-controlled\\x07\n') expect(exit).toHaveBeenCalledWith(1) const session = ctx.sessions.create(SessionId('main-session')) @@ -1365,7 +1842,7 @@ describe('terminal mounting', () => { }) expect(terminal.started).toBe(0) - expect(terminal.output).toBe('ui-tui: session "main-session" failed to start: \n') + expect(terminal.output).toBe('ui-tui: session "main-session" failed to start: \n') expect(exit).toHaveBeenCalledWith(1) await ctx.fiber.dispose() }) @@ -1470,4 +1947,394 @@ describe('terminal mounting', () => { expect(terminal.output).toContain('\x1b[2mdeepseek-v4-flash') await disposeTuiTestHarness(result) }) + it('runs /reload against every file-backed loader subtree, reports completion, and rejects re-entry while in flight', async () => { + const refreshed: string[] = [] + let releaseRefresh!: () => void + const gate = new Promise((resolve) => { releaseRefresh = resolve }) + const result = await setup({ + configureContext: async (ctx) => { + ctx.provide('tools', { get: () => undefined } as never) + // A structural Loader: two file-backed subtrees and one plain entry. + // The first subtree blocks on a gate so re-entry can be probed + // deterministically mid-flight. + ctx.provide('loader', { + entries: () => [ + { subtree: { refresh: async () => { refreshed.push('root'); await gate } } }, + {}, + { subtree: { refresh: async () => { refreshed.push('nested') } } }, + ], + } as never) + }, + }) + result.terminal.send('/reload') + result.terminal.send('\r') + await tick() + expect(result.terminal.output).toContain('Reloading 2 config tree(s)') + // Second /reload while the first is gated: refused, no extra refreshes. + result.terminal.send('/reload') + result.terminal.send('\r') + await tick() + expect(result.terminal.output).toContain('A config reload is already running.') + expect(refreshed.sort()).toEqual(['nested', 'root']) + releaseRefresh() + await tick() + expect(result.terminal.output).toContain('Config reload complete.') + // The guard released: a third /reload runs again. + result.terminal.send('/reload') + result.terminal.send('\r') + await tick() + expect(refreshed).toHaveLength(4) + await dispose(result) + }) + + it('reports a /reload failure if a refresh ever rejects', async () => { + const result = await setup({ + configureContext: async (ctx) => { + ctx.provide('tools', { get: () => undefined } as never) + ctx.provide('loader', { + entries: () => [{ subtree: { refresh: () => Promise.reject(new Error('disk gone')) } }], + } as never) + }, + }) + result.terminal.send('/reload') + result.terminal.send('\r') + await tick() + expect(result.terminal.output).toContain('Config reload failed: disk gone') + // The failure arm also releases the re-entrancy guard. + result.terminal.send('/reload') + result.terminal.send('\r') + await tick() + expect(result.terminal.output).not.toContain('A config reload is already running.') + await dispose(result) + }) + + it('refuses /reload while the agent is running and allows it back at idle', async () => { + const refreshed: string[] = [] + const result = await setup({ + status: 'running', + configureContext: async (ctx) => { + ctx.provide('tools', { get: () => undefined } as never) + ctx.provide('loader', { + entries: () => [{ subtree: { refresh: async () => { refreshed.push('tree') } } }], + } as never) + }, + }) + result.terminal.send('/reload') + result.terminal.send('\r') + await tick() + expect(result.terminal.output).toContain('/reload requires an idle agent (status: running).') + expect(refreshed).toHaveLength(0) + // Back at idle the same command runs. + result.agent.status = 'idle' + result.terminal.send('/reload') + result.terminal.send('\r') + await tick() + expect(refreshed).toHaveLength(1) + expect(result.terminal.output).toContain('Config reload complete.') + await dispose(result) + }) + +}) + +describe('banner sweep reveal', () => { + it('renders the product name through the brand-gradient path when truecolor is enabled', async () => { + // The product name carries a per-letter 24-bit gradient from the brand + // indigo to light blue; the per-letter layout is pinned by the + // `banner-gradient` terminal snapshot. + const result = await setup({ config: { color: true, truecolor: true } }) + expect(result.terminal.output).toContain('\x1b[38;2;77;107;254m') + expect(result.terminal.output).toContain('\x1b[38;2;36;152;255m') + expect(result.terminal.output).toContain('HARNESS') + await dispose(result) + }) + + it('sweeps the whole borderless banner in when no welcome is configured, ending complete', async () => { + const intervals = vi.spyOn(globalThis, 'setInterval') + const cleared = vi.spyOn(globalThis, 'clearInterval') + const result = await setup({ omitWelcome: true }) + const revealHandle = intervals.mock.results.at(-1)?.value as ReturnType + // Run the sweep to natural completion — it clears its own timer at the end. + const done = (): boolean => cleared.mock.calls.some(call => call[0] === revealHandle) + const deadline = Date.now() + 5000 + while (!done() && Date.now() < deadline) await tick() + intervals.mockRestore() + cleared.mockRestore() + // The finished banner carries the title and the model • session detail. + expect(result.terminal.output).toContain('DEEPSEEK') + expect(result.terminal.output).toContain('HARNESS') + expect(result.terminal.output).toContain('main-session') + // Borderless: no box-drawing frame around the banner. + expect(result.terminal.output).not.toContain('╭') + expect(result.terminal.output).not.toContain('╮') + // A mid-sweep frame rendered a clipped title: `DEEPSEEK` with no `HARNESS` + // on the same line. + const clipped = result.terminal.output + .split('\n') + .some(line => line.includes('DEEPSEEK') && !line.includes('HARNESS')) + expect(clipped).toBe(true) + await dispose(result) + }) + + it('renders a configured welcome verbatim in a complete banner with no sweep', async () => { + const result = await setup() + await tick() + expect(result.terminal.output).toContain('Coding agent ready.') + expect(result.terminal.output).toContain('DEEPSEEK') + expect(result.terminal.output).not.toContain('╭') + // No reveal frames: the banner is drawn whole from the first render, so no + // clipped-title frame ever appears. + const clipped = result.terminal.output + .split('\n') + .some(line => line.includes('DEEPSEEK') && !line.includes('HARNESS')) + expect(clipped).toBe(false) + await dispose(result) + }) + + it('omits the subtitle line entirely when no welcome is configured', async () => { + const result = await setup({ omitWelcome: true }) + const deadline = Date.now() + 5000 + while (!result.terminal.output.includes('main-session') && Date.now() < deadline) await tick() + // Banner is title + detail only — no subtitle between them. + expect(result.terminal.output).toContain('deepseek-v4-flash') + expect(result.terminal.output).not.toContain('ready.') + await dispose(result) + }) + + it('stops a mid-sweep animation on dispose', async () => { + // The output-stability probe alone is insensitive to a leaked interval + // (pi-tui's stopped guard silences post-stop renders), so capture the + // reveal's own interval handle and assert dispose clears exactly it. + const intervals = vi.spyOn(globalThis, 'setInterval') + const result = await setup({ omitWelcome: true }) + const revealHandle = intervals.mock.results.at(-1)?.value as ReturnType + expect(revealHandle).toBeDefined() + const cleared = vi.spyOn(globalThis, 'clearInterval') + await dispose(result) + expect(cleared.mock.calls.some(call => call[0] === revealHandle)).toBe(true) + intervals.mockRestore() + cleared.mockRestore() + const settled = result.terminal.output.length + await tick() + await tick() + expect(result.terminal.output.length).toBe(settled) + }) +}) + +/** Streams one fixed reply (or throws) so a test can drive the auto-title call. */ +class TitleAdapter extends LlmAdapter { + lastOptions: GenerateOptions | undefined + calls = 0 + constructor(private readonly reply: string | Error) { + super() + } + + async *stream(options: GenerateOptions): AsyncIterable { + this.calls += 1 + this.lastOptions = options + if (this.reply instanceof Error) throw this.reply + yield { type: 'block-start', index: 0, blockType: 'text' } + yield { type: 'text-delta', index: 0, text: this.reply } + yield { type: 'block-end', index: 0, block: { type: 'text', text: this.reply } } + yield { type: 'finish', reason: { kind: 'stop' } } + } +} + +/** Provide the `llm` service (with `adapter` on provider `mock`) plus the tools stub the TUI injects. */ +function withLlm(adapter: LlmAdapter): (ctx: Context) => Promise { + return async (ctx: Context) => { + await ctx.plugin(LlmService) + ctx.llm.registerAdapter(['mock'], adapter) + ctx.provide('tools', { get: () => undefined } as never) + } +} + +describe('TUI auto-title', () => { + const agentOptions: Agent['options'] = { provider: 'mock', model: 'mock-model' } + + it('replaces the title with a model-generated title after the first user message', async () => { + const adapter = new TitleAdapter('fix the login redirect') + const result = await setup({ config: { autoTitle: true }, agentOptions, configureContext: withLlm(adapter) }) + appendUser(result.session, 'the login page throws a 500 on submit, please investigate') + await tick() + expect(result.terminal.title).toBe('fix the login redirect') + // The request carries the task framing, the user's first message, and no tools. + expect(adapter.lastOptions?.provider).toBe('mock') + expect(adapter.lastOptions?.model).toBe('mock-model') + expect(adapter.lastOptions?.system).toContain('short title') + expect(adapter.lastOptions?.tools).toBeUndefined() + expect(adapter.lastOptions?.messages).toEqual([ + { role: 'user', content: [{ type: 'text', text: 'the login page throws a 500 on submit, please investigate' }] }, + ]) + await dispose(result) + }) + + it('requests a title only once, even after later user messages', async () => { + const adapter = new TitleAdapter('the settled title') + const result = await setup({ config: { autoTitle: true }, agentOptions, configureContext: withLlm(adapter) }) + appendUser(result.session, 'the first request that earns the title') + await tick() + expect(result.terminal.title).toBe('the settled title') + appendUser(result.session, 'a second request that must not re-title') + await tick() + expect(adapter.calls).toBe(1) + expect(result.terminal.title).toBe('the settled title') + await dispose(result) + }) + + it('uses the first non-empty line and truncates an over-long title with an ellipsis', async () => { + const adapter = new TitleAdapter('\n this title is deliberately far too long to fit a terminal tab \nextra') + const result = await setup({ config: { autoTitle: true }, agentOptions, configureContext: withLlm(adapter) }) + appendUser(result.session, 'do the big thing') + await tick() + expect(result.terminal.title).toBe('this title is deliberately far too long…') + expect(result.terminal.title.length).toBe(40) + await dispose(result) + }) + + it('skips a whitespace-only first message without consuming the one-shot slot', async () => { + const adapter = new TitleAdapter('the real title') + const result = await setup({ config: { autoTitle: true }, agentOptions, configureContext: withLlm(adapter) }) + appendUser(result.session, ' ') + await tick() + expect(adapter.lastOptions).toBeUndefined() + expect(result.terminal.title).toBe('DeepSeek Harness') + appendUser(result.session, 'the first real request') + await tick() + expect(result.terminal.title).toBe('the real title') + await dispose(result) + }) + + it('leaves the title unchanged when the model returns no usable text', async () => { + const adapter = new TitleAdapter(' \n ') + const result = await setup({ config: { autoTitle: true }, agentOptions, configureContext: withLlm(adapter) }) + appendUser(result.session, 'anything at all') + await tick() + expect(result.terminal.title).toBe('DeepSeek Harness') + await dispose(result) + }) + + it('leaves the title unchanged when the title request fails', async () => { + const adapter = new TitleAdapter(new Error('router unavailable')) + const result = await setup({ config: { autoTitle: true }, agentOptions, configureContext: withLlm(adapter) }) + appendUser(result.session, 'trigger a failing title request') + await tick() + expect(result.terminal.title).toBe('DeepSeek Harness') + await dispose(result) + }) + + it('re-derives the title on resume from the already-logged first user message', async () => { + const adapter = new TitleAdapter('resumed session title') + const result = await setup({ + config: { autoTitle: true }, + agentOptions, + configureContext: withLlm(adapter), + beforeMount: (session) => { + appendUser(session, 'the original first request') + appendUser(session, 'a later request that must not seed the title') + }, + }) + await tick() + // The title comes from the stored first message, not any later one. + expect(adapter.lastOptions?.messages).toEqual([ + { role: 'user', content: [{ type: 'text', text: 'the original first request' }] }, + ]) + expect(result.terminal.title).toBe('resumed session title') + // A message that arrives after the resume must not re-title. + appendUser(result.session, 'a follow-up message') + await tick() + expect(adapter.calls).toBe(1) + expect(result.terminal.title).toBe('resumed session title') + await dispose(result) + }) + + it('keeps the static title when auto-title is disabled', async () => { + const adapter = new TitleAdapter('should not run') + const result = await setup({ config: { autoTitle: false }, agentOptions, configureContext: withLlm(adapter) }) + appendUser(result.session, 'a normal message with the feature off') + await tick() + expect(adapter.lastOptions).toBeUndefined() + expect(result.terminal.title).toBe('DeepSeek Harness') + await dispose(result) + }) + + it('keeps the static title when no llm service is available', async () => { + const result = await setup({ config: { autoTitle: true }, agentOptions }) + appendUser(result.session, 'no model can answer this') + await tick() + expect(result.terminal.title).toBe('DeepSeek Harness') + await dispose(result) + }) + + it('keeps the static title when the agent has no provider', async () => { + const adapter = new TitleAdapter('unused') + const result = await setup({ + config: { autoTitle: true }, + agentOptions: { model: 'mock-model' }, + configureContext: withLlm(adapter), + }) + appendUser(result.session, 'the provider is missing') + await tick() + expect(adapter.lastOptions).toBeUndefined() + expect(result.terminal.title).toBe('DeepSeek Harness') + await dispose(result) + }) + + it('keeps the static title when the agent has no model', async () => { + const adapter = new TitleAdapter('unused') + const result = await setup({ + config: { autoTitle: true }, + agentOptions: { provider: 'mock' }, + configureContext: withLlm(adapter), + }) + appendUser(result.session, 'the model is missing') + await tick() + expect(adapter.lastOptions).toBeUndefined() + expect(result.terminal.title).toBe('DeepSeek Harness') + await dispose(result) + }) + + it('aborts an in-flight title request on shutdown', async () => { + const seen: { aborted: boolean } = { aborted: false } + class HangingAdapter extends LlmAdapter { + async *stream(options: GenerateOptions): AsyncIterable { + await new Promise((_resolve, reject) => { + options.signal?.addEventListener('abort', () => { + seen.aborted = true + reject(new Error('aborted')) + }) + }) + yield { type: 'finish', reason: { kind: 'stop' } } + } + } + const result = await setup({ config: { autoTitle: true }, agentOptions, configureContext: withLlm(new HangingAdapter()) }) + appendUser(result.session, 'start a title request that never resolves') + await tick() + await dispose(result) + expect(seen.aborted).toBe(true) + expect(result.terminal.title).toBe('DeepSeek Harness') + }) + + it('does not set the title when the UI is torn down before the stream completes', async () => { + let release: () => void = () => {} + const gate = new Promise((resolve) => { release = resolve }) + class GatedAdapter extends LlmAdapter { + // Yields a full reply, then blocks on the gate so the post-stream title + // apply runs only after the test has torn the UI down. Ignores `signal`, + // so shutdown's abort cannot cut the stream short. + async *stream(_options: GenerateOptions): AsyncIterable { + yield { type: 'block-start', index: 0, blockType: 'text' } + yield { type: 'text-delta', index: 0, text: 'title that arrives too late' } + yield { type: 'block-end', index: 0, block: { type: 'text', text: 'title that arrives too late' } } + yield { type: 'finish', reason: { kind: 'stop' } } + await gate + } + } + const result = await setup({ config: { autoTitle: true }, agentOptions, configureContext: withLlm(new GatedAdapter()) }) + appendUser(result.session, 'start a title that finishes after teardown') + await tick() + await dispose(result) + release() + await tick() + expect(result.terminal.title).toBe('DeepSeek Harness') + }) }) diff --git a/python/sdk-runtime/package.json b/python/sdk-runtime/package.json index 5d98cee538..32f2fa072b 100644 --- a/python/sdk-runtime/package.json +++ b/python/sdk-runtime/package.json @@ -43,6 +43,7 @@ "@deepseek-ai/dsh-paths": "workspace:^", "@deepseek-ai/dsh-repeat-tool-guard": "workspace:^", "@deepseek-ai/dsh-sandbox": "workspace:^", + "@deepseek-ai/dsh-sandbox-policy": "workspace:^", "@deepseek-ai/dsh-scope": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-persistence": "workspace:^", diff --git a/scripts/gen-tool-catalog.ts b/scripts/gen-tool-catalog.ts index 4650a0b753..867e67542b 100644 --- a/scripts/gen-tool-catalog.ts +++ b/scripts/gen-tool-catalog.ts @@ -14,6 +14,8 @@ import AgentRegistry from '@deepseek-ai/dsh-agent' import GoalService from '@deepseek-ai/dsh-goal' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry, { type Config as ToolsConfig } from '@deepseek-ai/dsh-tools' +import { BashExecutor } from '@deepseek-ai/dsh-bash' +import type { BashExecRequest, BashExecSpec, BashProcess, BashRunResult } from '@deepseek-ai/dsh-bash' import LocalBashExecutor from '@deepseek-ai/dsh-bash-local' import LocalFileSystem from '@deepseek-ai/dsh-fs-local' import UserInteractionService from '@deepseek-ai/dsh-user-interaction' @@ -44,6 +46,44 @@ import * as ToolWorkflow from '@deepseek-ai/dsh-tool-workflow' const root = resolve(import.meta.dirname, '..') const OUT = 'docs/tool-catalog.md' +const CATALOG_RG_PROBE_COMMAND = 'command -v rg >/dev/null 2>&1' + +/** + * Minimal bash service for harvesting `dsh-tool-fs-search` schemas. The search + * plugin now probes `rg` at registration time, but the generated catalog must + * remain independent of the host PATH and never execute a real search. + */ +class CatalogSearchBashExecutor extends BashExecutor { + override resolve(request: BashExecRequest): BashExecSpec { + return { + command: request.command, + workdir: request.workdir ?? root, + timeoutMs: request.timeoutMs ?? 60_000, + stdoutMaxBytes: request.stdoutMaxBytes ?? 64_000, + signal: request.signal, + sandboxMode: request.sandboxMode, + } + } + + override run(spec: BashExecSpec): Promise { + if (spec.command !== CATALOG_RG_PROBE_COMMAND) { + throw new Error(`gen-tool-catalog: unexpected search bash command during schema harvest: ${spec.command}`) + } + return Promise.resolve({ + exitCode: 0, + signal: null, + timedOut: false, + aborted: false, + timeoutMs: spec.timeoutMs, + stdout: { text: '', truncated: false }, + stderr: { text: '', truncated: false }, + }) + } + + override start(): BashProcess { + throw new Error('gen-tool-catalog: search schema harvest must not start background processes') + } +} /** * Register the descriptor needed to mount schema-producing consumers. Declares @@ -180,14 +220,15 @@ const TOOL_PACKAGES: ToolPackage[] = [ writes: ['tool/call', 'tool/result'], async mount(ctx) { // The tools inject `bash` (search executes fixed `rg` commands through - // the executor seam, not ctx.fs); boot the local executor to satisfy it. - // `ctx.spillStore` is optional (read via ctx.get) and does not affect the - // schemas, so no spill backend is mounted. - await ctx.plugin(LocalBashExecutor) + // the executor seam, not ctx.fs). Use a catalog-only executor so the + // registration-time `rg` probe stays deterministic and the generator + // never depends on the host PATH. `ctx.spillStore` is optional (read via + // ctx.get) and does not affect the schemas, so no spill backend is mounted. + await ctx.plugin(CatalogSearchBashExecutor) await ctx.plugin(ToolFsSearch) }, note: - 'glob and grep are bash-backed discovery tools: they run fixed ripgrep commands through ctx.bash as ordinary foreground calls (never background tasks). Capped results save the complete formatted list through the optional ctx.spillStore backend; returned locators are follow-up-readable/searchable when the backend exposes local paths in co-located deployments.', + 'glob and grep are conditional bash-backed discovery tools: they register only when ctx.bash can find `rg`, then run fixed ripgrep commands through ctx.bash as ordinary foreground calls (never background tasks). Capped results save the complete formatted list through the optional ctx.spillStore backend; returned locators are follow-up-readable/searchable when the backend exposes local paths in co-located deployments.', }, { pkg: '@deepseek-ai/dsh-tool-goal', diff --git a/scripts/verify-package-readme-model-experience.ts b/scripts/verify-package-readme-model-experience.ts index f492319cf7..11eef97698 100644 --- a/scripts/verify-package-readme-model-experience.ts +++ b/scripts/verify-package-readme-model-experience.ts @@ -48,12 +48,14 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly> = { 'packages/examples/agent-spine-demo': { kind: 'indirect', reason: 'The bundle only mounts model-facing child plugins.' }, 'packages/fs/fs': { kind: 'indirect', reason: 'The service interface delegates model rendering to dsh-tool-fs.' }, 'packages/fs/fs-local': { kind: 'indirect', reason: 'The provider backend delegates model rendering to dsh-tool-fs.' }, + 'packages/fs/fs-sandbox': { kind: 'indirect', reason: 'The provider backend delegates model rendering to dsh-tool-fs.' }, 'packages/hooks/hook-protocol': { kind: 'indirect', reason: 'Only the hook bridge plugins render decoded hook output to a model.' }, 'packages/llm/llm': { kind: 'none', reason: 'The adapter registry forwards already-assembled requests unchanged.' }, 'packages/llm/token-meter': { kind: 'indirect', reason: 'The measurement service leaves model-visible changes to its consumers.' }, 'packages/lsp/lsp': { kind: 'indirect', reason: 'The provider registry delegates model rendering to dsh-tool-lsp.' }, 'packages/lsp/lsp-local': { kind: 'indirect', reason: 'The provider backend delegates model rendering to dsh-tool-lsp.' }, 'packages/sandbox/sandbox-local': { kind: 'indirect', reason: 'The provider backend delegates model rendering to dsh-bash-sandbox and dsh-tool-bash.' }, + 'packages/sandbox/sandbox-policy': { kind: 'indirect', reason: 'The policy service holds the mode dsh-tool-bash and dsh-tool-fs render in their denial markers.' }, 'packages/sdk/create-sdk': { kind: 'indirect', reason: 'The initializer only writes project files; selected runtime plugins provide the generated project model surface.' }, 'packages/sdk/helper': { kind: 'none', reason: 'The project domain edits files and registers no live agent or model surface.' }, 'packages/sdk/scripts': { kind: 'indirect', reason: 'The launcher delegates model context to the loaded project plugin tree.' }, diff --git a/tsconfig.build.json b/tsconfig.build.json index 71e7a8d165..e332336315 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -57,11 +57,13 @@ { "path": "./packages/bash/bash-local" }, { "path": "./packages/sandbox/sandbox" }, { "path": "./packages/sandbox/sandbox-local" }, + { "path": "./packages/sandbox/sandbox-policy" }, { "path": "./packages/bash/bash-sandbox" }, { "path": "./packages/bash/tool-bash" }, { "path": "./packages/fs/fs" }, { "path": "./packages/fs/fs-local" }, { "path": "./packages/fs/fs-policy" }, + { "path": "./packages/fs/fs-sandbox" }, { "path": "./packages/fs/tool-fs" }, { "path": "./packages/fs/tool-fs-search" }, { "path": "./packages/web/web" }, diff --git a/tsconfig.json b/tsconfig.json index 99b7fe8b35..295e2a6365 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -68,11 +68,13 @@ { "path": "./packages/bash/bash-local" }, { "path": "./packages/sandbox/sandbox" }, { "path": "./packages/sandbox/sandbox-local" }, + { "path": "./packages/sandbox/sandbox-policy" }, { "path": "./packages/bash/bash-sandbox" }, { "path": "./packages/bash/tool-bash" }, { "path": "./packages/fs/fs" }, { "path": "./packages/fs/fs-local" }, { "path": "./packages/fs/fs-policy" }, + { "path": "./packages/fs/fs-sandbox" }, { "path": "./packages/fs/tool-fs" }, { "path": "./packages/fs/tool-fs-search" }, { "path": "./packages/compact/compact" }, From a2f17d71ed796bf94d6d70384d21a800c3c48423 Mon Sep 17 00:00:00 2001 From: Turtle Date: Wed, 22 Jul 2026 10:55:17 +0800 Subject: [PATCH 28/90] fix(vendor/include): config hot-reload keeps the last good tree and its patches --- ...-20-config-hot-reload-resilience.i18n.yaml | 6 + ...2026-07-20-config-hot-reload-resilience.md | 38 ++++++ ...6-07-20-config-hot-reload-resilience.zh.md | 38 ++++++ .../ui/app-boot/tests/config-reload.spec.ts | 128 ++++++++++++++++++ vendor/README.md | 1 + vendor/include/src/index.ts | 63 +++++++-- 6 files changed, 260 insertions(+), 14 deletions(-) create mode 100644 .agents/notes/implemented/bug-fix/2026-07-20-config-hot-reload-resilience.i18n.yaml create mode 100644 .agents/notes/implemented/bug-fix/2026-07-20-config-hot-reload-resilience.md create mode 100644 .agents/notes/implemented/bug-fix/2026-07-20-config-hot-reload-resilience.zh.md create mode 100644 packages/ui/app-boot/tests/config-reload.spec.ts diff --git a/.agents/notes/implemented/bug-fix/2026-07-20-config-hot-reload-resilience.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-20-config-hot-reload-resilience.i18n.yaml new file mode 100644 index 0000000000..b16ef70d7c --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-20-config-hot-reload-resilience.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 +2026-07-20-config-hot-reload-resilience.md: 1a8e29c603ede50b60199e9151fca58dadcc3d40 +2026-07-20-config-hot-reload-resilience.zh.md: 6c7a421bfa84504a36d5329e13a485bf72cc6b6c diff --git a/.agents/notes/implemented/bug-fix/2026-07-20-config-hot-reload-resilience.md b/.agents/notes/implemented/bug-fix/2026-07-20-config-hot-reload-resilience.md new file mode 100644 index 0000000000..1a8e29c603 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-20-config-hot-reload-resilience.md @@ -0,0 +1,38 @@ +# Agent Note: A config hot-reload must not kill or degrade a live app + +Status: implemented + +English | [中文](2026-07-20-config-hot-reload-resilience.zh.md) + +## Problem + +The demo apps mount `@cordisjs/plugin-hmr` as a leaf so a running agent picks up `cordis.yml` edits. One bad edit killed the process: `Include.refresh()` rethrew the YAML parse error, the HMR watcher awaits `refresh()` inside an async chokidar callback nobody catches, and the resulting unhandled rejection tripped `dsh-app-boot`'s fail-loud handler — `exit(1)` mid-session, losing the live TUI. Two adjacent defects made even *valid* reloads wrong: a file that parses to `undefined` (empty or mid-write truncated — editors and `sed -i` routinely produce these states) crashed the entry walk instead of reading as invalid, and a re-read never re-applied the include's `config.patches`, so any hot-reload of an overlay-based tree (Code Mode, personal overlays) silently reverted patched entries and removed inserted ones. + +## Decision + +Harden the vendored `@cordisjs/plugin-include` (logged as local modification 8 in [vendor/README.md](../../../../vendor/README.md)) rather than the callers: + +- `refresh()` awaits the whole read-and-update and catches failures, logs a warning, and keeps the last good entry tree. A hot-reload is advisory; the invariant is that no file state reachable by an editor may take the process down. +- `read()` rejects a non-array parse result with a `TypeError`, folding the `undefined`-parse case into the same "invalid file" signal, and commits `content`/`data` only after a successful parse — so reverting an edit to the exact last good content correctly reads as "unchanged". +- `refresh()` and the `internal/update` listener apply `this.applyPatches(...)` before `root.update()`, restoring parity with `[Service.init]`. `applyPatches` deep-copies the cached parse (`structuredClone`) instead of mutating it, so repeated application converges and removing a patch reverts to the file's own values. The listener uses the incoming config's `patches` and persists that config itself: it vetoes the fiber restart (children update in place), and `Fiber.update` only assigns `this.config` behind `next()`, so without the explicit assignment the next re-read would re-apply the old overlay. + +Boot-time behavior stays fail-loud and gets a sharper diagnostic: `[Service.init]` falls back to `initial` (or "config file not found") only on `ENOENT`; an existing-but-invalid file now fails with its real parse error instead of being mislabelled as absent or silently overwritten by `initial`. + +## Alternatives considered + +**Catch in the HMR watcher callback instead of `refresh()`.** Rejected: it would leave `refresh()` a trap for every other caller (the `internal/update` path shares the same tree-update logic), and it cannot fix the `undefined`-parse or patch-loss defects, which live inside the include. + +**Filter config-file rejections in `installFailLoud`.** Rejected: the fail-loud handler exists to make late load failures visible; teaching it to classify exceptions by origin would silently swallow genuine boot failures and leave the stale-`data` crash in place. + +**A PTY e2e proving the TUI survives a bad edit.** Rejected as the primary gate: the PTY smoke reads the repo's committed `cordis.yml`, so corrupting it in-place is not test-safe, and a temp copy cannot resolve the tree's bare package specifiers. The unit spec drives the exact `refresh()` entry point the watcher calls; the fix was additionally verified manually against the live TUI (bad YAML, empty file, restored file). + +## Consequences + +- A bad `cordis.yml` edit now logs `ignoring config reload at ` and the agent keeps running on the last good tree; the next valid edit applies normally. With no logger exporter mounted in the TUI demos the warning is currently invisible on screen — surfacing loader warnings in the TUI is deferred. +- Overlay trees survive base-file reloads with patches intact instead of silently reverting to the unpatched base. +- The vendored include diverges further from upstream; the divergence is logged in the vendor manifest and re-applies on the next sync. +- Known gap, out of scope here: the HMR watcher only handles chokidar `change` events, so editors that replace the file by rename (BSD `sed -i`, `git checkout`) do not trigger a config reload at all; and a reloaded app-entry config does not visibly restart the running TUI (pre-existing on the unmodified tree). + +## Testing + +`packages/ui/app-boot/tests/config-reload.spec.ts` boots real Loader trees against temp configs and pins: an invalid-YAML edit and an empty-file edit both resolve `refresh()` without rejection and keep the previous entry config; a subsequent valid edit applies; an overlay tree re-applies both entry patches and inserted entries on re-read; a hot-update of the include entry's own `patches` applies immediately, survives the next file re-read, and reverts cleanly when the patches are removed. The assertions fail on the unpatched vendored include. diff --git a/.agents/notes/implemented/bug-fix/2026-07-20-config-hot-reload-resilience.zh.md b/.agents/notes/implemented/bug-fix/2026-07-20-config-hot-reload-resilience.zh.md new file mode 100644 index 0000000000..6c7a421bfa --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-20-config-hot-reload-resilience.zh.md @@ -0,0 +1,38 @@ +# Agent Note: 配置热重载不得杀死或降级正在运行的应用 + +Status: implemented + +[English](2026-07-20-config-hot-reload-resilience.md) | 中文 + +## Problem + +各示例应用把 `@cordisjs/plugin-hmr` 挂载为叶子配置项,让运行中的 agent 能感知 `cordis.yml` 的编辑。一次错误的编辑就会杀死进程:`Include.refresh()` 把 YAML 解析错误原样抛出,HMR 的文件监听器在一个无人捕获的异步 chokidar 回调里 await `refresh()`,产生的未处理 rejection 触发 `dsh-app-boot` 的快速失败处理器——会话中途 `exit(1)`,正在运行的 TUI 就此丢失。另有两个相邻缺陷让*合法*的重载也出错:解析结果为 `undefined` 的文件(空文件或写入中途被截断的文件——编辑器和 `sed -i` 常态性地产生这类中间状态)会让配置项遍历直接崩溃,而不是被判定为无效文件;并且重新读取时从不重新应用 include 的 `config.patches`,因此对基于 overlay 的配置树(Code Mode、个人 overlay)做任何热重载,都会悄悄把打过补丁的配置项回退、并把插入的配置项移除。 + +## Decision + +加固 vendor 的 `@cordisjs/plugin-include`(在 [vendor/README.md](../../../../vendor/README.md) 中记录为本地修改第 8 条),而不是修改调用方: + +- `refresh()` await 整个「读取并更新」过程并捕获失败,记录一条警告,并保留上一份完好的配置树。热重载是尽力而为的;不变式是编辑器可能产生的任何文件状态都不得导致进程退出。 +- `read()` 对非数组的解析结果抛出 `TypeError`,把 `undefined` 解析结果并入同一个「无效文件」信号,并且只在解析成功后才提交 `content`/`data`——因此把编辑撤销回与上一份完好内容完全一致时,会正确地判定为「无变化」。 +- `refresh()` 与 `internal/update` 监听器在 `root.update()` 之前调用 `this.applyPatches(...)`,与 `[Service.init]` 保持一致。`applyPatches` 对缓存的解析结果做深拷贝(`structuredClone`)而不是就地修改,因此重复应用会收敛,移除补丁会回退到文件自身的值。监听器使用传入配置中的 `patches` 并自行持久化该配置:它否决 fiber 重启(子配置项就地更新),而 `Fiber.update` 只在 `next()` 之后才赋值 `this.config`,若不显式赋值,下一次重新读取会重新应用旧的 overlay。 + +启动期行为保持快速失败并获得更准确的诊断:`[Service.init]` 只在 `ENOENT` 时回退到 `initial`(或「config file not found」);存在但无效的文件现在会以真实的解析错误失败,而不是被误标为文件缺失、或被 `initial` 静默覆盖。 + +## Alternatives considered + +**在 HMR 监听回调里捕获,而不是在 `refresh()` 里。** 否决:这会让 `refresh()` 继续成为其他所有调用方的陷阱(`internal/update` 路径共享同一套树更新逻辑),而且无法修复 `undefined` 解析结果与补丁丢失这两个位于 include 内部的缺陷。 + +**在 `installFailLoud` 里过滤配置文件相关的 rejection。** 否决:快速失败处理器的存在意义就是让延迟出现的加载失败可见;教它按来源给异常分类会悄悄吞掉真正的启动失败,并且原样保留陈旧 `data` 导致的崩溃。 + +**用 PTY e2e 证明 TUI 能在错误编辑后存活。** 否决其作为主要门禁:PTY 冒烟测试读取仓库中已提交的 `cordis.yml`,就地破坏它对测试不安全,而临时副本无法解析该配置树的裸包说明符。单元测试直接驱动监听器所调用的 `refresh()` 入口;此外还对运行中的 TUI 做了人工验证(错误 YAML、空文件、恢复文件)。 + +## Consequences + +- 现在错误的 `cordis.yml` 编辑会记录 `ignoring config reload at `,agent 继续运行在上一份完好的配置树上;下一次合法编辑正常生效。TUI 示例没有挂载任何日志导出器,这条警告目前不会显示在屏幕上——在 TUI 中呈现 loader 警告的工作暂缓。 +- overlay 配置树在基础文件重载后补丁保持完整,不再悄悄回退到未打补丁的基础配置。 +- vendor 的 include 与上游进一步分叉;该分叉已记录在 vendor 的 manifest 里,下次同步时重新应用。 +- 已知缺口,不在本次范围内:HMR 监听器只处理 chokidar 的 `change` 事件,因此通过重命名替换文件的编辑方式(BSD `sed -i`、`git checkout`)完全不会触发配置重载;应用配置项重载后也不会可见地重启运行中的 TUI(未修改的代码树上即已如此)。 + +## Testing + +`packages/ui/app-boot/tests/config-reload.spec.ts` 用真实 Loader 树加载临时配置并固定以下行为:无效 YAML 编辑和空文件编辑都让 `refresh()` 正常 resolve 而不产生 rejection,并保留之前的配置项配置;随后的合法编辑正常生效;overlay 配置树在重新读取时重新应用配置项补丁和插入的配置项;对 include 配置项自身 `patches` 的热更新立即生效、在下一次文件重读后依然保持、并在补丁移除后干净地回退。这些断言在未打补丁的 vendor include 上会失败。 diff --git a/packages/ui/app-boot/tests/config-reload.spec.ts b/packages/ui/app-boot/tests/config-reload.spec.ts new file mode 100644 index 0000000000..05ebe80b58 --- /dev/null +++ b/packages/ui/app-boot/tests/config-reload.spec.ts @@ -0,0 +1,128 @@ +/** + * Config hot-reload resilience of the booted include tree. `dsh-app-boot` + * installs a fail-loud unhandled-rejection handler, so a `refresh()` that + * rethrows a config-file parse error would kill a live app on one bad + * `cordis.yml` edit (the HMR watcher awaits `refresh()` in an async event + * callback nobody else catches). These tests pin the vendored + * `@cordisjs/plugin-include` contract that boot relies on: an invalid file + * keeps the last good tree, and a valid re-read re-applies overlay patches + * exactly like the initial load. + */ + +import { mkdtempSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import type { Context } from 'cordis' +import type { Include } from '@cordisjs/plugin-include' +import { boot } from '../src/index.ts' + +const NAME = 'dsh-test-bin' + +const NOOP_PLUGIN = 'export const name = "noop"\nexport function apply() {}\n' + +interface TreeFixture { + ctx: Context + dir: string + include: Include +} + +async function bootTree(configBody: string): Promise { + const dir = mkdtempSync(join(tmpdir(), 'dsh-config-reload-')) + writeFileSync(join(dir, 'noop.mjs'), NOOP_PLUGIN) + writeFileSync(join(dir, 'cordis.yml'), configBody) + const ctx = await boot(NAME, join(dir, 'cordis.yml')) + const entry = [...ctx.loader.entries()].find(candidate => candidate.subtree !== undefined) + if (entry?.subtree === undefined) throw new Error('booted tree has no include entry') + return { ctx, dir, include: entry.subtree as Include } +} + +function entryConfig(ctx: Context, id: string): unknown { + return [...ctx.loader.entries()].find(entry => entry.options.id === id)?.options.config +} + +describe('include refresh with an invalid file', () => { + it('keeps the last good tree instead of throwing, then applies the next valid edit', async () => { + const { ctx, dir, include } = await bootTree('- id: noop\n name: ./noop.mjs\n config:\n value: 1\n') + try { + expect(entryConfig(ctx, 'noop')).toEqual({ value: 1 }) + + writeFileSync(join(dir, 'cordis.yml'), 'invalid: [unclosed\n') + await expect(include.refresh()).resolves.toBeUndefined() + expect(entryConfig(ctx, 'noop')).toEqual({ value: 1 }) + + // An empty file parses to `undefined` without a YAML error; it must be + // treated exactly like a parse failure, not crash the entry walk. + writeFileSync(join(dir, 'cordis.yml'), '') + await expect(include.refresh()).resolves.toBeUndefined() + expect(entryConfig(ctx, 'noop')).toEqual({ value: 1 }) + + writeFileSync(join(dir, 'cordis.yml'), '- id: noop\n name: ./noop.mjs\n config:\n value: 2\n') + await include.refresh() + await ctx.loader.await() + expect(entryConfig(ctx, 'noop')).toEqual({ value: 2 }) + } finally { + await ctx.fiber.dispose() + } + }) +}) + +describe('include refresh with overlay patches', () => { + it('re-applies entry patches and inserted entries on every re-read (parity with initial load)', async () => { + const dir = mkdtempSync(join(tmpdir(), 'dsh-config-reload-overlay-')) + writeFileSync(join(dir, 'noop.mjs'), NOOP_PLUGIN) + writeFileSync(join(dir, 'base.yml'), '- id: noop\n name: ./noop.mjs\n config:\n value: base\n') + writeFileSync(join(dir, 'cordis.yml'), [ + '- id: base', + " name: 'cordis:include'", + ' config:', + ' path: ./base.yml', + ' patches:', + ' - id: noop', + ' name: ./noop.mjs', + ' config:', + ' value: patched', + ' - insert:', + ' - id: extra', + ' name: ./noop.mjs', + '', + ].join('\n')) + const ctx = await boot(NAME, join(dir, 'cordis.yml')) + try { + const entry = [...ctx.loader.entries()].find(candidate => candidate.options.id === 'base') + if (entry?.subtree === undefined) throw new Error('overlay tree has no base include entry') + const include = entry.subtree as Include + expect(entryConfig(ctx, 'noop')).toEqual({ value: 'patched' }) + expect(entryConfig(ctx, 'extra')).toBeUndefined() + expect([...ctx.loader.entries()].some(candidate => candidate.options.id === 'extra')).toBe(true) + + writeFileSync(join(dir, 'base.yml'), '- id: noop\n name: ./noop.mjs\n config:\n value: edited\n') + await include.refresh() + await ctx.loader.await() + expect(entryConfig(ctx, 'noop')).toEqual({ value: 'patched' }) + expect([...ctx.loader.entries()].some(candidate => candidate.options.id === 'extra')).toBe(true) + + // Hot-update of the include entry's own config (the `internal/update` + // path): the new patches must apply now AND stick for later re-reads — + // the listener vetoes the fiber restart, so it must persist the new + // config itself or the next refresh() re-applies the old overlay. + await entry.update({ config: { path: './base.yml', patches: [{ id: 'noop', name: './noop.mjs', config: { value: 'patched-v2' } }] } }) + await ctx.loader.await() + expect(entryConfig(ctx, 'noop')).toEqual({ value: 'patched-v2' }) + expect([...ctx.loader.entries()].some(candidate => candidate.options.id === 'extra')).toBe(false) + + writeFileSync(join(dir, 'base.yml'), '- id: noop\n name: ./noop.mjs\n config:\n value: edited-2\n') + await include.refresh() + await ctx.loader.await() + expect(entryConfig(ctx, 'noop')).toEqual({ value: 'patched-v2' }) + + // Removing every patch must revert to the file's own values: patching + // may not bake earlier patch results into the cached parse. + await entry.update({ config: { path: './base.yml', patches: [] } }) + await ctx.loader.await() + expect(entryConfig(ctx, 'noop')).toEqual({ value: 'edited-2' }) + } finally { + await ctx.fiber.dispose() + } + }) +}) diff --git a/vendor/README.md b/vendor/README.md index ae43760ceb..1f4d61e6b1 100644 --- a/vendor/README.md +++ b/vendor/README.md @@ -37,6 +37,7 @@ Keep this log exhaustive — every divergence from upstream must be listed. 5. **`schemastery/tsdown.config.ts` and `logger-console/tsdown.config.ts`**: ours, not upstream files — per-package build-shape overrides (dual ESM+CJS output; separate node/browser entries) for the repo-root tsdown build. They read the JS emitted under `lib/types` and then write the publish runtime entries under `lib/`. Like the regenerated tsconfigs, they are not part of the upstream sync surface. 6. **`cordis/src/fiber.ts` lifecycle hardening**: locally closes three reentrant disposal gaps. An effect's owner-list wrapper is registered before its setup body runs, so an unload begun from inside setup awaits setup and every collected cleanup; synchronous setup failure removes the wrapper and rolls back collected cleanup. Async cleanup stays owner-visible until quiescence, and Cordis's internal effect composition joins an already-running cleanup while repeated public disposer calls retain their upstream single-shot result. Effect creation is rejected while the owner is `UNLOADING` (while `PENDING` and `LOADING` remain legal), preventing cleanup-time registrations from escaping the unload snapshot. Child fibers register and receive their parent-owned disposer before `internal/plugin` publication, resolve dependency declarations added by that notification before activation, drain effects attached while pending, skip plugin execution when reentrant disposal invalidates the load epoch before its first checkpoint, and contain teardown-notification failures per observer so one callback cannot starve peers or interrupt ownership cleanup. 7. **`cordis/src/*.ts` JSDoc enrichment**: added `@param`/`@returns` tags and contract documentation (disposal semantics, waterfall veto, bail conditions, error cases) across the public plugin-author surface — `Context` (class, statics, and the `Context` interface properties incl. `root`), `EventsService`, `Fiber`, `RegistryService`, `ReflectService`, `Service`, `LoggerService` and their `declare module './context.ts'` overloads. Comment-only; no code changes. Motivation: the website API-reference generator renders these docs and hard-errors on undocumented members. Retire this entry when the enrichment is upstreamed to the fork. +8. **`include/src/index.ts` hot-reload hardening**: `refresh()` awaits the full read-and-update and catches failures (logging a warning and keeping the last good entry tree) instead of rethrowing — upstream's throw escaped `@cordisjs/plugin-hmr`'s async watcher callback as an unhandled rejection, so one bad `cordis.yml` edit killed a live app. `read()` rejects a non-array parse result (an empty or mid-write truncated file parses to `undefined`, which upstream later crashed on) and commits `content`/`data` only on success, so reverting an edit to the exact last good content reads as "unchanged". `refresh()` and the `internal/update` listener re-apply `config.patches` before `root.update()`, matching initial load; upstream applied patches only in `[Service.init]`, so any config hot-reload silently reverted overlay-patched entries and removed inserted ones. `applyPatches` deep-copies via `structuredClone` instead of mutating the cached parse (repeated application converges; removing a patch reverts), and the veto-style `internal/update` listener persists the incoming config itself (`Fiber.update` only assigns behind `next()`), so later re-reads use the new patches. `[Service.init]` falls back to `initial` only on `ENOENT`; an existing-but-invalid file fails loud with its real parse error instead of "config file not found" (or a silent overwrite). Covered by `packages/ui/app-boot/tests/config-reload.spec.ts`. ## Sync procedure diff --git a/vendor/include/src/index.ts b/vendor/include/src/index.ts index 2258d3af06..b1517d5458 100644 --- a/vendor/include/src/index.ts +++ b/vendor/include/src/index.ts @@ -77,7 +77,15 @@ export class Include extends EntryTree { ctx.on('internal/update', (config, _, next) => { if (config.path !== this.config.path) return next() - this.root.update(this.data!) + // Veto the fiber restart (children update in place), but persist the new + // config ourselves — `Fiber.update` only assigns `this.config` behind + // `next()`, and a stale `this.config.patches` would make the next + // `refresh()` re-apply the old overlay. + this.config = config + this.root.update(this.applyPatches(this.data!, config.patches)).catch((error) => { + this.ctx.logger.warn('config update at %C failed', this.filename) + this.ctx.logger.warn(error) + }) }) } @@ -93,22 +101,37 @@ export class Include extends EntryTree { private async read(forced = false) { const content = await readFile(this.filename, 'utf8') if (!forced && this.content === content) return false - this.content = content + let data: any if (this.type === 'application/yaml') { - this.data = yaml.load(this.content, { schema }) as any + data = yaml.load(content, { schema }) } else if (this.type === 'application/json') { - this.data = JSON.parse(this.content) as any + data = JSON.parse(content) } else { const module = await import(/* @vite-ignore */ this.filename) - this.data = module.default || module + data = module.default || module } + // An empty or truncated file (common mid-edit: editors and `sed -i` write + // through temp states) parses to `undefined`, not an error; reject every + // non-array shape here so callers see one "invalid file" signal. Content + // and data commit only on success, so an edit that is later reverted to + // the exact last good content correctly reads as "unchanged". + if (!Array.isArray(data)) { + throw new TypeError(`config file must be a top-level array of entries: ${this.filename}`) + } + this.content = content + this.data = data await this.checkAccess() return true } - private applyPatches(data: EntryOptions[]): EntryOptions[] { - const { patches } = this.config - if (!patches?.length) return data + private applyPatches(data: EntryOptions[], patches = this.config.patches): EntryOptions[] { + // Always detach from the cached parse: patching shared entry objects would + // bake earlier patch values into `this.data`, so repeated application + // (config hot-reloads) could never revert a removed or changed patch. The + // supported extensions guarantee JSON-safe plain data, so `structuredClone` + // cannot throw here. + if (!patches?.length) return [...data] + data = structuredClone(data) const entryMap = new Map() const buildMap = (entries: EntryOptions[]) => { @@ -174,7 +197,11 @@ export class Include extends EntryTree { async* [Service.init]() { try { await this.read() - } catch { + } catch (error) { + // Only a missing file falls back to `initial` (or the not-found error): + // an existing-but-invalid file must fail loud with its real parse error, + // never be mislabelled as absent or silently overwritten. + if ((error as NodeJS.ErrnoException | null)?.code !== 'ENOENT') throw error if (this.config.initial) { this.writeFile(this.config.initial as any) await this.read() @@ -184,18 +211,26 @@ export class Include extends EntryTree { } yield () => this.stop() - const data = this.applyPatches([...this.data!]) - await this.root.update(data) + await this.root.update(this.applyPatches(this.data!)) } stop() { this.root.stop() } - /** Re-read the file and refresh child entries when content changed. */ + /** + * Re-read the file and refresh child entries when content changed. An + * unreadable or unparsable file logs a warning and keeps the last good + * tree: a hot-reload of a live app must never take the process down. + */ async refresh() { - if (!await this.read()) return - this.root.update(this.data!) + try { + if (!await this.read()) return + await this.root.update(this.applyPatches(this.data!)) + } catch (error) { + this.ctx.logger.warn('config reload at %C failed; keeping the running tree', this.filename) + this.ctx.logger.warn(error) + } } private async _writeFile(config: EntryOptions[]) { From 6baa030594e1f07f39c42c0c3f09a8cd436b363a Mon Sep 17 00:00:00 2001 From: Turtle Date: Wed, 22 Jul 2026 10:55:17 +0800 Subject: [PATCH 29/90] feat(cli): dsh CLI with personal config overlays from ~/.config/dsh --- ...26-07-20-dsh-cli-personal-config.i18n.yaml | 6 + .../2026-07-20-dsh-cli-personal-config.md | 49 ++++++ .../2026-07-20-dsh-cli-personal-config.zh.md | 49 ++++++ apps/cli/README.md | 19 ++ apps/cli/package.json | 18 ++ apps/cli/src/bin.ts | 23 +++ apps/cli/src/tui.ts | 50 ++++++ apps/cli/tsconfig.json | 15 ++ apps/cli/tsdown.config.ts | 18 ++ bin/dsh | 22 +++ eslint.config.mjs | 6 +- packages/ui/app-boot/package.json | 8 + .../ui/app-boot/tests/personal-config.spec.ts | 162 ++++++++++++++++++ pnpm-workspace.yaml | 3 + tsconfig.build.json | 3 +- tsconfig.json | 3 +- tsdown.config.ts | 2 +- 17 files changed, 450 insertions(+), 6 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.md create mode 100644 .agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.zh.md create mode 100644 apps/cli/README.md create mode 100644 apps/cli/package.json create mode 100644 apps/cli/src/bin.ts create mode 100644 apps/cli/src/tui.ts create mode 100644 apps/cli/tsconfig.json create mode 100644 apps/cli/tsdown.config.ts create mode 100755 bin/dsh create mode 100644 packages/ui/app-boot/tests/personal-config.spec.ts diff --git a/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.i18n.yaml b/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.i18n.yaml new file mode 100644 index 0000000000..8dd2bc2fdf --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.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 +2026-07-20-dsh-cli-personal-config.md: 7849f6317f290a677bae45219012edd2ad9e7211 +2026-07-20-dsh-cli-personal-config.zh.md: 77b3c319da975a2d52e3d50fda361edd4a1bd23c diff --git a/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.md b/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.md new file mode 100644 index 0000000000..7849f6317f --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.md @@ -0,0 +1,49 @@ +# Agent Note: The dsh CLI and personal config overlays from ~/.config/dsh + +Status: implemented + +English | [中文](2026-07-20-dsh-cli-personal-config.zh.md) + +## Problem + +A developer's own preferences — which provider and model the TUI uses, personal credentials, a private adapter route — had nowhere to live except edits to committed files. Pointing the TUI demo at a personal Anthropic-proxy Opus route meant patching `examples/tui-agent/cordis.yml` and `.env` in the working tree, which risks committing secrets and repeats per checkout. There was also no installable command: running the agent in an arbitrary project directory required invoking the repo's demo script from the repo root. Loader metadata is static, so "conditional composition uses overlays" (AGENTS.md) — but overlays only existed as committed sibling files, not as a machine-level layer. + +## Decision + +Two coupled pieces, aligned with the `apps/` assembly tier proposed by the `dsh web` PR (#443): + +**The `dsh` CLI (`apps/cli`, npm name `@deepseek-ai/dsh`).** `apps/*` joins the workspaces as the product-assembly tier over `packages/*` libraries. The bin's dispatch reserves `web` and `-p`/`--prompt` for PR #443 (they exit with a pointer) so the two branches merge as a near-union; everything else runs the default surface: the interactive TUI, booting the shipped `examples/tui-agent/cordis.yml` (or an explicit config argument) with the invoking directory as the workspace. The committed `bin/dsh` launcher resolves the checkout through its own real path and runs the bin **from source** via the repo's tsx (with `--expose-internals` for the config's HMR entry), so `ln -sf "$(pwd)/bin/dsh" ~/.local/bin/dsh` installs a command that always executes the current working tree. `pnpm run demo:tui` runs the same entry. + +**Personal config (`dsh-app-boot`).** The personal config directory resolves as `$DSH_CONFIG_HOME`, else `$XDG_CONFIG_HOME/dsh`, else `~/.config/dsh` (`resolvePersonalConfigDir`; empty variables read as unset). The dsh TUI surface consumes its two optional files; the demo bins boot their committed trees verbatim: + +- `.env` — loaded after the invoking directory's `.env`; `process.loadEnvFile` never overrides, so precedence is ambient > project `.env` > personal `.env`. +- `config.yaml` — a top-level YAML array of `@cordisjs/plugin-include` `PatchOptions`, parsed with the include's own `!!js` dialect (`loadPersonalPatches`) and passed to `boot()`, which forwards it as the root include's `patches`. Patch semantics are exactly the committed overlay semantics (the Code Mode overlay is the template): an id-targeted patch replaces the named entry's whole `config`, `insert` appends entries, an unmatched id warns and is skipped. +- A missing file means no overlay; a present-but-unreadable, unparsable, or non-array file throws at boot (misconfiguration fails loud, never a silent skip). + +The PTY smoke's launcher isolates `DSH_CONFIG_HOME` to a per-test directory, exactly as it already isolates `DSH_HOME`/`DSH_AGENTS_HOME`, so a developer's real personal overlay cannot leak into fixtures; only the dsh CLI reads personal config, so no other test launcher needed changes. + +Hot-reload interplay: the include re-applies its `patches` on every config re-read (the [config hot-reload resilience Agent Note](../bug-fix/2026-07-20-config-hot-reload-resilience.md)), so a live `cordis.yml` edit keeps the personal overlay applied. + +## Alternatives considered + +**A standalone `bin/dsh` wrapper owning the `dsh` name.** Rejected after reading PR #443: that PR establishes `apps/cli` as the `dsh` CLI with subcommand dispatch (`web`, `-p`) and leaves the default slot unclaimed. Two competing `dsh` entrypoints would collide in `$PATH` and in product identity; claiming the default slot inside the same package shape confines the eventual merge conflict to the small dispatch chain. + +**A pi-style typed settings file (`defaultProvider`/`defaultModel`/`providers`).** Rejected by the user in favor of patch semantics: the personal file is a cordis overlay over the shipped default config, not a second config vocabulary to own and translate. + +**A personal full `cordis.yml` that includes the requested config.** Rejected: the personal file would have to name the leaf config's path, which varies per checkout; patches invert the dependency so the bin keeps choosing the tree and the personal layer only amends it. + +**Deep-merging personal patches into entry configs.** Rejected: it would fork the patch semantics from the committed overlays and the vendored include; whole-config replacement is already the documented contract. + +**Opt-in via env flag instead of presence.** Rejected: personal config that is off by default never gets used; presence plus explicit per-test isolation gives live runs the overlay and tests hermeticity. + +## Consequences + +- `dsh` from any directory (and `pnpm run demo:tui`) boots the personal provider/model with zero repo changes; verified end-to-end against a personal Anthropic proxy with Opus 4.8, including a bash tool round trip. +- Because an id-targeted patch replaces the whole `config`, a personal override restates the base fields it keeps and can drift when the base entry changes shape; the loader's entry-not-found/name-mismatch warnings are the only diagnostics. +- Personal patches resolve ids against the booted file's own tree, so nested-include overlays (Code Mode) are not personalized; live-run parity for those leaves is deferred. +- `dsh-app-boot` gains a real dependency (`js-yaml`) and a load-only copy of the include's `!!js` YAML type. +- When PR #443 lands, `apps/cli/src/bin.ts`'s dispatch chain and `apps/cli/package.json`'s dependency list conflict textually; both resolve as unions (their `web`/`-p` branches plus our default-TUI branch). + +## Testing + +`packages/ui/app-boot/tests/personal-config.spec.ts` pins directory precedence (including empty-variable fallback), `!!js` preservation and end-to-end interpolation through a booted tree, insert entries, the absent/empty no-op paths, and the three fail-loud shapes (unreadable, unparsable, non-array). `examples/tui-agent/tests/tui-keyless-smoke.e2e.ts` boots the dsh bin in a PTY three ways: default config with no overlay, a personal `.env` + `config.yaml` chain whose patched welcome renders in the banner, and an invalid personal file failing the boot loudly. The pre-existing smokes and snapshot suites pass on a machine whose real `~/.config/dsh` overlay would change the booted model — the isolation, not luck. diff --git a/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.zh.md b/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.zh.md new file mode 100644 index 0000000000..77b3c319da --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.zh.md @@ -0,0 +1,49 @@ +# Agent Note: dsh CLI 与来自 ~/.config/dsh 的个人配置 overlay + +Status: implemented + +[English](2026-07-20-dsh-cli-personal-config.md) | 中文 + +## Problem + +开发者自己的偏好——TUI 使用哪个提供方和模型、个人凭证、私有的适配器路由——除了改动已提交的文件之外无处安放。要把 TUI 示例指向个人的 Anthropic 代理 Opus 路由,只能在工作区里改 `examples/tui-agent/cordis.yml` 和 `.env`,既有提交密钥的风险,又要在每个 checkout 里重复一遍。也没有可安装的命令:想在任意项目目录里运行这个 agent,必须回到仓库根目录调用示例脚本。Loader 元数据是静态的,所以「条件组合使用 overlay」(AGENTS.md)——但 overlay 此前只以已提交的同级文件形式存在,没有机器级的层。 + +## Decision + +两个耦合的部分,与 `dsh web` PR(#443)提出的 `apps/` 装配层对齐: + +**`dsh` CLI(`apps/cli`,npm 名 `@deepseek-ai/dsh`)。** `apps/*` 作为 `packages/*` 库之上的产品装配层加入 workspaces。bin 的分发把 `web` 和 `-p`/`--prompt` 保留给 PR #443(它们以指引退出),使两个分支能以接近并集的方式合并;其余一切都运行默认表面:交互式 TUI,加载随仓库提供的 `examples/tui-agent/cordis.yml`(或显式的配置参数),并以调用目录为工作区。已提交的 `bin/dsh` 启动器通过自身真实路径解析 checkout,用仓库的 tsx **从源码**运行该 bin(带 `--expose-internals`,供配置里的 HMR 配置项使用),因此 `ln -sf "$(pwd)/bin/dsh" ~/.local/bin/dsh` 安装的命令永远执行当前工作树。`pnpm run demo:tui` 运行同一入口。 + +**个人配置(`dsh-app-boot`)。** 个人配置目录按 `$DSH_CONFIG_HOME`、其次 `$XDG_CONFIG_HOME/dsh`、最后 `~/.config/dsh` 解析(`resolvePersonalConfigDir`;空变量视为未设置)。dsh 的 TUI 表面消费其中两个可选文件;各示例 bin 仍然逐字节按已提交的配置树启动: + +- `.env`——在调用目录的 `.env` 之后加载;`process.loadEnvFile` 从不覆盖已有值,因此优先级为环境变量 > 项目 `.env` > 个人 `.env`。 +- `config.yaml`——顶层 YAML 数组,元素为 `@cordisjs/plugin-include` 的 `PatchOptions`,用 include 自己的 `!!js` 方言解析(`loadPersonalPatches`)并传给 `boot()`,由它作为根 include 的 `patches` 转发。补丁语义与已提交 overlay 完全一致(Code Mode overlay 是模板):按 id 定位的补丁替换该配置项的整个 `config`,`insert` 追加配置项,未匹配的 id 记录警告并跳过。 +- 文件缺失即无 overlay;文件存在但不可读、不可解析或非数组则在启动时抛出(配置错误响亮失败,绝不静默跳过)。 + +PTY 冒烟测试的启动器把 `DSH_CONFIG_HOME` 隔离到每个测试自己的目录,与它已有的 `DSH_HOME`/`DSH_AGENTS_HOME` 隔离方式完全一致,开发者真实的个人 overlay 不可能泄漏进 fixture;只有 dsh CLI 读取个人配置,因此其他测试启动器无需改动。 + +与热重载的交互:include 在每次配置重读时重新应用其 `patches`(见[配置热重载韧性 Agent Note](../bug-fix/2026-07-20-config-hot-reload-resilience.md)),因此运行中编辑 `cordis.yml` 后个人 overlay 仍保持生效。 + +## Alternatives considered + +**独立的 `bin/dsh` 包装脚本占有 `dsh` 这个名字。** 读过 PR #443 后否决:该 PR 把 `apps/cli` 确立为带子命令分发(`web`、`-p`)的 `dsh` CLI,并且默认位空缺。两个互相竞争的 `dsh` 入口会在 `$PATH` 和产品身份上冲突;在同一包形态内认领默认位,把最终的合并冲突限制在小小的分发链上。 + +**pi 风格的类型化设置文件(`defaultProvider`/`defaultModel`/`providers`)。** 用户否决,选择补丁语义:个人文件是叠加在随仓库提供的默认配置之上的 cordis overlay,而不是需要另行拥有和翻译的第二套配置词汇。 + +**个人完整 `cordis.yml` 去 include 请求的配置。** 否决:个人文件将不得不写死叶子配置的路径,而该路径随 checkout 变化;补丁反转了依赖方向,bin 仍然选择配置树,个人层只做修正。 + +**把个人补丁深合并进配置项配置。** 否决:会使补丁语义与已提交 overlay 和 vendor 的 include 分叉;整个 `config` 替换已是成文契约。 + +**用环境变量开关代替存在性判断。** 否决:默认关闭的个人配置永远不会被用起来;存在即生效加上每个测试的显式隔离,让实际运行获得 overlay、测试获得封闭性。 + +## Consequences + +- 在任意目录运行 `dsh`(以及 `pnpm run demo:tui`)即可零仓库改动地使用个人提供方/模型;已针对个人 Anthropic 代理与 Opus 4.8 端到端验证,包括一次 bash 工具往返。 +- 由于按 id 定位的补丁替换整个 `config`,个人覆盖必须复述它保留的基础字段,并可能随基础配置项形态变化而漂移;loader 的「配置项未找到/名称不匹配」警告是仅有的诊断。 +- 个人补丁只在被启动文件自身的树里解析 id,因此嵌套 include 的 overlay(Code Mode)不会被个性化;这些叶子的实际运行等价性暂缓。 +- `dsh-app-boot` 新增一个真实依赖(`js-yaml`)和一份只用于加载的 include `!!js` YAML 类型副本。 +- PR #443 落地时,`apps/cli/src/bin.ts` 的分发链与 `apps/cli/package.json` 的依赖列表会产生文本冲突;两者都按并集解决(他们的 `web`/`-p` 分支加上我们的默认 TUI 分支)。 + +## Testing + +`packages/ui/app-boot/tests/personal-config.spec.ts` 固定目录优先级(含空变量回退)、`!!js` 的保留与经真实启动树的端到端插值、insert 配置项、缺失/为空的无操作路径,以及三种响亮失败形态(不可读、不可解析、非数组)。`examples/tui-agent/tests/tui-keyless-smoke.e2e.ts` 在 PTY 里以三种方式启动 dsh bin:无 overlay 的默认配置、个人 `.env` + `config.yaml` 链条(打补丁的欢迎语渲染进横幅)、以及无效个人文件导致的响亮启动失败。既有冒烟与快照套件在一台真实 `~/.config/dsh` overlay 会改变启动模型的机器上通过——靠隔离,不靠运气。 diff --git a/apps/cli/README.md b/apps/cli/README.md new file mode 100644 index 0000000000..21479e4b3f --- /dev/null +++ b/apps/cli/README.md @@ -0,0 +1,19 @@ +# `@deepseek-ai/dsh` + +The `dsh` command-line entry, following the `apps/` assembly tier proposed by the `dsh web` PR (#443): `apps/*` are product assemblies over `packages/*` libraries. This branch ships one surface — plain `dsh [config.yml]` boots the interactive TUI coding agent — and reserves the `web` and `-p`/`--prompt` subcommands for that PR so the dispatch merges as a union. + +The TUI surface: + +- boots the shipped default config (`examples/tui-agent/cordis.yml`) or an explicit config argument, through [`dsh-app-boot`](../../packages/ui/app-boot/README.md); +- treats the **invoking directory** as the workspace — sessions, relative paths, and workspace instructions resolve from the cwd; +- applies the personal overlay from `~/.config/dsh` (see [app-boot's Personal config](../../packages/ui/app-boot/README.md#personal-config)): `.env` fills environment gaps (ambient > project `.env` > personal `.env`), `config.yaml` patches the booted tree. + +## Install (developer machine) + +Symlink the source-running launcher onto your PATH; it resolves the checkout through its own real path, so code changes apply on the next launch with no build step: + +```sh +ln -sf "$(pwd)/bin/dsh" ~/.local/bin/dsh +``` + +`pnpm run demo:tui` runs the same entry from the repo root. The built form (`lib/bin.js`, via `pnpm run build`) needs `node --expose-internals` for the shipped config's HMR entry, exactly like the demo bins. diff --git a/apps/cli/package.json b/apps/cli/package.json new file mode 100644 index 0000000000..bae172303a --- /dev/null +++ b/apps/cli/package.json @@ -0,0 +1,18 @@ +{ + "name": "@deepseek-ai/dsh", + "description": "dsh CLI: the interactive TUI coding agent, booting the shipped default config with the personal overlay from ~/.config/dsh", + "version": "0.0.1", + "private": true, + "type": "module", + "bin": { + "dsh": "lib/bin.js" + }, + "files": [ + "lib/bin.js", + "src" + ], + "license": "BSD-3-Clause", + "dependencies": { + "@deepseek-ai/dsh-app-boot": "workspace:^" + } +} diff --git a/apps/cli/src/bin.ts b/apps/cli/src/bin.ts new file mode 100644 index 0000000000..d05cc0820d --- /dev/null +++ b/apps/cli/src/bin.ts @@ -0,0 +1,23 @@ +#!/usr/bin/env node +/** + * dsh — command-line entry. Coarse dispatch only; each surface module owns its + * own argument handling. `web` and `-p`/`--prompt` are reserved for the + * browser GUI and headless surfaces (PR #443) so that dispatch merges as a + * union; everything else is the interactive TUI, the default surface. + * @module @deepseek-ai/dsh/bin + */ + +/* v8 ignore file -- thin self-executing dispatch; the tui-agent PTY smoke + exercises the TUI path end to end */ + +import { loadEnv } from '@deepseek-ai/dsh-app-boot' +import { runTui } from './tui.ts' + +loadEnv('dsh') +const argv = process.argv.slice(2) + +if (argv[0] === 'web' || argv.includes('-p') || argv.includes('--prompt')) { + process.stderr.write('dsh: the web and headless surfaces are not on this branch (PR #443); run the TUI: dsh [config.yml]\n') + process.exit(1) +} +await runTui(argv) diff --git a/apps/cli/src/tui.ts b/apps/cli/src/tui.ts new file mode 100644 index 0000000000..ef9ff91fb2 --- /dev/null +++ b/apps/cli/src/tui.ts @@ -0,0 +1,50 @@ +/** + * `dsh` default surface — the interactive TUI coding agent. Boots the shipped + * tui-agent config (or an explicit config argument) with the personal overlay + * from `~/.config/dsh`: its `.env` fills environment gaps (precedence: ambient + * environment, then the invoking directory's `.env`, then the personal one) + * and its `config.yaml` patches the booted tree. The workspace is the invoking + * directory: sessions, relative paths, and workspace instructions resolve from + * the cwd, so `dsh` acts on whatever project it is launched in. + * @module @deepseek-ai/dsh/tui + */ + +import { fileURLToPath } from 'node:url' +import { + boot, + installFailLoud, + loadEnv, + loadPersonalPatches, + resolveConfigPath, + resolvePersonalConfigDir, +} from '@deepseek-ai/dsh-app-boot' + +const NAME = 'dsh' + +// Both the source tree (apps/cli/src) and the bundled bin (apps/cli/lib) sit +// one directory under apps/cli, so the shipped default config resolves with +// the same relative hop from either artifact. +const DEFAULT_CONFIG = fileURLToPath(new URL('../../../examples/tui-agent/cordis.yml', import.meta.url)) + +/* v8 ignore start -- composition over the unit-tested dsh-app-boot helpers; + the tui-agent PTY smoke drives this path end to end, personal overlay included */ +/** + * Run the interactive TUI from the invoking directory. + * @param argv - arguments after the subcommand dispatch; `argv[0]` may name a + * config to boot instead of the shipped default. + */ +export async function runTui(argv: string[]): Promise { + // Refuse pipes BEFORE booting: a compose-time throw inside the Loader tree + // is logged per-entry rather than rethrown, so a piped launch would + // otherwise settle into an idle UI-less process instead of exiting nonzero. + if (!process.stdin.isTTY || !process.stdout.isTTY) { + process.stderr.write(`${NAME}: the TUI requires stdin and stdout to be interactive TTYs\n`) + process.exit(1) + } + installFailLoud(NAME) + // The bin already loaded the invoking directory's .env; the personal .env + // only fills what is still unset (process.loadEnvFile never overrides). + loadEnv(NAME, resolvePersonalConfigDir()) + await boot(NAME, resolveConfigPath(argv[0] ?? DEFAULT_CONFIG, undefined), loadPersonalPatches(NAME)) +} +/* v8 ignore stop */ diff --git a/apps/cli/tsconfig.json b/apps/cli/tsconfig.json new file mode 100644 index 0000000000..c0f389409b --- /dev/null +++ b/apps/cli/tsconfig.json @@ -0,0 +1,15 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../packages/ui/app-boot" + } + ] +} diff --git a/apps/cli/tsdown.config.ts b/apps/cli/tsdown.config.ts new file mode 100644 index 0000000000..68ca2254e0 --- /dev/null +++ b/apps/cli/tsdown.config.ts @@ -0,0 +1,18 @@ +import { defineConfig } from 'tsdown' + +/** + * The dsh CLI ships one entry: the `bin` referenced by package.json `bin`. + * The root tsdown builds only `lib/types/index.js`, so this override points at + * `lib/types/bin.js` instead; the statically imported surface modules bundle + * into it. Declarations come from `tsc -b` (dts: false), matching every package. + */ +export default defineConfig({ + entry: ['lib/types/bin.js'], + outDir: 'lib', + format: ['esm'], + platform: 'node', + target: 'es2024', + fixedExtension: false, + dts: false, + clean: false, +}) diff --git a/bin/dsh b/bin/dsh new file mode 100755 index 0000000000..88eaa0ab71 --- /dev/null +++ b/bin/dsh @@ -0,0 +1,22 @@ +#!/bin/sh +# dsh launcher: runs the apps/cli `dsh` bin FROM SOURCE with this checkout's +# tsx, so a symlink from anywhere (e.g. ~/.local/bin/dsh) always executes the +# current working tree — code changes apply on the next launch, no build step. +# --expose-internals: the shipped config mounts HMR, which needs Loader internals. +set -eu + +# Resolve symlink chains without readlink -f (not on every macOS). +script=$0 +while [ -L "$script" ]; do + target=$(readlink "$script") + case $target in + /*) script=$target ;; + *) script=$(dirname "$script")/$target ;; + esac +done +root=$(CDPATH='' cd -- "$(dirname -- "$script")/.." && pwd) + +# tsx is imported by absolute path because bare `--import tsx` resolves from +# the invoking cwd, which is usually outside this repository. +export TSX_TSCONFIG_PATH="$root/tsconfig.json" +exec node --expose-internals --import "$root/node_modules/tsx/dist/loader.mjs" "$root/apps/cli/src/bin.ts" "$@" diff --git a/eslint.config.mjs b/eslint.config.mjs index 03dcf7edac..23b35475ec 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -23,7 +23,7 @@ export default tseslint.config( // --- our packages: full strictness ------------------------------------- { - files: ['packages/*/*/src/**/*.ts', 'examples/**/*.ts', 'scripts/**/*.ts', 'website/**/*.ts'], + files: ['packages/*/*/src/**/*.ts', 'apps/*/src/**/*.ts', 'examples/**/*.ts', 'scripts/**/*.ts', 'website/**/*.ts'], extends: [ ...tseslint.configs.strictTypeChecked, ], @@ -110,7 +110,7 @@ export default tseslint.config( // --- file-local duplication (all owned TypeScript) --------------------- { - files: ['packages/**/*.ts', 'examples/**/*.ts', 'scripts/**/*.ts', 'website/**/*.ts'], + files: ['packages/**/*.ts', 'apps/**/*.ts', 'examples/**/*.ts', 'scripts/**/*.ts', 'website/**/*.ts'], plugins: { sonarjs }, rules: { // Cross-file clones are covered separately by jscpd. @@ -127,7 +127,7 @@ export default tseslint.config( // --- formatting (everything we own) ------------------------------------- { - files: ['packages/**/*.ts', 'examples/**/*.ts', 'scripts/**/*.ts', 'website/**/*.ts', 'eslint.config.mjs'], + files: ['packages/**/*.ts', 'apps/**/*.ts', 'examples/**/*.ts', 'scripts/**/*.ts', 'website/**/*.ts', 'eslint.config.mjs'], plugins: { '@stylistic': stylistic }, rules: { '@stylistic/indent': ['error', 2], diff --git a/packages/ui/app-boot/package.json b/packages/ui/app-boot/package.json index e2f0631e8b..ef267e8588 100644 --- a/packages/ui/app-boot/package.json +++ b/packages/ui/app-boot/package.json @@ -26,16 +26,24 @@ "src" ], "license": "BSD-3-Clause", + "dependencies": { + "js-yaml": "^4.2.0" + }, "peerDependencies": { "@cordisjs/plugin-include": "^1.0.4", "@cordisjs/plugin-loader": "^1.0.0-rc.5", "@deepseek-ai/dsh-invariants": "^0.0.1", + "@deepseek-ai/dsh-paths": "^0.0.1", + "@deepseek-ai/dsh-system-prompt": "^0.0.1", "cordis": "^4.0.0-rc.7" }, "devDependencies": { "@cordisjs/plugin-include": "workspace:^", "@cordisjs/plugin-loader": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-paths": "workspace:^", + "@deepseek-ai/dsh-system-prompt": "workspace:^", + "@types/js-yaml": "^4.0.9", "cordis": "^4.0.0-rc.7" } } diff --git a/packages/ui/app-boot/tests/personal-config.spec.ts b/packages/ui/app-boot/tests/personal-config.spec.ts new file mode 100644 index 0000000000..de29c6d0bf --- /dev/null +++ b/packages/ui/app-boot/tests/personal-config.spec.ts @@ -0,0 +1,162 @@ +/** + * Personal-config behavior of `dsh-app-boot`: the `~/.config/dsh` directory + * resolution, the `config.yaml` overlay loader, and `boot()` applying the + * personal overlay over a real Loader tree. + */ + +import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join, resolve, sep } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import type { Context } from 'cordis' +import { + boot, + DSH_CONFIG_HOME_ENV, + loadPersonalPatches, + PERSONAL_CONFIG_FILENAME, + resolvePersonalConfigDir, +} from '../src/index.ts' + +const NAME = 'dsh-test-bin' + +const tmp = (): string => mkdtempSync(join(tmpdir(), 'dsh-personal-config-')) + +describe('resolvePersonalConfigDir', () => { + it('prefers $DSH_CONFIG_HOME, then $XDG_CONFIG_HOME/dsh, then ~/.config/dsh', () => { + const home = `${sep}home${sep}user` + expect(resolvePersonalConfigDir({ DSH_CONFIG_HOME: `${sep}explicit`, XDG_CONFIG_HOME: `${sep}xdg` }, home)) + .toBe(resolve(`${sep}explicit`)) + expect(resolvePersonalConfigDir({ XDG_CONFIG_HOME: `${sep}xdg` }, home)) + .toBe(resolve(`${sep}xdg`, 'dsh')) + expect(resolvePersonalConfigDir({}, home)).toBe(resolve(home, '.config', 'dsh')) + }) + + it('treats empty variables as unset and defaults to the real env and home', () => { + const home = `${sep}home${sep}user` + expect(resolvePersonalConfigDir({ DSH_CONFIG_HOME: '', XDG_CONFIG_HOME: '' }, home)) + .toBe(resolve(home, '.config', 'dsh')) + // Default-arg arm: resolves against the ambient environment without throwing. + expect(resolvePersonalConfigDir().length).toBeGreaterThan(0) + }) +}) + +describe('loadPersonalPatches', () => { + afterEach(() => { + delete process.env.DSH_CONFIG_HOME + }) + + it('returns undefined when no personal patches file exists', () => { + expect(loadPersonalPatches(NAME, tmp())).toBeUndefined() + }) + + it('parses a patch list and preserves !!js expressions as loader expression nodes', () => { + const dir = tmp() + writeFileSync(join(dir, PERSONAL_CONFIG_FILENAME), [ + '- id: tui-agent', + " name: '@deepseek-ai/dsh-tui-demo'", + ' config:', + ' model: !!js process.env.DSH_SPEC_MODEL', + '- insert:', + ' - id: llm', + " name: '@deepseek-ai/dsh-llm-pi-ai'", + '', + ].join('\n')) + const patches = loadPersonalPatches(NAME, dir) + expect(patches).toHaveLength(2) + expect(patches?.[0]).toMatchObject({ + id: 'tui-agent', + config: { model: { __jsExpr: 'process.env.DSH_SPEC_MODEL' } }, + }) + expect(patches?.[1]?.insert).toHaveLength(1) + }) + + it('defaults its directory to the resolved personal config dir', () => { + const dir = tmp() + writeFileSync(join(dir, PERSONAL_CONFIG_FILENAME), '- id: x\n config:\n a: 1\n') + process.env[DSH_CONFIG_HOME_ENV] = dir + expect(loadPersonalPatches(NAME)).toHaveLength(1) + }) + + it('fails loud on an unreadable file (a present personal config is never skipped)', () => { + const dir = tmp() + mkdirSync(join(dir, PERSONAL_CONFIG_FILENAME)) // a directory: present, unreadable as a file + expect(() => loadPersonalPatches(NAME, dir)) + .toThrow(new RegExp(`^${NAME}: failed to read personal patches `)) + }) + + it('fails loud on unparsable YAML and on a !!js tag with no expression body', () => { + const dir = tmp() + writeFileSync(join(dir, PERSONAL_CONFIG_FILENAME), 'invalid: [unclosed\n') + expect(() => loadPersonalPatches(NAME, dir)) + .toThrow(new RegExp(`^${NAME}: failed to parse personal patches `)) + writeFileSync(join(dir, PERSONAL_CONFIG_FILENAME), '- id: x\n config:\n a: !!js\n') + expect(() => loadPersonalPatches(NAME, dir)) + .toThrow(new RegExp(`^${NAME}: failed to parse personal patches `)) + }) + + it('fails loud when the file is not a top-level array or an entry is not an object', () => { + const dir = tmp() + writeFileSync(join(dir, PERSONAL_CONFIG_FILENAME), 'id: not-a-list\n') + expect(() => loadPersonalPatches(NAME, dir)) + .toThrow('must be a top-level YAML array of loader patch entries') + writeFileSync(join(dir, PERSONAL_CONFIG_FILENAME), '- just-a-string\n') + expect(() => loadPersonalPatches(NAME, dir)) + .toThrow(`${NAME}: personal patches entry 1 in`) + }) +}) + +describe('boot with personal patches', () => { + function writeTree(dir: string): string { + writeFileSync(join(dir, 'noop.mjs'), 'export const name = "noop"\nexport function apply() {}\n') + writeFileSync(join(dir, 'cordis.yml'), '- id: noop\n name: ./noop.mjs\n config:\n value: base\n') + return join(dir, 'cordis.yml') + } + + function entryConfig(ctx: Context, id: string): unknown { + return [...ctx.loader.entries()].find(entry => entry.options.id === id)?.options.config + } + + it('applies id-targeted overrides, inserts, and interpolates !!js from the environment', async () => { + const dir = tmp() + const personal = tmp() + writeFileSync(join(personal, PERSONAL_CONFIG_FILENAME), [ + '- id: noop', + ' name: ./noop.mjs', + ' config:', + ' value: !!js process.env.DSH_APP_BOOT_PERSONAL_SPEC', + '- insert:', + ' - id: personal-extra', + ' name: ./noop.mjs', + '', + ].join('\n')) + process.env['DSH_APP_BOOT_PERSONAL_SPEC'] = 'personal-value' + const ctx = await boot(NAME, writeTree(dir), loadPersonalPatches(NAME, personal)) + try { + const noop = [...ctx.loader.entries()].find(entry => entry.options.id === 'noop') + // The mounted plugin received the interpolated environment value. + expect(noop?.fiber?.config).toEqual({ value: 'personal-value' }) + expect([...ctx.loader.entries()].some(entry => entry.options.id === 'personal-extra')).toBe(true) + } finally { + await ctx.fiber.dispose() + delete process.env['DSH_APP_BOOT_PERSONAL_SPEC'] + } + }) + + it('mounts no patch layer for an absent or empty personal overlay', async () => { + const dir = tmp() + const ctx = await boot(NAME, writeTree(dir), loadPersonalPatches(NAME, tmp())) + try { + expect(entryConfig(ctx, 'noop')).toEqual({ value: 'base' }) + } finally { + await ctx.fiber.dispose() + } + const empty = tmp() + writeFileSync(join(empty, PERSONAL_CONFIG_FILENAME), '[]\n') + const ctxEmpty = await boot(NAME, writeTree(tmp()), loadPersonalPatches(NAME, empty)) + try { + expect(entryConfig(ctxEmpty, 'noop')).toEqual({ value: 'base' }) + } finally { + await ctxEmpty.fiber.dispose() + } + }) +}) diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 62a78a2e69..869cd50f0f 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,6 +1,9 @@ packages: - vendor/* - packages/*/* + # Product assemblies over the packages tier (the structure introduced with + # the dsh CLI): apps/cli is the `dsh` bin. + - apps/* - website # The runnable demo leaves join as ONE workspace member: examples/package.json # declares the union of every leaf's cordis.yml plugins as workspace:*, so a diff --git a/tsconfig.build.json b/tsconfig.build.json index e332336315..7f15de0340 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -114,6 +114,7 @@ { "path": "./packages/sdk/telemetry" }, { "path": "./packages/lsp/lsp" }, { "path": "./packages/lsp/lsp-local" }, - { "path": "./packages/lsp/tool-lsp" } + { "path": "./packages/lsp/tool-lsp" }, + { "path": "./apps/cli" } ] } diff --git a/tsconfig.json b/tsconfig.json index 295e2a6365..200abdb1f0 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -128,6 +128,7 @@ { "path": "./packages/sdk/telemetry" }, { "path": "./packages/lsp/lsp" }, { "path": "./packages/lsp/lsp-local" }, - { "path": "./packages/lsp/tool-lsp" } + { "path": "./packages/lsp/tool-lsp" }, + { "path": "./apps/cli" } ] } diff --git a/tsdown.config.ts b/tsdown.config.ts index 31fa53c675..c0062b6379 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -14,7 +14,7 @@ export default defineConfig({ // Explicit globs keep bundling to vendored Cordis and the TypeScript package tree; // `workspace: true` would discover package manifests outside that bundle set. Landlock // platform packages contain only a prebuilt native binary, so they have no JS entry. - workspace: ['vendor/*', 'packages/*/*'], + workspace: ['vendor/*', 'packages/*/*', 'apps/*'], // The brace glob admits the package companion when present while retaining the // index-only build for vendored Cordis packages outside the Harness package tree. entry: ['lib/types/{index,invariant}.js'], From 45868b940f6f0739a234dcfb5b87a6658d83e90f Mon Sep 17 00:00:00 2001 From: Turtle Date: Wed, 22 Jul 2026 10:55:17 +0800 Subject: [PATCH 30/90] =?UTF-8?q?feat(tui):=20startup=20banner=20iteration?= =?UTF-8?q?s=20=E2=80=94=20slogans,=20sweep=20reveal,=20then=20no=20banner?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Squashes three linearized steps (feat/tui-startup, feat/banner-reveal, feat/remove-banner): the banner experiment converged on removing the startup banner entirely; later commits restore it borderless and add the brand gradient. --- .../2026-07-20-tui-startup-slogans.i18n.yaml | 6 + .../feature/2026-07-20-tui-startup-slogans.md | 39 ++++++ .../2026-07-20-tui-startup-slogans.zh.md | 39 ++++++ .../2026-07-21-tui-banner-sweep.i18n.yaml | 6 + .../feature/2026-07-21-tui-banner-sweep.md | 35 +++++ .../feature/2026-07-21-tui-banner-sweep.zh.md | 35 +++++ .../2026-07-21-tui-no-banner.i18n.yaml | 6 + .../feature/2026-07-21-tui-no-banner.md | 37 +++++ .../feature/2026-07-21-tui-no-banner.zh.md | 37 +++++ .../bash-terminal-card/terminal.expected.txt | 86 ++++++------ .../snapshots/code-mode/terminal.expected.txt | 90 ++++++------ .../terminal.expected.txt | 126 ++++++++--------- .../dynamic-workflow/terminal.expected.txt | 120 ++++++++-------- .../terminal.expected.txt | 88 ++++++------ .../parallel-file-reads/terminal.expected.txt | 104 +++++++------- .../snapshots/todo-plan/terminal.expected.txt | 97 ++++++------- packages/ui/tui/tests/harness.ts | 4 +- .../advanced-cards-collapsed.expected.txt | 114 +++++++--------- .../advanced-cards-expanded.expected.txt | 124 ++++++++--------- .../snapshots/code-mode-pending.expected.txt | 60 ++++---- .../conversation-streaming.expected.txt | 68 ++++----- .../cordis-tools-pending.expected.txt | 66 ++++----- .../snapshots/disposed-terminal.expected.txt | 85 +++++------- .../dynamic-workflow-pending.expected.txt | 65 ++++----- .../snapshots/errors-and-help.expected.txt | 85 +++++------- .../question-dialog-validation.expected.txt | 100 ++++++++------ .../snapshots/question-dialog.expected.txt | 91 ++++++------ ...rface-after-compaction-narrow.expected.txt | 48 +++---- ...surface-after-compaction-wide.expected.txt | 48 +++---- .../surface-before-compaction.expected.txt | 76 +++++------ .../snapshots/untrusted-controls.expected.txt | 129 ++++++++++-------- 31 files changed, 1092 insertions(+), 1022 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-07-20-tui-startup-slogans.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-07-20-tui-startup-slogans.md create mode 100644 .agents/notes/implemented/feature/2026-07-20-tui-startup-slogans.zh.md create mode 100644 .agents/notes/implemented/feature/2026-07-21-tui-banner-sweep.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-07-21-tui-banner-sweep.md create mode 100644 .agents/notes/implemented/feature/2026-07-21-tui-banner-sweep.zh.md create mode 100644 .agents/notes/implemented/feature/2026-07-21-tui-no-banner.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-07-21-tui-no-banner.md create mode 100644 .agents/notes/implemented/feature/2026-07-21-tui-no-banner.zh.md diff --git a/.agents/notes/implemented/feature/2026-07-20-tui-startup-slogans.i18n.yaml b/.agents/notes/implemented/feature/2026-07-20-tui-startup-slogans.i18n.yaml new file mode 100644 index 0000000000..3ed957d231 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-20-tui-startup-slogans.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 +2026-07-20-tui-startup-slogans.md: a2a22baafddd08145cec0d03b65ee56b2f8114b1 +2026-07-20-tui-startup-slogans.zh.md: 58fa5790f315845f27b810d62658bd79428b519b diff --git a/.agents/notes/implemented/feature/2026-07-20-tui-startup-slogans.md b/.agents/notes/implemented/feature/2026-07-20-tui-startup-slogans.md new file mode 100644 index 0000000000..a2a22baafd --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-20-tui-startup-slogans.md @@ -0,0 +1,39 @@ +# Agent Note: Startup slogans replace the configured TUI welcome line + +Status: implemented + +English | [中文](2026-07-20-tui-startup-slogans.zh.md) + +> **Superseded** for the slogan/animation half by the [banner sweep Agent Note](2026-07-21-tui-banner-sweep.md): the slogan bank and typewriter reveal shipped, read as weird in use, and were replaced by a subtitle-free banner with a whole-banner sweep. The removal of the configured demo welcome and the animation-lifecycle groundwork (start after `ui.start()`, clear through `detachListeners`) stand. + +## Problem + +The TUI header subtitle came from a `welcome` config the demo leaf set to "TUI agent ready. Give it a coding task." — instructional filler that told a returning user nothing, restated what the product is on every boot, and had a hardcoded twin (`'ready.'`) as the schema default in two packages. The product wanted a startup moment with some character instead of a static banner caption. + +## Decision + +- `examples/tui-agent/cordis.yml` no longer configures `welcome`; the config key stays for deployments and fixtures that need a fixed, deterministic subtitle (the Code Mode overlay and every snapshot/scripted fixture keep theirs). +- When `welcome` is unset, `dsh-tui` picks one member of an exported `STARTUP_SLOGANS` bank per boot (`pickStartupSlogan`, injectable random source) and reveals it with a typewriter animation: one character per 40 ms frame, a `▌` block cursor trailing until complete. The reveal starts only after `ui.start()` succeeds and its interval is cleared on dispose alongside the other listeners. +- The slogan bank is presentation copy, deliberately not config: deployments that want controlled wording already have `welcome`. Slogans are ASCII-only by contract because the reveal slices per character. +- `dsh-tui-demo` forwards `welcome` only when configured instead of defaulting it, so the app no longer decides the TUI's idle subtitle. +- The keyless PTY boot scenario now waits for the reveal cursor (`▌` — the only source of that glyph in an empty transcript) instead of the removed welcome text. + +The same change restores `packages/ui/tui/src/index.ts` to 100 % per-file coverage, which the color-scheme merge had broken on the integration branch: the editor border-color reassignment inside `applyColorScheme` was dead (the `setStatus` call right after re-derives it) and is removed, and the color-scheme query's `.then`/`.catch` arrows became named, tested handlers (`applyReportedScheme`, `ignoreSchemeQueryFailure` — the latter pinned by a test whose terminal throws on the DSR query write). + +## Alternatives considered + +**A fixed cooler slogan.** Rejected: one string re-read on every boot decays into wallpaper exactly like the line it replaces; a small rotating bank keeps the moment alive at no complexity cost. + +**Making the bank and reveal speed configurable.** Rejected: that is two new knobs for presentation copy; `welcome` is already the escape hatch for deployments with an opinion, and the no-hardcoded-tunables rule targets deployment-varying behavior, not brand copy. + +**Animating in `HeaderComponent` itself.** Rejected: the component would need a TUI handle and its own lifecycle; the chat already owns a render loop, timers, and a disposal path, so the reveal lives beside the other `createTuiChat` effects and `detachListeners` clears it. + +## Consequences + +- Boot output is no longer byte-deterministic when `welcome` is unset (random slogan, timed frames). Every recorded or snapshot surface pins `welcome` explicitly, so no snapshot changed; the PTY smoke anchors on the reveal cursor and the session-id line instead. +- The `welcome` schema default disappeared from both `dsh-tui` and `dsh-tui-demo`; a direct caller passing no welcome now gets a slogan, not `'ready.'`. +- Adding a slogan is a one-line bank edit; tests assert membership, not specific text. + +## Testing + +`packages/ui/tui/tests/tui.spec.ts` pins deterministic bank selection with an injected random source, the reveal (a bank member fully rendered, cursor frames observed), the configured-welcome path rendering verbatim with no cursor, and dispose stopping a mid-reveal animation. `examples/tui-agent/tests/tui-keyless-smoke.e2e.ts` boots the real tree in a PTY and waits on the reveal cursor. Verified live in tmux (mid-reveal frame `no map below▌` then the full slogan). diff --git a/.agents/notes/implemented/feature/2026-07-20-tui-startup-slogans.zh.md b/.agents/notes/implemented/feature/2026-07-20-tui-startup-slogans.zh.md new file mode 100644 index 0000000000..58fa5790f3 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-20-tui-startup-slogans.zh.md @@ -0,0 +1,39 @@ +# Agent Note: 启动 slogan 取代配置化的 TUI 欢迎语 + +Status: implemented + +[English](2026-07-20-tui-startup-slogans.md) | 中文 + +> **已被取代**:slogan/动画的那一半由[横幅扫入 Agent Note](2026-07-21-tui-banner-sweep.md)取代:slogan 库和打字机动画上线后实际使用中显得怪异,已替换为无副标题的横幅加整体扫入。移除示例配置中欢迎语的决定与动画生命周期基础设施(`ui.start()` 后启动、经 `detachListeners` 清除)保持不变。 + +## Problem + +TUI 头部副标题来自一个 `welcome` 配置,示例叶子配置把它设为 "TUI agent ready. Give it a coding task."——一句说明书式的填充语,对老用户毫无信息量,每次启动都在复述产品是什么,而且它还有一个硬编码的孪生兄弟(`'ready.'`)作为两个包里的 schema 默认值。产品需要的是一个有性格的启动时刻,而不是一条静态横幅说明。 + +## Decision + +- `examples/tui-agent/cordis.yml` 不再配置 `welcome`;该配置键保留给需要固定、确定性副标题的部署与 fixture(Code Mode overlay 和所有快照/脚本化 fixture 都保留各自的欢迎语)。 +- `welcome` 未设置时,`dsh-tui` 每次启动从导出的 `STARTUP_SLOGANS` 库里挑选一条(`pickStartupSlogan`,随机源可注入),并以打字机动画逐字显示:每帧 40 ms 一个字符,完成前尾随一个 `▌` 块状光标。动画只在 `ui.start()` 成功后启动,其定时器与其他监听器一起在 dispose 时清除。 +- slogan 库是展示文案,刻意不做成配置:想控制措辞的部署已经有 `welcome` 这个出口。按契约 slogan 只含 ASCII,因为逐字显示按字符切片。 +- `dsh-tui-demo` 只在配置了 `welcome` 时才转发它,不再填默认值,应用不再替 TUI 决定空闲副标题。 +- 无 key 的 PTY 启动场景改为等待逐字显示的光标(`▌`——空 transcript 里该字形的唯一来源),不再等待已删除的欢迎文本。 + +同一变更把 `packages/ui/tui/src/index.ts` 恢复到 100% 的单文件覆盖率(颜色方案合并曾在集成分支上破坏它):`applyColorScheme` 里对编辑器边框颜色的重新赋值是死代码(紧随其后的 `setStatus` 调用会重新推导它),已删除;颜色方案查询的 `.then`/`.catch` 箭头函数改为具名、有测试的处理器(`applyReportedScheme`、`ignoreSchemeQueryFailure`——后者由一个让终端在 DSR 查询写入时抛错的测试固定)。 + +## Alternatives considered + +**换一条更酷的固定 slogan。** 否决:一条每次启动都重读的字符串会和它取代的那行一样退化成墙纸;一个小的轮换库以零复杂度代价让这个时刻保持新鲜。 + +**把 slogan 库和显示速度做成配置。** 否决:那是为展示文案新增两个旋钮;对措辞有主张的部署已经有 `welcome` 这个出口,而「插件里不许硬编码可调参数」规则针对的是随部署变化的行为,不是品牌文案。 + +**在 `HeaderComponent` 内部做动画。** 否决:组件将需要持有 TUI 句柄和自己的生命周期;聊天层已经拥有渲染循环、定时器和释放路径,所以逐字显示与 `createTuiChat` 的其他资源放在一起,由 `detachListeners` 清除。 + +## Consequences + +- `welcome` 未设置时启动输出不再字节级确定(随机 slogan、定时帧)。所有录制或快照表面都显式固定 `welcome`,因此没有快照变化;PTY 冒烟测试改为锚定逐字显示光标和会话 id 行。 +- `welcome` 的 schema 默认值从 `dsh-tui` 和 `dsh-tui-demo` 中消失;不传 welcome 的直接调用方现在得到的是 slogan,而不是 `'ready.'`。 +- 新增一条 slogan 只需在库里加一行;测试断言成员归属,不断言具体文本。 + +## Testing + +`packages/ui/tui/tests/tui.spec.ts` 固定以下行为:注入随机源后的确定性选取、逐字显示(库中某条完整渲染、观察到光标帧)、配置了 welcome 时逐字动画不启动且原文渲染、以及 dispose 停止进行中的动画。`examples/tui-agent/tests/tui-keyless-smoke.e2e.ts` 在 PTY 里启动真实配置树并等待显示光标。已在 tmux 中实机验证(中途帧 `no map below▌`,随后是完整 slogan)。 diff --git a/.agents/notes/implemented/feature/2026-07-21-tui-banner-sweep.i18n.yaml b/.agents/notes/implemented/feature/2026-07-21-tui-banner-sweep.i18n.yaml new file mode 100644 index 0000000000..a06145f092 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-21-tui-banner-sweep.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 +2026-07-21-tui-banner-sweep.md: c146424d53e75a72b63e346f87a5bbd206d67350 +2026-07-21-tui-banner-sweep.zh.md: 01cc153e88f067b7b8d2eb6317648f3892fe8a5a diff --git a/.agents/notes/implemented/feature/2026-07-21-tui-banner-sweep.md b/.agents/notes/implemented/feature/2026-07-21-tui-banner-sweep.md new file mode 100644 index 0000000000..c146424d53 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-21-tui-banner-sweep.md @@ -0,0 +1,35 @@ +# Agent Note: The banner sweeps in; the subtitle line is gone + +Status: implemented + +English | [中文](2026-07-21-tui-banner-sweep.zh.md) + +> **Superseded** by the [no-banner Agent Note](2026-07-21-tui-no-banner.md): the banner itself was removed, taking the sweep with it. + +## Problem + +The [startup-slogans Agent Note](2026-07-20-tui-startup-slogans.md) replaced the instructional welcome line with a random slogan bank revealed by a per-character typewriter. In use the quotes read as weird — random flavor text in a tool's header — and the animation was slow (40 ms/char over a full sentence) while animating only one line of a four-line banner. This note supersedes that decision's slogan half; the removal of the configured demo welcome and the animation-lifecycle groundwork stand. + +## Decision + +- The slogan bank, `pickStartupSlogan`, and the typewriter reveal are deleted. When `welcome` is unset the banner simply has **no subtitle line** — title and model/session detail only. The `welcome` config remains for deployments and fixtures that want a fixed subtitle, rendered frame-deterministically with no animation. +- The startup animation is now the **whole banner**: `HeaderComponent` gains a `revealWidth` clip, and the header box wipes in left-to-right over ~24 frames at 15 ms (~360 ms total, ~60 fps), started after `ui.start()` succeeds and cleared through the same `detachListeners` path the typewriter used. `stopBannerReveal` also resets the clip so a disposed-mid-sweep header re-renders whole. +- The PTY smoke's boot marker changes from the typewriter cursor (`▌`) to the banner's top-right corner (`╮`), which only renders once the sweep completes. + +## Alternatives considered + +**Keep the animation as-is and only change the copy.** Rejected: any fixed or rotating phrase re-read on every boot decays into wallpaper; the user's judgment was that the quotes themselves, not just their content, were wrong for the surface. + +**Animate per banner line (top-down) instead of a left-right sweep.** Rejected: with only four lines the animation would have four visible steps — closer to a flicker than a reveal; the horizontal sweep uses the full terminal width for a smooth motion at the same total duration. + +**Character-level clipping via `revealWidth` on styled text.** Adopted with `truncateToWidth` from pi-tui, the same ANSI-aware clipper the header already uses for width overflow, so the sweep cannot tear escape sequences. + +## Consequences + +- Boot output with `welcome` unset is again animation-dependent but no longer random: every boot sweeps the same banner. Configured welcomes (all snapshot/scripted fixtures, the Code Mode overlay) stay frame-deterministic and unchanged. +- The `STARTUP_SLOGANS`/`pickStartupSlogan` exports are gone; no consumer outside the deleted tests referenced them. +- The default banner is one line shorter (no subtitle), so PTY assertions anchored on banner geometry use the corner glyph rather than any subtitle text. + +## Testing + +`packages/ui/tui/tests/tui.spec.ts` pins: the sweep completes to a full banner (both corners + title) and produced at least one clipped mid-sweep frame; a configured welcome renders verbatim with no clipped frames; the unset-welcome banner has no subtitle; and dispose clears the sweep's own interval handle. The PTY smoke boots on the `╮` completion marker across the tui-demo bin, the dsh CLI, and the personal-overlay scenarios. Verified live in tmux. diff --git a/.agents/notes/implemented/feature/2026-07-21-tui-banner-sweep.zh.md b/.agents/notes/implemented/feature/2026-07-21-tui-banner-sweep.zh.md new file mode 100644 index 0000000000..01cc153e88 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-21-tui-banner-sweep.zh.md @@ -0,0 +1,35 @@ +# Agent Note: 横幅整体扫入;副标题行移除 + +Status: implemented + +[English](2026-07-21-tui-banner-sweep.md) | 中文 + +> **已被取代**:由[移除启动横幅 Agent Note](2026-07-21-tui-no-banner.md)取代:横幅本身已移除,扫入动画随之移除。 + +## Problem + +[启动 slogan Agent Note](2026-07-20-tui-startup-slogans.md) 用随机 slogan 库加逐字打字机动画取代了说明书式的欢迎行。实际使用中这些引语显得怪异——工具头部出现随机的风味文案——而且动画很慢(每字符 40 ms,扫完一整句),却只动画四行横幅中的一行。本 note 取代该决定中 slogan 的那一半;移除示例配置中欢迎语的决定与动画生命周期的基础设施保持不变。 + +## Decision + +- 删除 slogan 库、`pickStartupSlogan` 和打字机动画。`welcome` 未设置时横幅直接**没有副标题行**——只有标题和模型/会话详情。`welcome` 配置保留给想要固定副标题的部署与 fixture,无动画、逐帧确定地渲染。 +- 启动动画现在作用于**整个横幅**:`HeaderComponent` 增加 `revealWidth` 裁剪,头部盒子以约 24 帧、每帧 15 ms(总计约 360 ms、约 60 fps)从左到右扫入,在 `ui.start()` 成功后启动,经打字机动画用过的同一条 `detachListeners` 路径清除。`stopBannerReveal` 同时重置裁剪,因此扫入中途被 dispose 的头部会重新完整渲染。 +- PTY 冒烟测试的启动标记从打字机光标(`▌`)改为横幅右上角(`╮`),它只在扫入完成后才渲染。 + +## Alternatives considered + +**保留动画原样、只改文案。** 否决:任何每次启动都被重读的固定或轮换语句都会退化成墙纸;用户的判断是引语本身——而不只是内容——对这个表面来说就是错的。 + +**按横幅行逐行(自上而下)动画而非左右扫入。** 否决:只有四行时动画只有四个可见步骤——更像闪烁而不是展开;水平扫入用满终端宽度,在相同总时长内动作更平滑。 + +**用 `revealWidth` 对带样式文本做字符级裁剪。** 采用 pi-tui 的 `truncateToWidth`——头部处理宽度溢出时已在使用的同一个 ANSI 感知裁剪器——因此扫入不可能撕裂转义序列。 + +## Consequences + +- `welcome` 未设置时启动输出再次依赖动画但不再随机:每次启动扫入同一幅横幅。配置了欢迎语的场景(全部快照/脚本化 fixture、Code Mode overlay)保持逐帧确定且不变。 +- `STARTUP_SLOGANS`/`pickStartupSlogan` 导出移除;除被删除的测试外没有消费者引用它们。 +- 默认横幅少一行(无副标题),因此锚定横幅几何的 PTY 断言使用角落字形而非任何副标题文本。 + +## Testing + +`packages/ui/tui/tests/tui.spec.ts` 固定:扫入完成为完整横幅(两个角 + 标题)且产生了至少一个裁剪的中途帧;配置的欢迎语原文渲染且无裁剪帧;未设置欢迎语的横幅没有副标题;dispose 清除扫入自己的定时器句柄。PTY 冒烟测试在 tui-demo bin、dsh CLI 和个人 overlay 场景中以 `╮` 完成标记启动。已在 tmux 中实机验证。 diff --git a/.agents/notes/implemented/feature/2026-07-21-tui-no-banner.i18n.yaml b/.agents/notes/implemented/feature/2026-07-21-tui-no-banner.i18n.yaml new file mode 100644 index 0000000000..e9ef3a7671 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-21-tui-no-banner.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 +2026-07-21-tui-no-banner.md: 284d02df4b1fe3445a76b030aa873164081aa571 +2026-07-21-tui-no-banner.zh.md: 83bf7ffa27ca1abef0e71a1847b5aa1bd54bbe87 diff --git a/.agents/notes/implemented/feature/2026-07-21-tui-no-banner.md b/.agents/notes/implemented/feature/2026-07-21-tui-no-banner.md new file mode 100644 index 0000000000..284d02df4b --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-21-tui-no-banner.md @@ -0,0 +1,37 @@ +# Agent Note: No startup banner + +Status: implemented + +English | [中文](2026-07-21-tui-no-banner.zh.md) + +## Problem + +The TUI opened with a boxed product banner ("DEEPSEEK HARNESS" + model/session detail), most recently with a sweep-in animation ([banner sweep Agent Note](2026-07-21-tui-banner-sweep.md)). The user's verdict: remove it. A product title re-read on every boot is chrome, the box spends four rows before any content, and the identifying facts it carried (model, session) have better homes. + +## Decision + +- `HeaderComponent`, the sweep animation, and its lifecycle wiring are deleted. The TUI mounts straight into the transcript; startup renders nothing above the separator. +- The model name moves into the footer status line's left segment (` ↑tokens ↓tokens`), so the session's driving model stays visible at all times, not just at boot. The session id is no longer displayed — it lives in the session log and `./.sessions` filenames, and `RESUME_SESSION_ID` consumers retrieve it there. +- `welcome`, when configured, renders as the transcript's first line (a muted notice) inside `rebuildTranscript`, so palette swaps preserve it. Unset renders nothing. Fixtures keep their configured welcomes; the PTY smoke's boot marker becomes the footer's model name, the only mounted-TUI text guaranteed to render regardless of cwd length. + +This supersedes the [banner sweep Agent Note](2026-07-21-tui-banner-sweep.md) entirely: both the sweep and the banner it animated are gone. + +## Alternatives considered + +**Keep a one-line header (no box).** Rejected: the only load-bearing fact was the model name, and the footer already aggregates session status; a dedicated header row for one fact is the same chrome, smaller. + +**Show the session id in the footer too.** Rejected: a 36-char UUID dominates the 100-column footer and clips the status segment; it identifies the session for resume, which is a log/filesystem concern, not a glanceable one. + +**Print the welcome outside the transcript (above the separator).** Rejected: any fixed region above the transcript is a banner again; as a transcript line it scrolls away naturally and survives rebuilds through the same path as every other transcript element. + +## Consequences + +- Startup output is fully deterministic again — no animation frames at all; the interval-lifecycle machinery from the two animation iterations is gone. +- All 26 pi-tui terminal snapshots re-recorded (`test:snapshot:refresh`): banner rows gone, footer rows gain the model prefix. +- Anything that anchored on banner text (`DEEPSEEK`, box corners) re-anchors on the footer model name; `main-session-` no longer appears in boot output. +- `/clear` now wipes the welcome line too: it is an ordinary transcript line, and `/clear` empties the transcript (the old banner survived `/clear` only by sitting outside it). +- The footer's left segment is wider; on narrow terminals the right status segment clips earlier. + +## Testing + +`packages/ui/tui/tests/tui.spec.ts` pins: no box corners/product title and an empty transcript when `welcome` is unset, with the model in the footer; a configured welcome as the first transcript line without a banner; and the welcome surviving a palette-swap transcript rebuild. The PTY smoke boots on the footer model name and asserts `DEEPSEEK HARNESS` is absent. Snapshots verify the full frames. diff --git a/.agents/notes/implemented/feature/2026-07-21-tui-no-banner.zh.md b/.agents/notes/implemented/feature/2026-07-21-tui-no-banner.zh.md new file mode 100644 index 0000000000..83bf7ffa27 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-21-tui-no-banner.zh.md @@ -0,0 +1,37 @@ +# Agent Note: 移除启动横幅 + +Status: implemented + +[English](2026-07-21-tui-no-banner.md) | 中文 + +## Problem + +TUI 启动时展示一个带框的产品横幅("DEEPSEEK HARNESS" + 模型/会话详情),最近一版还带扫入动画([横幅扫入 Agent Note](2026-07-21-tui-banner-sweep.md))。用户的裁决:删掉它。每次启动都被重读的产品标题是装饰,盒子在任何内容之前先占掉四行,而它承载的识别信息(模型、会话)有更好的去处。 + +## Decision + +- 删除 `HeaderComponent`、扫入动画及其生命周期接线。TUI 直接挂载进 transcript;启动时分隔线之上不渲染任何东西。 +- 模型名移入页脚状态行的左段(` ↑tokens ↓tokens`),会话使用的模型因此始终可见,而不只是启动时。会话 id 不再显示——它存在于会话日志和 `./.sessions` 文件名中,`RESUME_SESSION_ID` 的使用者从那里获取。 +- 配置了 `welcome` 时,它作为 transcript 的第一行(一条弱化的通知)在 `rebuildTranscript` 内渲染,因此调色板切换会保留它。未设置则什么也不渲染。fixture 保留各自配置的欢迎语;PTY 冒烟测试的启动标记改为页脚的模型名——无论 cwd 多长都保证渲染的唯一挂载后文本。 + +本 note 完全取代[横幅扫入 Agent Note](2026-07-21-tui-banner-sweep.md):扫入动画和它所动画的横幅都已移除。 + +## Alternatives considered + +**保留单行头部(去掉盒子)。** 否决:唯一有承载价值的信息是模型名,而页脚已经聚合会话状态;为一条信息保留专用头部行仍是同一种装饰,只是小一点。 + +**把会话 id 也放进页脚。** 否决:36 字符的 UUID 会占满 100 列页脚并裁掉状态段;它的用途是恢复会话的标识,属于日志/文件系统关注点,不是需要一瞥可见的信息。 + +**把欢迎语渲染在 transcript 之外(分隔线上方)。** 否决:transcript 上方任何固定区域都会再次变成横幅;作为 transcript 行它自然滚走,并通过与其他 transcript 元素相同的路径在重建后保留。 + +## Consequences + +- 启动输出再次完全确定——没有任何动画帧;两轮动画迭代留下的定时器生命周期机制全部移除。 +- 全部 26 个 pi-tui 终端快照重新录制(`test:snapshot:refresh`):横幅行消失,页脚行增加模型前缀。 +- 锚定横幅文本(`DEEPSEEK`、盒子角)的内容改为锚定页脚模型名;启动输出中不再出现 `main-session-`。 +- `/clear` 现在也会清掉欢迎行:它是普通的 transcript 行,而 `/clear` 清空 transcript(旧横幅能在 `/clear` 后存活只因为它在 transcript 之外)。 +- 页脚左段变宽;窄终端上右侧状态段更早被裁剪。 + +## Testing + +`packages/ui/tui/tests/tui.spec.ts` 固定:`welcome` 未设置时无盒子角/产品标题、transcript 为空、模型在页脚;配置的欢迎语作为 transcript 第一行且无横幅;欢迎语在调色板切换的 transcript 重建后保留。PTY 冒烟测试以页脚模型名为启动标记并断言 `DEEPSEEK HARNESS` 不出现。快照验证完整帧。 diff --git a/examples/tui-agent/tests/snapshots/bash-terminal-card/terminal.expected.txt b/examples/tui-agent/tests/snapshots/bash-terminal-card/terminal.expected.txt index e03725cc4f..5b7505cc54 100644 --- a/examples/tui-agent/tests/snapshots/bash-terminal-card/terminal.expected.txt +++ b/examples/tui-agent/tests/snapshots/bash-terminal-card/terminal.expected.txt @@ -1,73 +1,63 @@ terminal 100x36 buffer=normal length=36 base=0 viewport=0 lifecycle started=1 stopped=0 progress=inactive -title "Use the bash tool to — DSH TUI snapshot" -cursor hidden column=1 viewportRow=27 bufferRow=27 +title "DSH TUI snapshot" +cursor hidden column=1 viewportRow=25 bufferRow=25 buffer -0| "╭──────────────────────────────────────────────────────────────────────────────────────────────────╮" - style 0-99 fg=bright-blue -1| "│ DEEPSEEK HARNESS │" +0| " DEEPSEEK HARNESS" + style 1-8 fg=bright-blue bold + style 10-16 bold +1| " Recorded replay: bash-terminal-card" + style 1-35 fg=bright-black +2| " deepseek-v4-flash • main-session" + style 1-34 dim +3| +4| "▌ " style 0-0 fg=bright-blue - style 2-9 fg=bright-blue bold - style 11-17 bold - style 99-99 fg=bright-blue -2| "│ Use the bash tool to │" - style 0-0 fg=bright-blue - style 2-21 fg=bright-black - style 99-99 fg=bright-blue -3| "│ deepseek-v4-flash • main-session │" - style 0-0 fg=bright-blue - style 2-35 dim - style 99-99 fg=bright-blue -4| "╰──────────────────────────────────────────────────────────────────────────────────────────────────╯" - style 0-99 fg=bright-blue -5| -6| "▌ " - style 0-0 fg=bright-blue -7| "▌ You " +5| "▌ You " style 0-0 fg=bright-blue style 2-4 fg=bright-blue bold -8| "▌ Use the bash tool to run exactly: echo TERMINAL_OK. Then reply with the single word DONE and stop." +6| "▌ Use the bash tool to run exactly: echo TERMINAL_OK. Then reply with the single word DONE and stop." style 0-0 fg=bright-blue -9| "▌ " +7| "▌ " style 0-0 fg=bright-blue -10| -11| " Reasoning " +8| +9| " Reasoning " style 1-9 fg=bright-black italic -12| " The user wants me to run a simple bash command and then reply with \"DONE\". " +10| " The user wants me to run a simple bash command and then reply with \"DONE\". " style 1-74 fg=bright-black italic -13| -14| "▌ " +11| +12| "▌ " style 0-0 fg=green -15| "▌ ✓ echo TERMINAL_OK " +13| "▌ ✓ echo TERMINAL_OK " style 0-0 fg=green style 2-2 fg=green bold style 3-19 bold -16| "▌ Echo TERMINAL_OK to verify terminal access " +14| "▌ Echo TERMINAL_OK to verify terminal access " style 0-0 fg=green style 2-43 fg=bright-black -17| "▌ TERMINAL_OK " +15| "▌ TERMINAL_OK " style 0-0 fg=green -18| "▌ [exit 0] " +16| "▌ [exit 0] " style 0-0 fg=green style 2-9 dim -19| "▌ " +17| "▌ " style 0-0 fg=green -20| -21| " Reasoning " +18| +19| " Reasoning " style 1-9 fg=bright-black italic -22| " The command ran successfully and output \"TERMINAL_OK\". I should now reply with just \"DONE\". " +20| " The command ran successfully and output \"TERMINAL_OK\". I should now reply with just \"DONE\". " style 1-91 fg=bright-black italic -23| -24| " Assistant " +21| +22| " Assistant " style 1-9 fg=bright-magenta bold -25| " DONE " +23| " DONE " +24| "────────────────────────────────────────────────────────────────────────────────────────────────────" + style 0-99 dim +25| " " + style 1-1 inverse 26| "────────────────────────────────────────────────────────────────────────────────────────────────────" style 0-99 dim -27| " " - style 1-1 inverse -28| "────────────────────────────────────────────────────────────────────────────────────────────────────" - style 0-99 dim -29| "/tmp/dsh-tui-snapshot-bash-te ↑3.0k ↓115 3% context tools:compact deepseek-v4-flash(reasoning:on)" - style 0-28 dim - style 42-99 dim -30-35| +27| "deepseek-v4-flash /workspace/project ↑3.0k ↓115 cache 48% tools:col" + style 0-88 dim + style 91-99 dim +28-35| diff --git a/examples/tui-agent/tests/snapshots/code-mode/terminal.expected.txt b/examples/tui-agent/tests/snapshots/code-mode/terminal.expected.txt index c774373268..b1a587e71f 100644 --- a/examples/tui-agent/tests/snapshots/code-mode/terminal.expected.txt +++ b/examples/tui-agent/tests/snapshots/code-mode/terminal.expected.txt @@ -1,79 +1,69 @@ terminal 100x36 buffer=normal length=36 base=0 viewport=0 lifecycle started=1 stopped=0 progress=inactive -title "Using ONE run_code program: call — DSH TUI snapshot" -cursor hidden column=1 viewportRow=29 bufferRow=29 +title "DSH TUI snapshot" +cursor hidden column=1 viewportRow=27 bufferRow=27 buffer -0| "╭──────────────────────────────────────────────────────────────────────────────────────────────────╮" - style 0-99 fg=bright-blue -1| "│ DEEPSEEK HARNESS │" +0| " DEEPSEEK HARNESS" + style 1-8 fg=bright-blue bold + style 10-16 bold +1| " Recorded replay: code-mode" + style 1-26 fg=bright-black +2| " deepseek-v4-flash • main-session" + style 1-34 dim +3| +4| "▌ " style 0-0 fg=bright-blue - style 2-9 fg=bright-blue bold - style 11-17 bold - style 99-99 fg=bright-blue -2| "│ Using ONE run_code program: call │" - style 0-0 fg=bright-blue - style 2-33 fg=bright-black - style 99-99 fg=bright-blue -3| "│ deepseek-v4-flash • main-session │" - style 0-0 fg=bright-blue - style 2-35 dim - style 99-99 fg=bright-blue -4| "╰──────────────────────────────────────────────────────────────────────────────────────────────────╯" - style 0-99 fg=bright-blue -5| -6| "▌ " - style 0-0 fg=bright-blue -7| "▌ You " +5| "▌ You " style 0-0 fg=bright-blue style 2-4 fg=bright-blue bold -8| "▌ Using ONE run_code program: call the bash tool twice — exactly echo CODE_ONE then exactly echo " +6| "▌ Using ONE run_code program: call the bash tool twice — exactly echo CODE_ONE then exactly echo " style 0-0 fg=bright-blue style 65-77 fg=cyan style 92-99 fg=cyan -9| "▌ CODE_TWO — and return the two outputs joined with a plus sign. Then reply with that joined string " +7| "▌ CODE_TWO — and return the two outputs joined with a plus sign. Then reply with that joined string " style 0-0 fg=bright-blue style 2-9 fg=cyan -10| "▌ only and stop. " +8| "▌ only and stop. " style 0-0 fg=bright-blue -11| "▌ " +9| "▌ " style 0-0 fg=bright-blue -12| -13| " Reasoning " +10| +11| " Reasoning " style 1-9 fg=bright-black italic -14| " The user wants a single run_code program that calls bash twice, then returns the two outputs " +12| " The user wants a single run_code program that calls bash twice, then returns the two outputs " style 1-99 fg=bright-black italic -15| " joined with a plus sign. Let me write this. " +13| " joined with a plus sign. Let me write this. " style 1-43 fg=bright-black italic -16| -17| "▌ " +14| +15| "▌ " style 0-0 fg=green -18| "▌ ✓ const out1 = await tools.bash({ command: \"echo CODE_ONE\", description: \"First echo\" }); " +16| "▌ ✓ const out1 = await tools.bash({ command: \"echo CODE_ONE\", description: \"First echo\" }); " style 0-0 fg=green style 2-2 fg=green bold style 3-99 bold -19| "▌ const o " +17| "▌ const o " style 0-0 fg=green style 2-8 bold -20| "▌ CODE_ONE+CODE_TWO " +18| "▌ CODE_ONE+CODE_TWO " style 0-0 fg=green -21| "▌ " +19| "▌ " style 0-0 fg=green -22| -23| " Reasoning " +20| +21| " Reasoning " style 1-9 fg=bright-black italic -24| " The output is exactly what the user asked for: CODE_ONE+CODE_TWO " +22| " The output is exactly what the user asked for: CODE_ONE+CODE_TWO " style 1-64 fg=bright-black italic -25| -26| " Assistant " +23| +24| " Assistant " style 1-9 fg=bright-magenta bold -27| " CODE_ONE+CODE_TWO " +25| " CODE_ONE+CODE_TWO " +26| "────────────────────────────────────────────────────────────────────────────────────────────────────" + style 0-99 dim +27| " " + style 1-1 inverse 28| "────────────────────────────────────────────────────────────────────────────────────────────────────" style 0-99 dim -29| " " - style 1-1 inverse -30| "────────────────────────────────────────────────────────────────────────────────────────────────────" - style 0-99 dim -31| "/tmp/dsh-tui-snapshot-code-mo ↑3.1k ↓158 3% context tools:compact deepseek-v4-flash(reasoning:on)" - style 0-28 dim - style 42-99 dim -32-35| +29| "deepseek-v4-flash /workspace/project ↑3.1k ↓158 cache 50% tools:collapsed" + style 0-79 dim + style 85-99 dim +30-35| diff --git a/examples/tui-agent/tests/snapshots/cordis-dynamic-toolchain/terminal.expected.txt b/examples/tui-agent/tests/snapshots/cordis-dynamic-toolchain/terminal.expected.txt index 51ef90684a..e0b6a4c1fb 100644 --- a/examples/tui-agent/tests/snapshots/cordis-dynamic-toolchain/terminal.expected.txt +++ b/examples/tui-agent/tests/snapshots/cordis-dynamic-toolchain/terminal.expected.txt @@ -1,116 +1,106 @@ -terminal 100x36 buffer=normal length=50 base=14 viewport=14 +terminal 100x36 buffer=normal length=48 base=12 viewport=12 lifecycle started=1 stopped=0 progress=inactive -title "Run this advanced flow exactly — DSH TUI snapshot" -cursor hidden column=1 viewportRow=33 bufferRow=47 +title "DSH TUI snapshot" +cursor hidden column=1 viewportRow=33 bufferRow=45 buffer -0| "╭──────────────────────────────────────────────────────────────────────────────────────────────────╮" - style 0-99 fg=bright-blue -1| "│ DEEPSEEK HARNESS │" +0| " DEEPSEEK HARNESS" + style 1-8 fg=bright-blue bold + style 10-16 bold +1| " Recorded replay: cordis-dynamic-toolchain" + style 1-41 fg=bright-black +2| " deepseek-v4-flash • main-session" + style 1-34 dim +3| +4| "▌ " style 0-0 fg=bright-blue - style 2-9 fg=bright-blue bold - style 11-17 bold - style 99-99 fg=bright-blue -2| "│ Run this advanced flow exactly │" - style 0-0 fg=bright-blue - style 2-31 fg=bright-black - style 99-99 fg=bright-blue -3| "│ deepseek-v4-flash • main-session │" - style 0-0 fg=bright-blue - style 2-35 dim - style 99-99 fg=bright-blue -4| "╰──────────────────────────────────────────────────────────────────────────────────────────────────╯" - style 0-99 fg=bright-blue -5| -6| "▌ " - style 0-0 fg=bright-blue -7| "▌ You " +5| "▌ You " style 0-0 fg=bright-blue style 2-4 fg=bright-blue bold -8| "▌ Run this advanced flow exactly once: mount a no-op Cordis plugin named snapshot-marker; use " +6| "▌ Run this advanced flow exactly once: mount a no-op Cordis plugin named snapshot-marker; use " style 0-0 fg=bright-blue -9| "▌ run_code to inspect the live dynamic mounts through tools.cordis_inspect; delegate once to a " +7| "▌ run_code to inspect the live dynamic mounts through tools.cordis_inspect; delegate once to a " style 0-0 fg=bright-blue -10| "▌ direct spawn child; run one workflow that delegates to another spawn child; unmount dyn-1; then " +8| "▌ direct spawn child; run one workflow that delegates to another spawn child; unmount dyn-1; then " style 0-0 fg=bright-blue -11| "▌ reply with exactly ADVANCED_ACP_OK. " +9| "▌ reply with exactly ADVANCED_ACP_OK. " style 0-0 fg=bright-blue +10| "▌ " + style 0-0 fg=bright-blue +11| 12| "▌ " - style 0-0 fg=bright-blue -13| -14| "▌ " style 0-0 fg=green -15| "▌ ✓ Mount plugin into live cordis runtime " +13| "▌ ✓ Mount plugin into live cordis runtime " style 0-0 fg=green style 2-2 fg=green bold style 3-40 bold -16| "▌ mounted dyn-1 (plugin \"snapshot-marker\", state: active) " +14| "▌ mounted dyn-1 (plugin \"snapshot-marker\", state: active) " style 0-0 fg=green +15| "▌ " + style 0-0 fg=green +16| 17| "▌ " style 0-0 fg=green -18| -19| "▌ " - style 0-0 fg=green -20| "▌ ✓ return await tools.cordis_inspect({ what: 'dynamic' }) " +18| "▌ ✓ return await tools.cordis_inspect({ what: 'dynamic' }) " style 0-0 fg=green style 2-2 fg=green bold style 3-57 bold -21| "▌ ## dynamic " +19| "▌ ## dynamic " style 0-0 fg=green -22| "▌ - dyn-1: snapshot-marker [active] " +20| "▌ - dyn-1: snapshot-marker [active] " style 0-0 fg=green +21| "▌ " + style 0-0 fg=green +22| 23| "▌ " style 0-0 fg=green -24| -25| "▌ " - style 0-0 fg=green -26| "▌ ✓ subagent " +24| "▌ ✓ subagent " style 0-0 fg=green style 2-2 fg=green bold style 3-11 bold -27| "▌ DIRECT_CHILD_OK " +25| "▌ DIRECT_CHILD_OK " style 0-0 fg=green +26| "▌ " + style 0-0 fg=green +27| 28| "▌ " style 0-0 fg=green -29| -30| "▌ " - style 0-0 fg=green -31| "▌ ✓ workflow: advanced-acp-snapshot " +29| "▌ ✓ workflow: advanced-acp-snapshot " style 0-0 fg=green style 2-2 fg=green bold style 3-34 bold -32| "▌ workflow \"advanced-acp-snapshot\" completed (1 agent). " +30| "▌ workflow \"advanced-acp-snapshot\" completed (1 agent). " style 0-0 fg=green -33| "▌ Return value: " +31| "▌ Return value: " style 0-0 fg=green -34| "▌ { " +32| "▌ { " style 0-0 fg=green -35| "▌ \"reply\": \"WORKFLOW_CHILD_OK\" " +33| "▌ \"reply\": \"WORKFLOW_CHILD_OK\" " style 0-0 fg=green -36| "▌ } " +34| "▌ } " style 0-0 fg=green +35| "▌ " + style 0-0 fg=green +36| 37| "▌ " style 0-0 fg=green -38| -39| "▌ " - style 0-0 fg=green -40| "▌ ✓ Unmount dyn-1 " +38| "▌ ✓ Unmount dyn-1 " style 0-0 fg=green style 2-2 fg=green bold style 3-16 bold -41| "▌ unmounted dyn-1 (plugin \"snapshot-marker\") " +39| "▌ unmounted dyn-1 (plugin \"snapshot-marker\") " style 0-0 fg=green -42| "▌ " +40| "▌ " style 0-0 fg=green -43| -44| " Assistant " +41| +42| " Assistant " style 1-9 fg=bright-magenta bold -45| " ADVANCED_ACP_OK " +43| " ADVANCED_ACP_OK " +44| "────────────────────────────────────────────────────────────────────────────────────────────────────" + style 0-99 dim +45| " " + style 1-1 inverse 46| "────────────────────────────────────────────────────────────────────────────────────────────────────" style 0-99 dim -47| " " - style 1-1 inverse -48| "────────────────────────────────────────────────────────────────────────────────────────────────────" - style 0-99 dim -49| "/tmp/dsh-tui-snapshot-cordis-dyn ↑18 ↓18 7% context tools:compact deepseek-v4-flash(reasoning:on)" - style 0-31 dim - style 42-99 dim +47| "deepseek-v4-flash /workspace/project ↑18 ↓18 cache 0% tools:c" + style 0-90 dim + style 93-99 dim diff --git a/examples/tui-agent/tests/snapshots/dynamic-workflow/terminal.expected.txt b/examples/tui-agent/tests/snapshots/dynamic-workflow/terminal.expected.txt index 114026295e..fd88ea5b40 100644 --- a/examples/tui-agent/tests/snapshots/dynamic-workflow/terminal.expected.txt +++ b/examples/tui-agent/tests/snapshots/dynamic-workflow/terminal.expected.txt @@ -1,106 +1,96 @@ -terminal 100x36 buffer=normal length=47 base=11 viewport=11 +terminal 100x36 buffer=normal length=45 base=9 viewport=9 lifecycle started=1 stopped=0 progress=inactive -title "Use the workflow tool exactly — DSH TUI snapshot" -cursor hidden column=1 viewportRow=33 bufferRow=44 +title "DSH TUI snapshot" +cursor hidden column=1 viewportRow=33 bufferRow=42 buffer -0| "╭──────────────────────────────────────────────────────────────────────────────────────────────────╮" - style 0-99 fg=bright-blue -1| "│ DEEPSEEK HARNESS │" +0| " DEEPSEEK HARNESS" + style 1-8 fg=bright-blue bold + style 10-16 bold +1| " Recorded replay: dynamic-workflow" + style 1-33 fg=bright-black +2| " deepseek-v4-flash • main-session" + style 1-34 dim +3| +4| "▌ " style 0-0 fg=bright-blue - style 2-9 fg=bright-blue bold - style 11-17 bold - style 99-99 fg=bright-blue -2| "│ Use the workflow tool exactly │" - style 0-0 fg=bright-blue - style 2-30 fg=bright-black - style 99-99 fg=bright-blue -3| "│ deepseek-v4-flash • main-session │" - style 0-0 fg=bright-blue - style 2-35 dim - style 99-99 fg=bright-blue -4| "╰──────────────────────────────────────────────────────────────────────────────────────────────────╯" - style 0-99 fg=bright-blue -5| -6| "▌ " - style 0-0 fg=bright-blue -7| "▌ You " +5| "▌ You " style 0-0 fg=bright-blue style 2-4 fg=bright-blue bold -8| "▌ Use the workflow tool exactly once, with args omitted, meta set to { \"name\": \"snapshot-flow\", " +6| "▌ Use the workflow tool exactly once, with args omitted, meta set to { \"name\": \"snapshot-flow\", " style 0-0 fg=bright-blue -9| "▌ \"description\": \"one child for the snapshot\" }, and this EXACT script body (copy it verbatim): " +7| "▌ \"description\": \"one child for the snapshot\" }, and this EXACT script body (copy it verbatim): " style 0-0 fg=bright-blue -10| "▌ phase('Run') " +8| "▌ phase('Run') " style 0-0 fg=bright-blue -11| "▌ const reply = await agent('Reply with exactly the word WF_CHILD_OK and nothing else.') " +9| "▌ const reply = await agent('Reply with exactly the word WF_CHILD_OK and nothing else.') " style 0-0 fg=bright-blue -12| "▌ return { reply } " +10| "▌ return { reply } " style 0-0 fg=bright-blue -13| "▌ After the workflow returns, reply with the single word WORKFLOW_DONE and stop. Do not use any " +11| "▌ After the workflow returns, reply with the single word WORKFLOW_DONE and stop. Do not use any " style 0-0 fg=bright-blue -14| "▌ other tool. " +12| "▌ other tool. " style 0-0 fg=bright-blue -15| "▌ " +13| "▌ " style 0-0 fg=bright-blue -16| -17| " Reasoning " +14| +15| " Reasoning " style 1-9 fg=bright-black italic -18| " The user wants me to use the workflow tool exactly once with specific parameters. Let me carefully " +16| " The user wants me to use the workflow tool exactly once with specific parameters. Let me carefully " style 1-99 fg=bright-black italic -19| " follow the instructions: " +17| " follow the instructions: " style 1-24 fg=bright-black italic -20| " " -21| " 1. args omitted (so I don't include it) " +18| " " +19| " 1. args omitted (so I don't include it) " style 1-3 fg=bright-blue style 4-39 fg=bright-black italic -22| " 2. meta = { \"name\": \"snapshot-flow\", \"description\": \"one child for the snapshot\" } " +20| " 2. meta = { \"name\": \"snapshot-flow\", \"description\": \"one child for the snapshot\" } " style 1-3 fg=bright-blue style 4-82 fg=bright-black italic -23| " 3. script = as given verbatim " +21| " 3. script = as given verbatim " style 1-3 fg=bright-blue style 4-29 fg=bright-black italic -24| " 4. After it returns, reply with \"WORKFLOW_DONE\" " +22| " 4. After it returns, reply with \"WORKFLOW_DONE\" " style 1-3 fg=bright-blue style 4-47 fg=bright-black italic -25| " " -26| " Let me do exactly that. " +23| " " +24| " Let me do exactly that. " style 1-23 fg=bright-black italic -27| -28| "▌ " +25| +26| "▌ " style 0-0 fg=green -29| "▌ ✓ workflow: snapshot-flow " +27| "▌ ✓ workflow: snapshot-flow " style 0-0 fg=green style 2-2 fg=green bold style 3-26 bold -30| "▌ workflow \"snapshot-flow\" completed (1 agent). " +28| "▌ workflow \"snapshot-flow\" completed (1 agent). " style 0-0 fg=green -31| "▌ Return value: " +29| "▌ Return value: " style 0-0 fg=green -32| "▌ { " +30| "▌ { " style 0-0 fg=green -33| "▌ \"reply\": \"WF_CHILD_OK\" " +31| "▌ \"reply\": \"WF_CHILD_OK\" " style 0-0 fg=green -34| "▌ } " +32| "▌ } " style 0-0 fg=green -35| "▌ " +33| "▌ " style 0-0 fg=green -36| -37| " Reasoning " +34| +35| " Reasoning " style 1-9 fg=bright-black italic -38| " The workflow returned successfully with the reply \"WF_CHILD_OK\". Now I need to reply with exactly " +36| " The workflow returned successfully with the reply \"WF_CHILD_OK\". Now I need to reply with exactly " style 1-99 fg=bright-black italic -39| " \"WORKFLOW_DONE\" and stop. " +37| " \"WORKFLOW_DONE\" and stop. " style 1-25 fg=bright-black italic -40| -41| " Assistant " +38| +39| " Assistant " style 1-9 fg=bright-magenta bold -42| " WORKFLOW_DONE " +40| " WORKFLOW_DONE " +41| "────────────────────────────────────────────────────────────────────────────────────────────────────" + style 0-99 dim +42| " " + style 1-1 inverse 43| "────────────────────────────────────────────────────────────────────────────────────────────────────" style 0-99 dim -44| " " - style 1-1 inverse -45| "────────────────────────────────────────────────────────────────────────────────────────────────────" - style 0-99 dim -46| "/tmp/dsh-tui-snapshot-dynamic ↑3.5k ↓227 3% context tools:compact deepseek-v4-flash(reasoning:on)" - style 0-28 dim - style 42-99 dim +44| "deepseek-v4-flash /workspace/project ↑3.5k ↓227 cache 47% tools:colla" + style 0-86 dim + style 89-99 dim diff --git a/examples/tui-agent/tests/snapshots/multi-turn-conversation/terminal.expected.txt b/examples/tui-agent/tests/snapshots/multi-turn-conversation/terminal.expected.txt index 69b9371fb0..ecc3c796e9 100644 --- a/examples/tui-agent/tests/snapshots/multi-turn-conversation/terminal.expected.txt +++ b/examples/tui-agent/tests/snapshots/multi-turn-conversation/terminal.expected.txt @@ -1,70 +1,60 @@ terminal 100x36 buffer=normal length=36 base=0 viewport=0 lifecycle started=1 stopped=0 progress=inactive -title "Reply with exactly the word: — DSH TUI snapshot" -cursor hidden column=1 viewportRow=28 bufferRow=28 +title "DSH TUI snapshot" +cursor hidden column=1 viewportRow=26 bufferRow=26 buffer -0| "╭──────────────────────────────────────────────────────────────────────────────────────────────────╮" - style 0-99 fg=bright-blue -1| "│ DEEPSEEK HARNESS │" +0| " DEEPSEEK HARNESS" + style 1-8 fg=bright-blue bold + style 10-16 bold +1| " Recorded replay: multi-turn-conversation" + style 1-40 fg=bright-black +2| " deepseek-v4-flash • main-session" + style 1-34 dim +3| +4| "▌ " style 0-0 fg=bright-blue - style 2-9 fg=bright-blue bold - style 11-17 bold - style 99-99 fg=bright-blue -2| "│ Reply with exactly the word: │" - style 0-0 fg=bright-blue - style 2-29 fg=bright-black - style 99-99 fg=bright-blue -3| "│ deepseek-v4-flash • main-session │" - style 0-0 fg=bright-blue - style 2-35 dim - style 99-99 fg=bright-blue -4| "╰──────────────────────────────────────────────────────────────────────────────────────────────────╯" - style 0-99 fg=bright-blue -5| -6| "▌ " - style 0-0 fg=bright-blue -7| "▌ You " +5| "▌ You " style 0-0 fg=bright-blue style 2-4 fg=bright-blue bold -8| "▌ Reply with exactly the word: ONE. No tools. " +6| "▌ Reply with exactly the word: ONE. No tools. " style 0-0 fg=bright-blue -9| "▌ " +7| "▌ " style 0-0 fg=bright-blue -10| -11| " Reasoning " +8| +9| " Reasoning " style 1-9 fg=bright-black italic -12| " The user wants me to reply with exactly the word \"ONE\" and use no tools. " +10| " The user wants me to reply with exactly the word \"ONE\" and use no tools. " style 1-72 fg=bright-black italic -13| -14| " Assistant " +11| +12| " Assistant " style 1-9 fg=bright-magenta bold -15| " ONE " -16| -17| "▌ " +13| " ONE " +14| +15| "▌ " style 0-0 fg=bright-blue -18| "▌ You " +16| "▌ You " style 0-0 fg=bright-blue style 2-4 fg=bright-blue bold -19| "▌ Reply with exactly the word: TWO. No tools. " +17| "▌ Reply with exactly the word: TWO. No tools. " style 0-0 fg=bright-blue -20| "▌ " +18| "▌ " style 0-0 fg=bright-blue -21| -22| " Reasoning " +19| +20| " Reasoning " style 1-9 fg=bright-black italic -23| " The user wants me to reply with exactly the word \"TWO\" and no tools. " +21| " The user wants me to reply with exactly the word \"TWO\" and no tools. " style 1-68 fg=bright-black italic -24| -25| " Assistant " +22| +23| " Assistant " style 1-9 fg=bright-magenta bold -26| " TWO " +24| " TWO " +25| "────────────────────────────────────────────────────────────────────────────────────────────────────" + style 0-99 dim +26| " " + style 1-1 inverse 27| "────────────────────────────────────────────────────────────────────────────────────────────────────" style 0-99 dim -28| " " - style 1-1 inverse -29| "────────────────────────────────────────────────────────────────────────────────────────────────────" - style 0-99 dim -30| "/tmp/dsh-tui-snapshot-multi-tu ↑2.9k ↓41 3% context tools:compact deepseek-v4-flash(reasoning:on)" - style 0-29 dim - style 42-99 dim -31-35| +28| "deepseek-v4-flash /workspace/project ↑2.9k ↓41 cache 49% tools" + style 0-92 dim + style 95-99 dim +29-35| diff --git a/examples/tui-agent/tests/snapshots/parallel-file-reads/terminal.expected.txt b/examples/tui-agent/tests/snapshots/parallel-file-reads/terminal.expected.txt index f56a8f8a24..5fd1a0942e 100644 --- a/examples/tui-agent/tests/snapshots/parallel-file-reads/terminal.expected.txt +++ b/examples/tui-agent/tests/snapshots/parallel-file-reads/terminal.expected.txt @@ -1,91 +1,81 @@ -terminal 100x36 buffer=normal length=39 base=3 viewport=3 +terminal 100x36 buffer=normal length=37 base=1 viewport=1 lifecycle started=1 stopped=0 progress=inactive -title "Use the read tool twice — DSH TUI snapshot" -cursor hidden column=1 viewportRow=33 bufferRow=36 +title "DSH TUI snapshot" +cursor hidden column=1 viewportRow=33 bufferRow=34 buffer -0| "╭──────────────────────────────────────────────────────────────────────────────────────────────────╮" - style 0-99 fg=bright-blue -1| "│ DEEPSEEK HARNESS │" +0| " DEEPSEEK HARNESS" + style 1-8 fg=bright-blue bold + style 10-16 bold +1| " Recorded replay: parallel-file-reads" + style 1-36 fg=bright-black +2| " deepseek-v4-flash • main-session" + style 1-34 dim +3| +4| "▌ " style 0-0 fg=bright-blue - style 2-9 fg=bright-blue bold - style 11-17 bold - style 99-99 fg=bright-blue -2| "│ Use the read tool twice │" - style 0-0 fg=bright-blue - style 2-24 fg=bright-black - style 99-99 fg=bright-blue -3| "│ deepseek-v4-flash • main-session │" - style 0-0 fg=bright-blue - style 2-35 dim - style 99-99 fg=bright-blue -4| "╰──────────────────────────────────────────────────────────────────────────────────────────────────╯" - style 0-99 fg=bright-blue -5| -6| "▌ " - style 0-0 fg=bright-blue -7| "▌ You " +5| "▌ You " style 0-0 fg=bright-blue style 2-4 fg=bright-blue bold -8| "▌ Use the read tool twice in the same assistant message: read a.txt and b.txt. Then reply DONE. " +6| "▌ Use the read tool twice in the same assistant message: read a.txt and b.txt. Then reply DONE. " style 0-0 fg=bright-blue +7| "▌ " + style 0-0 fg=bright-blue +8| 9| "▌ " - style 0-0 fg=bright-blue -10| -11| "▌ " style 0-0 fg=green -12| "▌ ✓ Read a.txt " +10| "▌ ✓ Read a.txt " style 0-0 fg=green style 2-2 fg=green bold style 3-13 bold -13| "▌ /workspace/project/a.txt " +11| "▌ /workspace/project/a.txt " style 0-0 fg=green -14| "▌ file " +12| "▌ file " style 0-0 fg=green -15| "▌ " +13| "▌ " style 0-0 fg=green -16| "▌ 1: alpha " +14| "▌ 1: alpha " style 0-0 fg=green -17| "▌ " +15| "▌ " style 0-0 fg=green -18| "▌ (End of file - total 1 lines) " +16| "▌ (End of file - total 1 lines) " style 0-0 fg=green -19| "▌ " +17| "▌ " style 0-0 fg=green +18| "▌ " + style 0-0 fg=green +19| 20| "▌ " style 0-0 fg=green -21| -22| "▌ " - style 0-0 fg=green -23| "▌ ✓ Read b.txt " +21| "▌ ✓ Read b.txt " style 0-0 fg=green style 2-2 fg=green bold style 3-13 bold -24| "▌ /workspace/project/b.txt " +22| "▌ /workspace/project/b.txt " style 0-0 fg=green -25| "▌ file " +23| "▌ file " style 0-0 fg=green -26| "▌ " +24| "▌ " style 0-0 fg=green -27| "▌ 1: beta " +25| "▌ 1: beta " style 0-0 fg=green -28| "▌ " +26| "▌ " style 0-0 fg=green -29| "▌ (End of file - total 1 lines) " +27| "▌ (End of file - total 1 lines) " style 0-0 fg=green -30| "▌ " +28| "▌ " style 0-0 fg=green -31| "▌ " +29| "▌ " style 0-0 fg=green -32| -33| " Assistant " +30| +31| " Assistant " style 1-9 fg=bright-magenta bold -34| " DONE " +32| " DONE " +33| "────────────────────────────────────────────────────────────────────────────────────────────────────" + style 0-99 dim +34| " " + style 1-1 inverse 35| "────────────────────────────────────────────────────────────────────────────────────────────────────" style 0-99 dim -36| " " - style 1-1 inverse -37| "────────────────────────────────────────────────────────────────────────────────────────────────────" - style 0-99 dim -38| "/tmp/dsh-tui-snapshot-parallel-fi ↑20 ↓6 3% context tools:compact deepseek-v4-flash(reasoning:on)" - style 0-32 dim - style 42-99 dim +36| "deepseek-v4-flash /workspace/project ↑20 ↓6 cache 0% tools:collaps" + style 0-84 dim + style 87-99 dim diff --git a/examples/tui-agent/tests/snapshots/todo-plan/terminal.expected.txt b/examples/tui-agent/tests/snapshots/todo-plan/terminal.expected.txt index 95774b4bfb..9022f28170 100644 --- a/examples/tui-agent/tests/snapshots/todo-plan/terminal.expected.txt +++ b/examples/tui-agent/tests/snapshots/todo-plan/terminal.expected.txt @@ -1,81 +1,72 @@ terminal 100x36 buffer=normal length=36 base=0 viewport=0 lifecycle started=1 stopped=0 progress=inactive -title "Use the todo_write tool to — DSH TUI snapshot" -cursor hidden column=1 viewportRow=33 bufferRow=33 +title "DSH TUI snapshot" +cursor hidden column=1 viewportRow=31 bufferRow=31 buffer -0| "╭──────────────────────────────────────────────────────────────────────────────────────────────────╮" - style 0-99 fg=bright-blue -1| "│ DEEPSEEK HARNESS │" +0| " DEEPSEEK HARNESS" + style 1-8 fg=bright-blue bold + style 10-16 bold +1| " Recorded replay: todo-plan" + style 1-26 fg=bright-black +2| " deepseek-v4-flash • main-session" + style 1-34 dim +3| +4| "▌ " style 0-0 fg=bright-blue - style 2-9 fg=bright-blue bold - style 11-17 bold - style 99-99 fg=bright-blue -2| "│ Use the todo_write tool to │" - style 0-0 fg=bright-blue - style 2-27 fg=bright-black - style 99-99 fg=bright-blue -3| "│ deepseek-v4-flash • main-session │" - style 0-0 fg=bright-blue - style 2-35 dim - style 99-99 fg=bright-blue -4| "╰──────────────────────────────────────────────────────────────────────────────────────────────────╯" - style 0-99 fg=bright-blue -5| -6| "▌ " - style 0-0 fg=bright-blue -7| "▌ You " +5| "▌ You " style 0-0 fg=bright-blue style 2-4 fg=bright-blue bold -8| "▌ Use the todo_write tool to record a plan with exactly three todos: \"read the code\" (in_progress), " +6| "▌ Use the todo_write tool to record a plan with exactly three todos: \"read the code\" (in_progress), " style 0-0 fg=bright-blue -9| "▌ \"write the fix\" (pending), \"run the tests\" (pending). Send all three in one todo_write call. Then " +7| "▌ \"write the fix\" (pending), \"run the tests\" (pending). Send all three in one todo_write call. Then " style 0-0 fg=bright-blue -10| "▌ reply with the single word DONE and stop. " +8| "▌ reply with the single word DONE and stop. " style 0-0 fg=bright-blue -11| "▌ " +9| "▌ " style 0-0 fg=bright-blue -12| -13| " Reasoning " +10| +11| " Reasoning " style 1-9 fg=bright-black italic -14| " The user wants me to use the todo_write tool to record a plan with exactly three todos in the " +12| " The user wants me to use the todo_write tool to record a plan with exactly three todos in the " style 1-99 fg=bright-black italic -15| " specified statuses, then reply with \"DONE\". " +13| " specified statuses, then reply with \"DONE\". " style 1-43 fg=bright-black italic -16| -17| "▌ " +14| +15| "▌ " style 0-0 fg=green -18| "▌ ✓ Update todo list " +16| "▌ ✓ Update todo list " style 0-0 fg=green style 2-2 fg=green bold style 3-19 bold -19| "▌ Updated todo list: 2 pending, 1 in progress, 0 completed. " +17| "▌ Updated todo list: 2 pending, 1 in progress, 0 completed. " style 0-0 fg=green -20| "▌ " +18| "▌ " style 0-0 fg=green -21| -22| " Reasoning " +19| +20| " Reasoning " style 1-9 fg=bright-black italic -23| " The todos have been written successfully. Now I just need to reply with the single word \"DONE\". " +21| " The todos have been written successfully. Now I just need to reply with the single word \"DONE\". " style 1-95 fg=bright-black italic -24| -25| " Assistant " +22| +23| " Assistant " style 1-9 fg=bright-magenta bold -26| " DONE " -27| -28| "Plan" +24| " DONE " +25| +26| "Plan" style 0-3 fg=bright-blue bold -29| " ● read the code" +27| " ● read the code" style 2-2 fg=yellow -30| " ○ write the fix" +28| " ○ write the fix" style 2-2 dim -31| " ○ run the tests" +29| " ○ run the tests" style 2-2 dim +30| "────────────────────────────────────────────────────────────────────────────────────────────────────" + style 0-99 dim +31| " " + style 1-1 inverse 32| "────────────────────────────────────────────────────────────────────────────────────────────────────" style 0-99 dim -33| " " - style 1-1 inverse -34| "────────────────────────────────────────────────────────────────────────────────────────────────────" - style 0-99 dim -35| "/tmp/dsh-tui-snapshot-todo-pl ↑3.1k ↓145 3% context tools:compact deepseek-v4-flash(reasoning:on)" - style 0-28 dim - style 42-99 dim +33| "deepseek-v4-flash /workspace/project ↑3.1k ↓145 cache 47% tools:collapsed" + style 0-79 dim + style 85-99 dim +34-35| diff --git a/packages/ui/tui/tests/harness.ts b/packages/ui/tui/tests/harness.ts index 1109895c3b..a8641ded63 100644 --- a/packages/ui/tui/tests/harness.ts +++ b/packages/ui/tui/tests/harness.ts @@ -24,6 +24,8 @@ interface FakeAgent extends Agent { export interface TuiHarnessOptions { status?: AgentStatus config?: Config + /** Omit the harness's default `welcome`, exercising the banner sweep-reveal path. */ + omitWelcome?: boolean tools?: Record configureContext?: (ctx: Context) => Promise beforeMount?: (session: Session) => void @@ -142,7 +144,7 @@ export async function createTuiTestHarness ``` -A same-file edit starts with `Updated instructions from: ` and says to use the new content instead of the previously loaded content. A candidate switch additionally names the old path. When no candidate remains, the message is `Instructions removed: ` followed by `The previously loaded instructions from this file no longer apply.` Literal `` text inside an instruction file is escaped so file content cannot close the plugin-owned frame. +A same-file edit starts with `Updated instructions from: ` and says to use the new content instead of the previously loaded content. When a candidate disappears or becomes a per-directory duplicate of an earlier candidate, the message is `Instructions removed: ` followed by `The previously loaded instructions from this file no longer apply.` Literal `` text inside an instruction file is escaped so file content cannot close the plugin-owned frame. The plugin owns the complete `` framing, and every `context/message` (from this plugin or any other) reaches the model verbatim as a user-role message with no wrapping. ## State And Refresh -Model-visible text contains no hidden state markers. Each dynamic context event instead carries JSON metadata with a versioned list of `{ action, scope, path, previousPath?, digest? }` changes. On every relevant tool touch, the plugin reconstructs loaded state from its visible session events and overlays a short in-memory pending window for context present on the immutable top-level `tools/result` but not yet appended by the loop. A matching durable `context/message` confirms the pending transition. If the owning `step/end` arrives before a matching context reaches the log, the plugin clears the pending transition and its version fast path so the next successful touch can load it again. Nested Code Mode results stage pending changes under the outer execution token for same-run duplicate suppression; the outer result rolls that state back and recommits only contexts that survived outer policy. +Model-visible text contains no hidden state markers. Each dynamic context event instead carries JSON metadata with a versioned list of `{ action, scope, path, digest? }` changes. On every relevant tool touch, the plugin reconstructs loaded state from its visible session events and overlays a short in-memory pending window for context present on the immutable top-level `tools/result` but not yet appended by the loop. A matching durable `context/message` confirms the pending transition. If the owning `step/end` arrives before a matching context reaches the log, the plugin clears the pending transition and its version fast path so the next successful touch can load it again. Nested Code Mode results stage pending changes under the outer execution token for same-run duplicate suppression; the outer result rolls that state back and recommits only contexts that survived outer policy. -An unchanged path and SHA-1 content digest is not injected again. A per-session, per-scope metadata cache stores only `{ path, version, digest }`: when the provider's opaque `FsVersion` and the effective visible state both match, reconciliation skips the content read; a changed version triggers a bounded read and SHA-1 confirmation before any model-visible update. Resume works because SHA-1 state is persisted in the session log, while an empty in-memory version cache merely causes one confirming read. Compaction re-arms a scope after its context event leaves the visible surface even when the cached version is unchanged. A removal is a tombstone, so a later candidate reappearance is loaded again. Only model-visible changes actually rendered within the byte budget enter metadata, pending state, and the version cache; an omitted change remains eligible for a later touch, while a same-digest version refresh updates metadata only. +An unchanged path and SHA-1 content digest is not injected again. A per-session, per-scope metadata cache stores only `{ path, version, digest, trimmedDigest }`: when the provider's opaque `FsVersion` and the effective visible state both match, reconciliation skips the content read; a changed version triggers a bounded read and SHA-1 confirmation before any model-visible update. The `trimmedDigest` — SHA-1 over the whitespace-trimmed content — is the per-directory duplicate key, so an unchanged file can still be removed when an earlier candidate converges on its content. Resume works because SHA-1 state is persisted in the session log, while an empty in-memory version cache merely causes one confirming read. Compaction re-arms a scope after its context event leaves the visible surface even when the cached version is unchanged. A removal is a tombstone, so a later candidate reappearance is loaded again. Only model-visible changes actually rendered within the byte budget enter metadata, pending state, and the version cache; an omitted change remains eligible for a later touch, while a same-digest version refresh updates metadata only. The frozen baseline itself is not rewritten mid-instance. Its initial path/digest map is retained as comparison state; the next successful filesystem touch appends any baseline replacement or removal. A resumed loop recomposes the current baseline and also reconciles still-visible dynamic scopes during prefix composition. There is no file watcher, so an on-disk change becomes visible at the next successful `read`, `write`, or `edit` touch, or when a resumed loop composes its prefix. @@ -65,7 +65,7 @@ export interface Config { } ``` -`maxBytes` is required so each deployment makes its prompt-budget choice explicitly. `maxSourceBytes` limits each source instruction file before rendering and defaults to 1 MiB. `projectRootMarkers` defaults to `['.git']`, and `instructionFileCandidates` defaults to `['AGENTS.md', 'CLAUDE.md']`. In each project directory, the first existing candidate wins; with defaults, `AGENTS.md` is native and `CLAUDE.md` is the compatibility fallback. `localInstructionFileCandidates` defaults to `['AGENTS.local.md', 'CLAUDE.local.md']` and loads the first existing local overlay *in addition to* the base file of the same directory (rendered after it); an empty list disables the overlay. Candidate entries in both lists must be same-directory file names, so empty entries, `.`/`..`, and entries containing `/` or `\` are ignored. +`maxBytes` is required so each deployment makes its prompt-budget choice explicitly. `maxSourceBytes` limits each source instruction file before rendering and defaults to 1 MiB. `projectRootMarkers` defaults to `['.git']`, and `instructionFileCandidates` defaults to `['AGENTS.md', 'CLAUDE.md']`. In each project directory every existing candidate loads, and candidates whose content matches an earlier one after trimming surrounding whitespace are dropped, so with the defaults an `AGENTS.md` and a `CLAUDE.md` that share content render once (as `AGENTS.md`) while genuinely distinct siblings both apply. `localInstructionFileCandidates` defaults to `['AGENTS.local.md', 'CLAUDE.local.md']` and loads its existing overlays alongside the base files of the same directory (rendered after them) under the same per-directory dedup; an empty list disables the overlay. Candidate entries in both lists must be same-directory file names, so empty entries, `.`/`..`, and entries containing `/` or `\` are ignored. The user-global file is always `$DSH_HOME/AGENTS.md` with no local overlay; both candidate lists only control project scopes. `$DSH_HOME` defaults to `~/.dsh`, and configured `~`, `~/...`, and Windows-style `~\...` prefixes are expanded against the operating-system home directory. A non-positive or non-finite render budget disables both baseline and dynamic loading; configured `maxSourceBytes` must be a positive integer. @@ -73,7 +73,7 @@ The user-global file is always `$DSH_HOME/AGENTS.md` with no local overlay; both Rendering preserves the most specific instruction files first. It drops whole broader files before truncating the most-specific file and emits a visible `Workspace instruction budget ...` notice naming omitted and truncated paths. The rendered bytes never exceed `maxBytes`. -Instruction content is read through `streamText()` under `maxSourceBytes`, even when provider metadata omits size or a file grows after its metadata probe. An oversized file is ignored without falling through to a lower-priority same-directory candidate; during dynamic reconciliation it is temporarily unavailable rather than removed. The plugin keeps no process-wide cache and never caches instruction prose. Its session-local scope cache uses provider versions only as a fast invalidation signal; after invalidation, SHA-1 over the bounded read remains the cross-provider content identity stored in structured session metadata. +Instruction content is read through `streamText()` under `maxSourceBytes`, even when provider metadata omits size or a file grows after its metadata probe. An oversized file is ignored; during dynamic reconciliation it is temporarily unavailable rather than removed. The plugin keeps no process-wide cache and never caches instruction prose. Its session-local scope cache uses provider versions only as a fast invalidation signal; after invalidation, SHA-1 over the bounded read remains the cross-provider content identity stored in structured session metadata. ## Model Experience @@ -137,7 +137,7 @@ Append-only; newly visible content follows the reusable request prefix and does #### What the model sees -A changed file produces `Updated instructions from: ` plus its replacement content; a candidate switch also names the previous path. A removed final candidate produces the removal notice below. +A changed file produces `Updated instructions from: ` plus its replacement content. A candidate that disappears or becomes a per-directory duplicate of an earlier candidate produces the removal notice below. ##### Removal notice @@ -162,4 +162,6 @@ Append-only; newly visible content follows the reusable request prefix and does - **Discovery follows structured fs tools, not shell navigation** — a `bash` command that changes directories does not trigger nested instruction discovery because shell syntax and per-call shell state are not a reliable filesystem seam. - **Refresh is touch-driven** — there is no watcher; external edits become visible on the next successful first-party `read`, `write`, or `edit`, or when a resumed loop recomposes its prefix. - **Candidate semantics stay intentionally small** — lowercase names, `.claude/rules/`, and `@path` imports are not interpreted; project scopes load `AGENTS.local.md`/`CLAUDE.local.md` overlays by default, but the user-global `$DSH_HOME` scope has no local overlay and other custom names require explicit candidate configuration. +- **Per-directory dedup is content-based** — sibling candidates collapse only when byte-identical after trimming leading and trailing whitespace; a `CLAUDE.md` that symlinks its sibling `AGENTS.md` resolves to the same content and collapses like any duplicate, while a distinct real copy that has drifted from `AGENTS.md` loads in full alongside it. +- **Symlinked instruction files are followed across the trust boundary** — a candidate whose final component is a symlink is resolved and its target loaded, so a cloned repository can surface off-tree file content as lower-authority workspace guidance (it never overrides system, developer, or direct user instructions). Confine `ctx.fs` with the filesystem policy gate or an OS sandbox when loading untrusted repositories. - **Instruction content is bounded, not summarized** — over-budget broad files are omitted and the most-specific file may be truncated; the plugin never asks a model to compress instruction prose. diff --git a/packages/context/workspace-context/src/config.ts b/packages/context/workspace-context/src/config.ts index 4b966e890c..56c048976c 100644 --- a/packages/context/workspace-context/src/config.ts +++ b/packages/context/workspace-context/src/config.ts @@ -23,9 +23,15 @@ export interface Config { maxBytes: number /** Maximum UTF-8 bytes read from one instruction file; larger files are ignored. */ maxSourceBytes?: number - /** Ordered same-directory project candidates; the first existing regular file wins in each scope. */ + /** + * Ordered same-directory project candidates; every existing file loads, with + * per-directory trimmed-content duplicates collapsed to the earliest candidate. + */ instructionFileCandidates?: string[] - /** Ordered same-directory local-overlay candidates loaded in addition to the base file per scope; empty disables the overlay. */ + /** + * Ordered same-directory local-overlay candidates loaded after the base files + * under the same per-directory trimmed-content dedup; empty disables the overlay. + */ localInstructionFileCandidates?: string[] } diff --git a/packages/context/workspace-context/src/digest.ts b/packages/context/workspace-context/src/digest.ts index 4568371277..23ba351046 100644 --- a/packages/context/workspace-context/src/digest.ts +++ b/packages/context/workspace-context/src/digest.ts @@ -14,3 +14,15 @@ import { createHash } from 'node:crypto' export function instructionContentSha1(content: string): string { return createHash('sha1').update(content).digest('hex') } + +/** + * Compute the whitespace-insensitive identity used for per-directory duplicate + * suppression. Leading and trailing whitespace is trimmed before hashing so a + * symlinked or byte-copied sibling that differs only by surrounding whitespace + * still collapses to a single rendered file. + * @param content - exact UTF-8 instruction text. + * @returns SHA-1 digest of the trimmed content. + */ +export function trimmedInstructionDigest(content: string): string { + return instructionContentSha1(content.trim()) +} diff --git a/packages/context/workspace-context/src/files.ts b/packages/context/workspace-context/src/files.ts index 8656582aa4..3e6a3d5de8 100644 --- a/packages/context/workspace-context/src/files.ts +++ b/packages/context/workspace-context/src/files.ts @@ -5,13 +5,14 @@ */ import { createReadStream } from 'node:fs' -import { lstat, stat } from 'node:fs/promises' +import { stat } from 'node:fs/promises' import { dirname, isAbsolute, join, relative, resolve } from 'node:path' -import type { FileSystem, FsInfo, FsPathInfo, FsTarget, FsVersion } from '@deepseek-ai/dsh-fs' +import type { FileSystem, FsInfo, FsTarget, FsVersion } from '@deepseek-ai/dsh-fs' import { assertNever } from '@deepseek-ai/dsh-llm' import { dshHomeDisplay } from '@deepseek-ai/dsh-paths' import { resolveConfig, resolveDiscoveryConfig, type ResolvedConfig } from './config.ts' -import { decodeScopeKey, renderWorkspaceContext, type InstructionTier, type RenderedWorkspaceContext } from './render.ts' +import { trimmedInstructionDigest } from './digest.ts' +import { decodeScopeKey, renderWorkspaceContext, USER_GLOBAL_DIRECTORY, USER_GLOBAL_FILE, type RenderedWorkspaceContext } from './render.ts' /** An instruction candidate identified by absolute and model-facing paths. */ export interface InstructionFile { @@ -24,18 +25,15 @@ export interface LoadedInstructionFile extends InstructionFile { content: string /** Provider freshness token when the file was loaded through `ctx.fs`. */ version?: FsVersion - /** Base file or additive local overlay; absent is treated as base. */ - tier?: InstructionTier } interface DiscoveredInstructionFile extends InstructionFile { target?: FsTarget size?: number version?: FsVersion - tier: InstructionTier } -/** Provider metadata for a winning scope candidate before its content is read. */ +/** Provider metadata for a probed scope candidate before its content is read. */ export interface ProbedInstructionFile extends InstructionFile { target: FsTarget version: FsVersion @@ -90,7 +88,9 @@ function isMissingPathError(error: unknown): boolean { async function nodeStatFile(path: string, signal?: AbortSignal): Promise { try { signal?.throwIfAborted() - const info = await lstat(path) + // stat (not lstat) follows a final-component symlink so a link to a regular + // file loads; a broken link surfaces as ENOENT and is treated as absent below. + const info = await stat(path) signal?.throwIfAborted() if (!info.isFile()) return { kind: 'absent' } return { kind: 'present', info: { size: info.size } } @@ -105,25 +105,15 @@ async function fsStatFile( fileSystem: FileSystem, signal?: AbortSignal, ): Promise { - // TODO(instruction-symlink-race): replace this lstat -> resolve -> read - // protocol, including probeScopeInstruction below, with a provider-owned - // atomic no-follow read so the final component cannot change after validation. - let pathInfo: FsPathInfo | undefined - try { - pathInfo = await fileSystem.lstat(path, undefined, signal) - signal?.throwIfAborted() - } catch { - signal?.throwIfAborted() - return { kind: 'unavailable' } - } - if (pathInfo?.type !== 'file') return { kind: 'absent' } - + // resolve() follows a final-component symlink to its target's stable identity; + // stat then classifies that target. A link to a regular file loads, while a + // missing path or non-file target (including a link to a directory) is absent. try { const target = await fileSystem.resolve(path, signalOptions(signal)) signal?.throwIfAborted() const info = await fileSystem.stat(target, signal) signal?.throwIfAborted() - if (info?.type !== 'file') return { kind: 'unavailable' } + if (info?.type !== 'file') return { kind: 'absent' } return { kind: 'present', info: { target, version: info.version, ...info.size === undefined ? {} : { size: info.size } }, @@ -236,35 +226,32 @@ export function relativeDisplay(root: string, path: string): string { return relative(root, path) } -async function firstExistingInstructionFile( +async function allExistingInstructionFiles( dir: string, root: string, instructionFileCandidates: readonly string[], - tier: InstructionTier, fileSystem?: FileSystem, signal?: AbortSignal, -): Promise { +): Promise { + const found: DiscoveredInstructionFile[] = [] for (const candidate of instructionFileCandidates) { const path = join(dir, candidate) const probe = await statFile(path, fileSystem, signal) switch (probe.kind) { case 'present': - return { - absolutePath: path, - displayPath: relativeDisplay(root, path), - tier, - ...probe.info, - } - case 'absent': + found.push({ absolutePath: path, displayPath: relativeDisplay(root, path), ...probe.info }) continue + // A missing candidate is skipped; a transient provider failure skips only + // that candidate so the remaining independent candidates still load. + case 'absent': case 'unavailable': - return undefined + continue /* v8 ignore next 2 -- StatFileProbe is closed; this arm only makes adding a kind a compile error. */ default: - return assertNever(probe, 'StatFileProbe') + assertNever(probe, 'StatFileProbe') } } - return undefined + return found } async function discoverInstructionFiles( @@ -280,14 +267,13 @@ async function discoverInstructionFiles( files.push(file) } - const userGlobal = join(config.dshHome, 'AGENTS.md') + const userGlobal = join(config.dshHome, USER_GLOBAL_FILE) const userGlobalProbe = await statFile(userGlobal, fileSystem, options.signal) switch (userGlobalProbe.kind) { case 'present': addFile({ absolutePath: userGlobal, displayPath: userGlobalDisplayPath(config.dshHome), - tier: 'base', ...userGlobalProbe.info, }) break @@ -302,11 +288,10 @@ async function discoverInstructionFiles( const cwd = resolve(options.cwd) const projectRoot = await findProjectRoot(cwd, config.projectRootMarkers, fileSystem, options.signal) for (const dir of ancestorChain(projectRoot, cwd)) { - const base = await firstExistingInstructionFile(dir, projectRoot, config.instructionFileCandidates, 'base', fileSystem, options.signal) - if (base !== undefined) addFile(base) - if (config.localInstructionFileCandidates.length > 0) { - const local = await firstExistingInstructionFile(dir, projectRoot, config.localInstructionFileCandidates, 'local', fileSystem, options.signal) - if (local !== undefined) addFile(local) + for (const candidates of [config.instructionFileCandidates, config.localInstructionFileCandidates]) { + for (const file of await allExistingInstructionFiles(dir, projectRoot, candidates, fileSystem, options.signal)) { + addFile(file) + } } } return files @@ -314,8 +299,10 @@ async function discoverInstructionFiles( /** * Discover host-visible user-global and root-to-cwd instruction candidates. + * All present candidates in each directory are returned; trimmed-content + * duplicates are collapsed later, once content is read. * @param options - cwd, home, root marker, and candidate configuration. - * @returns de-duplicated instruction paths in model precedence order. + * @returns path-deduplicated instruction candidates in model precedence order. */ export async function discoverBaselineInstructionFiles(options: DiscoverOptions): Promise { return (await discoverInstructionFiles(options)).map(({ absolutePath, displayPath }) => ({ absolutePath, displayPath })) @@ -358,6 +345,33 @@ async function readBounded( } } +/** + * Drop later candidates whose trimmed content duplicates an earlier sibling in + * the same directory. Different directories never collapse even when identical; + * within one directory the earliest candidate in discovery order is kept and its + * original bytes are rendered. A candidate that symlinks a sibling resolves to + * the same content and collapses here like any byte-identical real file. + * @param files - loaded files in discovery order. + * @returns the retained files in the same order. + */ +export function dedupInstructionFilesByDirectory(files: LoadedInstructionFile[]): LoadedInstructionFile[] { + const keptDigestsByDir = new Map>() + const kept: LoadedInstructionFile[] = [] + for (const file of files) { + const dir = dirname(file.displayPath) + let digests = keptDigestsByDir.get(dir) + if (digests === undefined) { + digests = new Set() + keptDigestsByDir.set(dir, digests) + } + const digest = trimmedInstructionDigest(file.content) + if (digests.has(digest)) continue + digests.add(digest) + kept.push(file) + } + return kept +} + /** * Discover, read, and render the baseline instruction chain. * @param options - discovery, source-size, byte-budget, and cancellation configuration. @@ -393,23 +407,23 @@ export async function loadBaselineInstructionSet( absolutePath: file.absolutePath, displayPath: file.displayPath, content, - tier: file.tier, ...file.version === undefined ? {} : { version: file.version }, }) } } - if (loaded.length === 0) return undefined - const rendered = renderWorkspaceContext(loaded, { maxBytes: config.maxBytes }) + const deduped = dedupInstructionFilesByDirectory(loaded) + if (deduped.length === 0) return undefined + const rendered = renderWorkspaceContext(deduped, { maxBytes: config.maxBytes }) const omitted = new Set(rendered.omitted.map(file => file.absolutePath)) - return { rendered, included: loaded.filter(file => !omitted.has(file.absolutePath)) } + return { rendered, included: deduped.filter(file => !omitted.has(file.absolutePath)) } } /** - * Probe the current first-winning instruction candidate for one logical scope. - * @param scope - `user-global`, or a {@link scopeKey} for a project directory's base or local tier. + * Probe the current provider metadata for one per-candidate instruction scope. + * @param scope - a {@link candidateScopeKey} identifying a directory and candidate file. * @param projectRoot - project root used to resolve and display project scopes. * @param resolved - normalized plugin configuration. - * @param fileSystem - provider used for no-follow probing. + * @param fileSystem - provider used to resolve and stat scope candidates. * @param signal - cancellation for provider probes. * @returns present metadata, confirmed absence, or temporary unavailability. */ @@ -420,43 +434,32 @@ export async function probeScopeInstruction( fileSystem: FileSystem, signal?: AbortSignal, ): Promise { - const { directory, tier } = decodeScopeKey(scope) - const dir = directory === 'user-global' + const { directory, candidateName } = decodeScopeKey(scope) + const dir = directory === USER_GLOBAL_DIRECTORY ? resolved.dshHome : directory === '.' ? projectRoot : join(projectRoot, directory) - const candidates = directory === 'user-global' - ? ['AGENTS.md'] - : tier === 'local' ? resolved.localInstructionFileCandidates : resolved.instructionFileCandidates - for (const candidate of candidates) { - const absolutePath = join(dir, candidate) - let pathInfo: FsPathInfo | undefined - try { - pathInfo = await fileSystem.lstat(absolutePath, undefined, signal) - } catch { - signal?.throwIfAborted() - return { kind: 'unavailable' } - } - if (pathInfo === undefined || pathInfo.type !== 'file') continue - let target: FsTarget - let info: FsInfo | undefined - try { - target = await fileSystem.resolve(absolutePath, signalOptions(signal)) - info = await fileSystem.stat(target, signal) - } catch { - signal?.throwIfAborted() - return { kind: 'unavailable' } - } - if (info?.type !== 'file') return { kind: 'unavailable' } - const file: ProbedInstructionFile = { - absolutePath, - displayPath: directory === 'user-global' ? userGlobalDisplayPath(resolved.dshHome) : relativeDisplay(projectRoot, absolutePath), - target, - version: info.version, - ...info.size === undefined ? {} : { size: info.size }, - } - return { kind: 'present', file } + const absolutePath = join(dir, candidateName) + // resolve() follows a final-component symlink; stat then classifies the target. + // A non-file target (missing, or a link to a directory) is a confirmed absence; + // only a provider exception is reported as unavailable. + let target: FsTarget + let info: FsInfo | undefined + try { + target = await fileSystem.resolve(absolutePath, signalOptions(signal)) + info = await fileSystem.stat(target, signal) + } catch { + signal?.throwIfAborted() + return { kind: 'unavailable' } } - return { kind: 'absent' } + if (info?.type !== 'file') return { kind: 'absent' } + const file: ProbedInstructionFile = { + absolutePath, + displayPath: directory === USER_GLOBAL_DIRECTORY ? userGlobalDisplayPath(resolved.dshHome) : relativeDisplay(projectRoot, absolutePath), + target, + version: info.version, + ...info.size === undefined ? {} : { size: info.size }, + } + return { kind: 'present', file } } /** diff --git a/packages/context/workspace-context/src/render.ts b/packages/context/workspace-context/src/render.ts index 7f8fd2f647..baca6bd84b 100644 --- a/packages/context/workspace-context/src/render.ts +++ b/packages/context/workspace-context/src/render.ts @@ -4,7 +4,7 @@ * @module @deepseek-ai/dsh-workspace-context/render */ -import { dirname } from 'node:path' +import { basename, dirname } from 'node:path' import type { InstructionFile, LoadedInstructionFile } from './files.ts' const SYSTEM_REMINDER_OPEN = '' @@ -33,7 +33,6 @@ export interface WorkspaceInstructionChange { action: 'set' | 'replace' | 'remove' scope: string path: string - previousPath?: string digest?: string } @@ -62,8 +61,8 @@ function truncateUtf8(value: string, maxBytes: number): string { function escapeInstructionContent(content: string): string { // TODO(instruction-frame-paths): apply the same delimiter neutralization to - // every interpolated path, scope, and previous path; repository-controlled - // names can otherwise close the plugin-owned system-reminder frame. + // every interpolated path and scope; repository-controlled names can + // otherwise close the plugin-owned system-reminder frame. return content.replaceAll(SYSTEM_REMINDER_CLOSE, '<\\/system-reminder>') } @@ -71,43 +70,63 @@ function sectionText(file: LoadedInstructionFile): string { return `Instructions from: ${file.displayPath}\n\n${escapeInstructionContent(file.content)}` } +/** Directory component that identifies the single user-global instruction scope. */ +export const USER_GLOBAL_DIRECTORY = 'user-global' + +/** + * File name of the single user-global instruction file under `$DSH_HOME`. + * Discovery (`$DSH_HOME/`) and reconciliation (the user-global scope key's + * candidate component) both key on this name, so it lives in one place: were the + * two to disagree, the user-global instruction would load but never reconcile. + */ +export const USER_GLOBAL_FILE = 'AGENTS.md' + /** * Derive the logical instruction scope from a model-facing path. * @param displayPath - project-relative or user-global instruction path. * @returns `user-global`, `.`, or the containing project-relative directory. */ export function scopeForDisplayPath(displayPath: string): string { - if (displayPath === '~/.dsh/AGENTS.md' || displayPath === '$DSH_HOME/AGENTS.md') return 'user-global' + if (displayPath === '~/.dsh/AGENTS.md' || displayPath === '$DSH_HOME/AGENTS.md') return USER_GLOBAL_DIRECTORY return dirname(displayPath) } -/** Instruction tier: the native base file or the additive local overlay. */ -export type InstructionTier = 'base' | 'local' - -const LOCAL_SCOPE_SUFFIX = '\u0000local' +const SCOPE_SEPARATOR = '\u0000' /** - * Compose the reconciliation key for a directory scope and instruction tier. - * The base tier keeps the human-readable directory; the local overlay appends a - * NUL-delimited marker that no directory path can contain, so a directory's base - * and local files never collide in the scope-keyed state maps. + * Compose the reconciliation key for one instruction candidate file. + * Each loaded candidate is tracked independently, so the key pairs the logical + * directory with the exact candidate file name behind a NUL separator that no + * directory path or file name can contain. Distinct candidates in one directory + * (`AGENTS.md` vs `CLAUDE.md`, a base file vs its `.local` overlay) therefore + * never collide in the scope-keyed state maps. * @param directory - `user-global`, `.`, or a project-relative directory. - * @param tier - base file or additive local overlay. - * @returns the collision-free logical scope key. + * @param candidateName - instruction file name within that directory. + * @returns the per-candidate logical scope key. */ -export function scopeKey(directory: string, tier: InstructionTier): string { - return tier === 'local' ? `${directory}${LOCAL_SCOPE_SUFFIX}` : directory +export function candidateScopeKey(directory: string, candidateName: string): string { + return `${directory}${SCOPE_SEPARATOR}${candidateName}` } /** - * Recover the directory and tier that {@link scopeKey} encoded. - * @param scope - a base or local scope key. - * @returns the directory scope and its instruction tier. + * Derive the per-candidate scope key for a loaded instruction file. + * @param displayPath - project-relative or user-global instruction path. + * @returns the scope key pairing the file's directory with its name. */ -export function decodeScopeKey(scope: string): { directory: string; tier: InstructionTier } { - return scope.endsWith(LOCAL_SCOPE_SUFFIX) - ? { directory: scope.slice(0, -LOCAL_SCOPE_SUFFIX.length), tier: 'local' } - : { directory: scope, tier: 'base' } +export function instructionScopeKey(displayPath: string): string { + return candidateScopeKey(scopeForDisplayPath(displayPath), basename(displayPath)) +} + +/** + * Recover the directory and candidate name that {@link candidateScopeKey} encoded. + * @param scope - a per-candidate scope key. + * @returns the directory scope and the candidate file name within it. + */ +export function decodeScopeKey(scope: string): { directory: string; candidateName: string } { + const separator = scope.indexOf(SCOPE_SEPARATOR) + /* v8 ignore next -- every scope key is produced by candidateScopeKey, which always inserts the separator. */ + if (separator < 0) return { directory: scope, candidateName: '' } + return { directory: scope.slice(0, separator), candidateName: scope.slice(separator + 1) } } function additionalSectionText(file: LoadedInstructionFile): string { @@ -129,13 +148,10 @@ function changedSectionText(item: ChangeRenderItem): string { if (change.action === 'remove') { return `Instructions removed: ${change.path}\n\nThe previously loaded instructions from this file no longer apply.` } - const description = change.previousPath === undefined - ? 'This file changed after it was loaded. Use the following content instead of the previously loaded instructions from this file.' - : `The instructions previously loaded from \`${change.previousPath}\` no longer apply. Use the following content for \`${scopeForDisplayPath(change.path)}\` instead.` return [ `Updated instructions from: ${change.path}`, '', - description, + 'This file changed after it was loaded. Use the following content instead of the previously loaded instructions from this file.', '', escapeInstructionContent(file.content), ].join('\n') diff --git a/packages/context/workspace-context/src/state.ts b/packages/context/workspace-context/src/state.ts index 885942a8c5..b2f1fa88ef 100644 --- a/packages/context/workspace-context/src/state.ts +++ b/packages/context/workspace-context/src/state.ts @@ -10,7 +10,7 @@ import type { JsonValue, Session, SessionEvent } from '@deepseek-ai/dsh-session' import type { FileSystem, FsVersion } from '@deepseek-ai/dsh-fs' import type { ToolExecution, ToolExecutionResult } from '@deepseek-ai/dsh-tools' import type { ResolvedConfig } from './config.ts' -import { instructionContentSha1 } from './digest.ts' +import { instructionContentSha1, trimmedInstructionDigest } from './digest.ts' import { ancestorChain, descendantDirsBetween, @@ -21,9 +21,12 @@ import { type LoadedInstructionFile, } from './files.ts' import { + candidateScopeKey, + decodeScopeKey, + instructionScopeKey, renderInstructionChanges, - scopeForDisplayPath, - scopeKey, + USER_GLOBAL_DIRECTORY, + USER_GLOBAL_FILE, type ChangeRenderItem, type WorkspaceInstructionChange, } from './render.ts' @@ -45,6 +48,11 @@ export interface InstructionVersionState { path: string version: FsVersion digest: string + /** + * Trimmed-content identity ({@link trimmedInstructionDigest}) used to suppress + * per-directory duplicates on the metadata fast path without re-reading a sibling. + */ + trimmedDigest: string } /** Session-isolated fast-path state keyed by logical instruction scope. */ @@ -72,7 +80,6 @@ function workspaceContextHook(text: string, changes: WorkspaceInstructionChange[ action: change.action, scope: change.scope, path: change.path, - ...change.previousPath !== undefined ? { previousPath: change.previousPath } : {}, ...change.digest !== undefined ? { digest: change.digest } : {}, })) const meta: JsonValue = { kind: 'workspace-instructions', version: 1, changes: serializedChanges } @@ -113,13 +120,11 @@ function workspaceInstructionChanges(meta: JsonValue | undefined): WorkspaceInst if (!isRecord(value)) continue if (value.action !== 'set' && value.action !== 'replace' && value.action !== 'remove') continue if (typeof value.scope !== 'string' || typeof value.path !== 'string') continue - if (value.previousPath !== undefined && typeof value.previousPath !== 'string') continue if (value.digest !== undefined && typeof value.digest !== 'string') continue changes.push({ action: value.action, scope: value.scope, path: value.path, - ...value.previousPath !== undefined ? { previousPath: value.previousPath } : {}, ...value.digest !== undefined ? { digest: value.digest } : {}, }) } @@ -130,7 +135,6 @@ function sameInstructionChange(a: WorkspaceInstructionChange, b: WorkspaceInstru return a.action === b.action && a.scope === b.scope && a.path === b.path - && a.previousPath === b.previousPath && a.digest === b.digest } @@ -170,13 +174,18 @@ export function baselineInstructionState(files: LoadedInstructionFile[]): { const digest = instructionContentSha1(file.content) const change: WorkspaceInstructionChange = { action: 'set', - scope: scopeKey(scopeForDisplayPath(file.displayPath), file.tier ?? 'base'), + scope: instructionScopeKey(file.displayPath), path: file.displayPath, digest, } changes.set(change.scope, change) if (file.version !== undefined) { - versions.set(change.scope, { path: file.displayPath, version: file.version, digest }) + versions.set(change.scope, { + path: file.displayPath, + version: file.version, + digest, + trimmedDigest: trimmedInstructionDigest(file.content), + }) } } return { changes, versions } @@ -392,40 +401,57 @@ export async function reconcileInstructionContext( // recomputing it after marker edits reinterprets the existing relative scope keys. const projectRoot = await findProjectRoot(cwd, resolved.projectRootMarkers, fileSystem, options.signal) const scopes = new Set() - const localEnabled = resolved.localInstructionFileCandidates.length > 0 + const addDirScopes = (directory: string): void => { + for (const candidate of resolved.instructionFileCandidates) scopes.add(candidateScopeKey(directory, candidate)) + for (const candidate of resolved.localInstructionFileCandidates) scopes.add(candidateScopeKey(directory, candidate)) + } const addProjectScopes = (dir: string): void => { - const scope = relativeScope(projectRoot, dir) - scopes.add(scope) - if (localEnabled) scopes.add(scopeKey(scope, 'local')) + addDirScopes(relativeScope(projectRoot, dir)) } if (options.includeBaselineScopes) { - scopes.add('user-global') + scopes.add(candidateScopeKey(USER_GLOBAL_DIRECTORY, USER_GLOBAL_FILE)) for (const dir of ancestorChain(projectRoot, cwd)) addProjectScopes(dir) } - for (const scope of effective.keys()) scopes.add(scope) + for (const scope of effective.keys()) { + const { directory } = decodeScopeKey(scope) + if (directory === USER_GLOBAL_DIRECTORY) scopes.add(candidateScopeKey(USER_GLOBAL_DIRECTORY, USER_GLOBAL_FILE)) + else addDirScopes(directory) + } if (options.touchedPath !== undefined) { for (const dir of descendantDirsBetween(cwd, options.touchedPath)) addProjectScopes(dir) } const versions = versionStatesFor(session, versionCache) const seenAbsolutePaths = new Set() + // Per-directory trimmed-content identities kept so far this pass, iterated in + // candidate order (base before local); a later sibling matching an earlier one + // is a duplicate and is dropped or removed rather than rendered twice. + const keptTrimmedByDir = new Map>() + const registerKeptTrimmed = (directory: string, digest: string): boolean => { + let digests = keptTrimmedByDir.get(directory) + if (digests === undefined) { + digests = new Set() + keptTrimmedByDir.set(directory, digests) + } + if (digests.has(digest)) return true + digests.add(digest) + return false + } const items: ChangeRenderItem[] = [] const versionUpdates: InstructionVersionUpdate[] = [] + const pushRemoval = (scope: string, path: string): void => { + const change: WorkspaceInstructionChange = { action: 'remove', scope, path } + items.push({ change, file: { absolutePath: `removed:${scope}`, displayPath: path, content: '' } }) + versionUpdates.push({ change }) + } for (const scope of scopes) { + const { directory } = decodeScopeKey(scope) const previous = effective.get(scope) const probe = await probeScopeInstruction(scope, projectRoot, resolved, fileSystem, options.signal) if (probe.kind === 'unavailable') continue if (probe.kind === 'absent') { - if (previous === undefined || previous.action === 'remove') { - versions.delete(scope) - continue - } - const change: WorkspaceInstructionChange = { action: 'remove', scope, path: previous.path } - items.push({ - change, - file: { absolutePath: `removed:${scope}`, displayPath: previous.path, content: '' }, - }) - versionUpdates.push({ change }) + if (previous === undefined || previous.action === 'remove') versions.delete(scope) + else pushRemoval(scope, previous.path) continue } const { file: probedFile } = probe @@ -440,29 +466,39 @@ export async function reconcileInstructionContext( && previous.action !== 'remove' && previous.path === cached.path && previous.digest === cached.digest - ) continue + ) { + // Unchanged and previously rendered: keep it, but an earlier sibling that + // now matches its trimmed content makes this the duplicate to remove. + if (registerKeptTrimmed(directory, cached.trimmedDigest)) pushRemoval(scope, previous.path) + continue + } const file = await readScopeInstruction(probedFile, resolved.maxSourceBytes, fileSystem, options.signal) if (file === undefined) continue const currentDigest = instructionContentSha1(file.content) + const trimmedDigest = trimmedInstructionDigest(file.content) + if (registerKeptTrimmed(directory, trimmedDigest)) { + // A distinct file whose trimmed content already appeared earlier in this + // directory: drop it, removing any copy that was previously rendered. + if (previous !== undefined && previous.action !== 'remove') pushRemoval(scope, previous.path) + else versions.delete(scope) + continue + } const nextVersion: InstructionVersionState = { path: file.displayPath, version: probedFile.version, digest: currentDigest, + trimmedDigest, } if (previous !== undefined && previous.action !== 'remove' && previous.path === file.displayPath && previous.digest === currentDigest) { versions.set(scope, nextVersion) continue } const action = previous === undefined || previous.action === 'remove' ? 'set' : 'replace' - const previousPath = action === 'replace' && previous !== undefined && previous.path !== file.displayPath - ? previous.path - : undefined const change: WorkspaceInstructionChange = { action, scope, path: file.displayPath, - ...previousPath === undefined ? {} : { previousPath }, digest: currentDigest, } items.push({ change, file }) diff --git a/packages/context/workspace-context/tests/workspace-context.spec.ts b/packages/context/workspace-context/tests/workspace-context.spec.ts index c3d1aa05f3..1d329d2171 100644 --- a/packages/context/workspace-context/tests/workspace-context.spec.ts +++ b/packages/context/workspace-context/tests/workspace-context.spec.ts @@ -1,5 +1,5 @@ -import { mkdtemp, mkdir, rm, stat, symlink, utimes, writeFile } from 'node:fs/promises' -import { dirname, join, resolve } from 'node:path' +import { chmod, mkdtemp, mkdir, rm, stat, symlink, utimes, writeFile } from 'node:fs/promises' +import { dirname, join } from 'node:path' import { tmpdir } from 'node:os' import { describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' @@ -7,9 +7,8 @@ import Loader from '@cordisjs/plugin-loader' import * as workspaceContext from '@deepseek-ai/dsh-workspace-context' import LlmService, { CallId, type Message, type StreamChunk } from '@deepseek-ai/dsh-llm' import SessionStore, { Session, SessionId, SESSION_FORMAT_VERSION, type SessionEvent } from '@deepseek-ai/dsh-session' -import AgentRegistry, { agentEvents, type Agent, type HookContext } from '@deepseek-ai/dsh-agent' +import AgentRegistry, { type Agent, type HookContext } from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' -import { scopeTarget } from '@deepseek-ai/dsh-scope' import { FileSystem, FsTargetKey, FsVersion } from '@deepseek-ai/dsh-fs' import type { FsDirEntry, @@ -24,12 +23,7 @@ import type { import LocalFileSystem from '@deepseek-ai/dsh-fs-local' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools' -import type { - PostToolDecision, - ToolExecution, - ToolExecutionResult, - ToolExecutionToken, -} from '@deepseek-ai/dsh-tools' +import type { ToolExecution, ToolExecutionToken } from '@deepseek-ai/dsh-tools' import * as ToolFs from '@deepseek-ai/dsh-tool-fs' import { discoverBaselineInstructionFiles, @@ -44,8 +38,12 @@ import { type InstructionVersionCache, type PendingInstructionChange, } from '../src/state.ts' +import { candidateScopeKey } from '../src/render.ts' import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' +/** Per-candidate reconciliation scope key: directory paired with the file name. */ +const sk = (directory: string, candidateName: string): string => candidateScopeKey(directory, candidateName) + const testToolSignal = new AbortController().signal async function tempRepo(): Promise { @@ -59,9 +57,7 @@ async function write(path: string, content: string): Promise { class RecordingFileSystem extends FileSystem { entries = new Map() - lstatTypes = new Map() throwOnStat = new Set() - throwOnRead = new Set() omitSizes = new Set() readTargets: string[] = [] readTextTargets: string[] = [] @@ -70,7 +66,7 @@ class RecordingFileSystem extends FileSystem { override async resolve(path: string, opts?: { cwd?: string; signal?: AbortSignal }): Promise { if (opts?.signal !== undefined) this.signals.push(opts.signal) opts?.signal?.throwIfAborted() - const absolute = resolve(opts?.cwd ?? '/', path) + const absolute = join(opts?.cwd ?? '/', path) return { targetKey: FsTargetKey(absolute), displayPath: absolute } } @@ -92,8 +88,6 @@ class RecordingFileSystem extends FileSystem { if (signal !== undefined) this.signals.push(signal) signal?.throwIfAborted() const target = await this.resolve(path, { ...opts, ...signal === undefined ? {} : { signal } }) - const lstatType = this.lstatTypes.get(target.targetKey) - if (lstatType !== undefined) return { version: FsVersion(`lstat:${target.targetKey}`), type: lstatType } const info = await this.stat(target, signal) if (info === undefined) return undefined return { @@ -114,7 +108,6 @@ class RecordingFileSystem extends FileSystem { if (signal !== undefined) this.signals.push(signal) signal?.throwIfAborted() this.readTargets.push(target.targetKey) - if (this.throwOnRead.has(target.targetKey)) throw new Error(`read failed: ${target.displayPath}`) const content = this.entries.get(target.targetKey)?.content ?? '' return (async function* () { const midpoint = Math.ceil(content.length / 2) @@ -235,31 +228,14 @@ const composedPrefixes = new WeakMap() async function composeBaselinePrefix(ctx: Context, agent: Agent): Promise { const empty: Message[] = [] - const prefix = await agentEvents(ctx, agent).waterfall( - 'agent/session-prefix', empty, AbortSignal.timeout(1000), + const prefix = await ctx.waterfall( + 'agent/session-prefix', agent, empty, AbortSignal.timeout(1000), () => Promise.resolve(empty), ) composedPrefixes.set(agent, prefix) return prefix } -function toolEventCarrier(ctx: Context, exec: ToolExecution) { - return scopeTarget(ctx.get('tools') ?? ctx as unknown as ToolRegistry, exec.agent) -} - -function postExecute( - ctx: Context, - exec: ToolExecution, - result: Readonly, - next: () => Promise, -): Promise { - return ctx.waterfall(toolEventCarrier(ctx, exec), 'tools/post-execute', exec, result, next) -} - -function emitToolResult(ctx: Context, exec: ToolExecution, result: Readonly): void { - ctx.emit(toolEventCarrier(ctx, exec), 'tools/result', exec, result) -} - function derivedText(agent: Agent): string { return blocksText(composedPrefixes.get(agent)?.[0]?.content) } @@ -284,7 +260,7 @@ describe('workspace context instruction discovery', () => { } }) - it('loads user-global first, then root-to-cwd workspace instructions using the default candidate order', async () => { + it('loads user-global first, then every root-to-cwd candidate in precedence order', async () => { const root = await tempRepo() const home = await tempRepo() try { @@ -292,7 +268,7 @@ describe('workspace context instruction discovery', () => { await mkdir(join(root, '.git'), { recursive: true }) await write(join(home, 'AGENTS.md'), 'global rules') await write(join(root, 'AGENTS.md'), 'root agents') - await write(join(root, 'CLAUDE.md'), 'root claude ignored') + await write(join(root, 'CLAUDE.md'), 'root claude') await write(join(root, 'packages/CLAUDE.md'), 'package claude') await write(join(cwd, 'AGENTS.md'), 'app agents') @@ -301,10 +277,11 @@ describe('workspace context instruction discovery', () => { expect(files.map(file => file.displayPath)).toEqual([ '$DSH_HOME/AGENTS.md', 'AGENTS.md', - join('packages', 'CLAUDE.md'), - join('packages', 'app', 'AGENTS.md'), + 'CLAUDE.md', + 'packages/CLAUDE.md', + 'packages/app/AGENTS.md', ]) - expect(files.map(file => file.absolutePath)).not.toContain(join(root, 'CLAUDE.md')) + expect(files.map(file => file.absolutePath)).toContain(join(root, 'CLAUDE.md')) } finally { await rm(root, { recursive: true, force: true }) await rm(home, { recursive: true, force: true }) @@ -406,44 +383,41 @@ describe('workspace context instruction discovery', () => { } }) - it('skips a provider file whose read fails after a successful metadata probe', async () => { + it('skips a file that becomes unreadable after discovery without failing the request', async () => { const root = await tempRepo() const home = await tempRepo() - const ctx = new Context() try { const cwd = join(root, 'pkg') + await mkdir(join(root, '.git'), { recursive: true }) + await mkdir(cwd, { recursive: true }) const leaf = join(cwd, 'AGENTS.md') - await ctx.plugin(RecordingFileSystem) - const fs = ctx.fs as RecordingFileSystem - fs.entries.set(join(root, '.git'), { type: 'directory' }) - fs.entries.set(leaf, { type: 'file', content: 'secret-ish rule' }) - fs.throwOnRead.add(leaf) + await write(leaf, 'secret-ish rule') + await chmod(leaf, 0) - const loaded = await loadBaselineInstructions({ cwd, dshHome: home, maxBytes: 65536 }, fs) + const loaded = await loadBaselineInstructions({ cwd, dshHome: home, maxBytes: 65536 }) expect(loaded).toBeUndefined() - expect(fs.readTargets).toEqual([leaf]) + await chmod(leaf, 0o600) } finally { - await ctx.fiber.dispose() await rm(root, { recursive: true, force: true }) await rm(home, { recursive: true, force: true }) } }) - it('rejects symlinked instruction files instead of following repository-controlled links', async () => { + it('follows a symlinked instruction file to its target content', async () => { const root = await tempRepo() const home = await tempRepo() const outside = await tempRepo() try { await mkdir(join(root, '.git'), { recursive: true }) - await write(join(outside, 'secret.txt'), 'outside secret') - await symlink(join(outside, 'secret.txt'), join(root, 'AGENTS.md')) + await write(join(outside, 'shared.md'), 'shared instruction body') + await symlink(join(outside, 'shared.md'), join(root, 'AGENTS.md')) const files = await discoverBaselineInstructionFiles({ cwd: root, dshHome: home }) const loaded = await loadBaselineInstructions({ cwd: root, dshHome: home, maxBytes: 65536 }) - expect(files).toEqual([]) - expect(loaded).toBeUndefined() + expect(files.map(file => file.displayPath)).toContain('AGENTS.md') + expect(loaded?.text).toContain('shared instruction body') } finally { await rm(root, { recursive: true, force: true }) await rm(home, { recursive: true, force: true }) @@ -451,21 +425,21 @@ describe('workspace context instruction discovery', () => { } }) - it('rejects symlinked instruction files through ctx.fs instead of following repository-controlled links', async () => { + it('follows a symlinked instruction file through ctx.fs to its target content', async () => { const root = await tempRepo() const home = await tempRepo() const outside = await tempRepo() try { await mkdir(join(root, '.git'), { recursive: true }) - await write(join(outside, 'secret.txt'), 'outside secret') - await symlink(join(outside, 'secret.txt'), join(root, 'AGENTS.md')) + await write(join(outside, 'shared.md'), 'shared provider instruction body') + await symlink(join(outside, 'shared.md'), join(root, 'AGENTS.md')) const ctx = new Context() await mountWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) const agent = stubAgent(root) await composeBaselinePrefix(ctx, agent) - expectNoDerivedMessages(agent) + expect(derivedText(agent)).toContain('shared provider instruction body') } finally { await rm(root, { recursive: true, force: true }) await rm(home, { recursive: true, force: true }) @@ -511,7 +485,7 @@ describe('workspace context instruction discovery', () => { } }) - it('uses the configured instruction candidate order without hard-coding AGENTS.md priority', async () => { + it('loads every configured instruction candidate in configured order without hard-coding AGENTS.md priority', async () => { const root = await tempRepo() const home = await tempRepo() try { @@ -526,7 +500,7 @@ describe('workspace context instruction discovery', () => { instructionFileCandidates: ['CLAUDE.local.md', 'AGENTS.md', 'CLAUDE.md'], }) - expect(files.map(file => file.displayPath)).toEqual(['CLAUDE.local.md']) + expect(files.map(file => file.displayPath)).toEqual(['CLAUDE.local.md', 'AGENTS.md', 'CLAUDE.md']) } finally { await rm(root, { recursive: true, force: true }) await rm(home, { recursive: true, force: true }) @@ -556,6 +530,12 @@ describe('workspace context instruction discovery', () => { it('defaults dshHome and uses cwd itself as root when no project marker exists', async () => { const root = await tempRepo() + const emptyHome = await tempRepo() + // Isolate the default-home fallback: blank DSH_HOME is treated as unset, and + // HOME points at an empty dir so the default ~/.dsh holds no global scope. + // Symlinks are now followed, so a real ~/.dsh/AGENTS.md would otherwise leak in. + vi.stubEnv('DSH_HOME', '') + vi.stubEnv('HOME', emptyHome) try { const cwd = join(root, 'child') await mkdir(cwd, { recursive: true }) @@ -567,7 +547,9 @@ describe('workspace context instruction discovery', () => { expect(files.map(file => file.displayPath)).toEqual(['AGENTS.md']) expect(files.map(file => file.absolutePath)).toEqual([join(cwd, 'AGENTS.md')]) } finally { + vi.unstubAllEnvs() await rm(root, { recursive: true, force: true }) + await rm(emptyHome, { recursive: true, force: true }) } }) @@ -596,7 +578,6 @@ describe('workspace context instruction discovery', () => { vi.resetModules() vi.doMock('node:os', () => ({ homedir: () => home })) - vi.stubEnv('DSH_HOME', undefined) const isolated = await import('@deepseek-ai/dsh-workspace-context') const files = await isolated.discoverBaselineInstructionFiles({ cwd: root }) @@ -604,7 +585,6 @@ describe('workspace context instruction discovery', () => { } finally { vi.doUnmock('node:os') vi.resetModules() - vi.unstubAllEnvs() await rm(root, { recursive: true, force: true }) await rm(home, { recursive: true, force: true }) } @@ -871,7 +851,7 @@ describe('workspace context request injection', () => { try { await ctx.plugin(workspaceContext, { maxBytes: 65536 }) - const decision = await postExecute(ctx, stubToolExecution({ + const decision = await ctx.waterfall('tools/post-execute', stubToolExecution({ signal: testToolSignal, callId: CallId('no-fs-post-execute'), name: 'read', @@ -920,7 +900,7 @@ describe('workspace context request injection', () => { } // A later PostToolUse-style policy blocks this otherwise-successful read. - const blocked = await postExecute(ctx, exec, result, async () => ({ + const blocked = await ctx.waterfall('tools/post-execute', exec, result, async () => ({ kind: 'block' as const, feedback: [{ type: 'text' as const, text: 'blocked by policy' }], })) @@ -934,7 +914,7 @@ describe('workspace context request injection', () => { // The same read, when the downstream accepts, DOES surface the nested // instructions — proving the block branch above is what suppressed them, // and that the block did not consume the pending nested change. - const accepted = await postExecute(ctx, exec, result, async () => ({ + const accepted = await ctx.waterfall('tools/post-execute', exec, result, async () => ({ kind: 'accept' as const, })) expect(accepted.kind).toBe('accept') @@ -1009,7 +989,7 @@ describe('workspace context request injection', () => { await composeBaselinePrefix(ctx, agent) expect(derivedText(agent)).toContain('omitted AGENTS.md') - expect(derivedText(agent)).toContain(`Instructions from: ${join('pkg', 'AGENTS.md')}\n\npackage rule`) + expect(derivedText(agent)).toContain('Instructions from: pkg/AGENTS.md\n\npackage rule') } finally { await rm(root, { recursive: true, force: true }) await rm(home, { recursive: true, force: true }) @@ -1059,7 +1039,7 @@ describe('workspace context request injection', () => { }) expect(workspaceContextOf(result)?.meta).toMatchObject({ - changes: [{ action: 'replace', scope: '.', path: 'AGENTS.md' }], + changes: [{ action: 'replace', scope: sk('.', 'AGENTS.md'), path: 'AGENTS.md' }], }) expect(blocksText(workspaceContextOf(result)?.content)).toContain('Updated instructions from: AGENTS.md') expect(blocksText(workspaceContextOf(result)?.content)).toContain('new root rule with more detail') @@ -1088,7 +1068,7 @@ describe('workspace context request injection', () => { }) expect(workspaceContextOf(result)?.meta).toMatchObject({ - changes: [{ action: 'remove', scope: '.', path: 'AGENTS.md' }], + changes: [{ action: 'remove', scope: sk('.', 'AGENTS.md'), path: 'AGENTS.md' }], }) expect(blocksText(workspaceContextOf(result)?.content)).toContain('Instructions removed: AGENTS.md') } finally { @@ -1120,6 +1100,32 @@ describe('workspace context request injection', () => { } }) + it('deduplicates trimmed-identical sibling candidates in one directory and renders the earliest original bytes', async () => { + const root = await tempRepo() + const home = await tempRepo() + try { + await mkdir(join(root, '.git'), { recursive: true }) + await write(join(root, 'AGENTS.md'), 'shared repo rule') + await write(join(root, 'CLAUDE.md'), ' shared repo rule\n\n') + await write(join(root, 'file.txt'), 'hello') + const ctx = new Context() + await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) + const agent = stubAgent(root) + + await composeBaselinePrefix(ctx, agent) + + const text = derivedText(agent) + expect(text.match(/shared repo rule/g)).toHaveLength(1) + expect(text).toContain('Instructions from: AGENTS.md') + expect(text).not.toContain('Instructions from: CLAUDE.md') + // The kept candidate's original bytes are rendered, not the whitespace-padded duplicate. + expect(text).not.toContain(' shared repo rule') + } finally { + await rm(root, { recursive: true, force: true }) + await rm(home, { recursive: true, force: true }) + } + }) + it('does not expose state markers when a tiny budget reduces the baseline contribution', async () => { const root = await tempRepo() const home = await tempRepo() @@ -1268,9 +1274,8 @@ describe('workspace context request injection', () => { const controller = new AbortController() const reason = new Error('cancel prefix') const empty: Message[] = [] - const agent = stubAgent(root) - const pending = agentEvents(ctx, agent).waterfall( - 'agent/session-prefix', empty, controller.signal, + const pending = ctx.waterfall( + 'agent/session-prefix', stubAgent(root), empty, controller.signal, () => Promise.resolve(empty), ) @@ -1337,30 +1342,6 @@ describe('workspace context request injection', () => { } }) - it('skips provider-visible instruction candidates when ctx.fs stat disagrees after no-follow preflight', async () => { - const root = await tempRepo() - const home = await tempRepo() - try { - await mkdir(join(root, '.git'), { recursive: true }) - await write(join(root, 'AGENTS.md'), 'node fs rule') - const ctx = new Context() - await ctx.plugin(RecordingFileSystem) - const fs = ctx.fs as RecordingFileSystem - fs.entries.set(join(root, '.git'), { type: 'directory' }) - fs.entries.set(join(root, 'AGENTS.md'), { type: 'directory' }) - fs.lstatTypes.set(join(root, 'AGENTS.md'), 'file') - await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 }) - const agent = stubAgent(root) - - await composeBaselinePrefix(ctx, agent) - - expectNoDerivedMessages(agent) - } finally { - await rm(root, { recursive: true, force: true }) - await rm(home, { recursive: true, force: true }) - } - }) - it('loads instruction files when ctx.fs omits the metadata size', async () => { const root = await tempRepo() const home = await tempRepo() @@ -1407,7 +1388,7 @@ describe('workspace context request injection', () => { } }) - it('does not fall through to a lower-priority candidate when the winning provider file becomes unavailable', async () => { + it('skips a candidate whose provider probe fails while still loading its available sibling', async () => { const root = await tempRepo() const home = await tempRepo() try { @@ -1415,16 +1396,16 @@ describe('workspace context request injection', () => { await ctx.plugin(RecordingFileSystem) const fs = ctx.fs as RecordingFileSystem fs.entries.set(join(root, '.git'), { type: 'directory' }) - fs.lstatTypes.set(join(root, 'AGENTS.md'), 'file') fs.throwOnStat.add(join(root, 'AGENTS.md')) - fs.entries.set(join(root, 'CLAUDE.md'), { type: 'file', content: 'must not bypass AGENTS failure' }) + fs.entries.set(join(root, 'CLAUDE.md'), { type: 'file', content: 'claude sibling rule' }) await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 }) const agent = stubAgent(root) await composeBaselinePrefix(ctx, agent) - expectNoDerivedMessages(agent) - expect(fs.readTargets).not.toContain(join(root, 'CLAUDE.md')) + expect(derivedText(agent)).toContain('claude sibling rule') + expect(fs.readTargets).toContain(join(root, 'CLAUDE.md')) + expect(fs.readTargets).not.toContain(join(root, 'AGENTS.md')) } finally { await rm(root, { recursive: true, force: true }) await rm(home, { recursive: true, force: true }) @@ -1498,7 +1479,7 @@ describe('workspace context request injection', () => { await composeBaselinePrefix(ctx, agent) expect(derivedText(agent)).toContain('Instructions from: AGENTS.md\n\nroot schema default rule') - expect(derivedText(agent)).toContain(`Instructions from: ${join('child', 'AGENTS.md')}\n\nchild schema default rule`) + expect(derivedText(agent)).toContain('Instructions from: child/AGENTS.md\n\nchild schema default rule') await ctx.fiber.dispose() } finally { await rm(root, { recursive: true, force: true }) @@ -1629,9 +1610,9 @@ describe('workspace context request injection', () => { const actual = await importOriginal() return { ...actual, - lstat: async (path: string) => { + stat: async (path: string) => { observedStats.set(path, (observedStats.get(path) ?? 0) + 1) - return actual.lstat(path) + return actual.stat(path) }, } }) @@ -1649,22 +1630,22 @@ describe('workspace context request injection', () => { } }) - it('does not bypass an unavailable host AGENTS.md with a lower-priority candidate', async () => { + it('skips an unavailable host candidate but still loads its available sibling', async () => { const root = await tempRepo() const home = await tempRepo() try { await mkdir(join(root, '.git'), { recursive: true }) - await write(join(root, 'CLAUDE.md'), 'must not bypass unavailable AGENTS') + await write(join(root, 'CLAUDE.md'), 'claude host sibling rule') vi.resetModules() vi.doMock('node:fs/promises', async (importOriginal) => { const actual = await importOriginal() return { ...actual, - lstat: async (path: string) => { + stat: async (path: string) => { if (path === join(root, 'AGENTS.md')) { throw Object.assign(new Error('permission denied'), { code: 'EACCES' }) } - return actual.lstat(path) + return actual.stat(path) }, } }) @@ -1672,7 +1653,7 @@ describe('workspace context request injection', () => { const rendered = await isolated.loadBaselineInstructions({ cwd: root, dshHome: home, maxBytes: 65536 }) - expect(rendered).toBeUndefined() + expect(rendered?.text).toContain('claude host sibling rule') } finally { vi.doUnmock('node:fs/promises') vi.resetModules() @@ -1718,7 +1699,7 @@ describe('dynamic nested workspace context injection', () => { description: 'Abort the current test step.', parameters: {}, async execute() { - agent.cancel({ kind: 'user' }) + ;(agent as unknown as { currentAbort?: AbortController }).currentAbort?.abort('test abort') return [{ type: 'text', text: 'aborted' }] }, })) @@ -1751,7 +1732,7 @@ describe('dynamic nested workspace context injection', () => { content: 'root rule', }]) - const change = state.changes.get('.') + const change = state.changes.get(sk('.', 'AGENTS.md')) expect(change).toMatchObject({ action: 'set', path: 'AGENTS.md', @@ -1781,7 +1762,7 @@ describe('dynamic nested workspace context injection', () => { signal: controller.signal, }) - const pending = postExecute(ctx, exec, { + const pending = ctx.waterfall('tools/post-execute', exec, { content: [{ type: 'text', text: 'ok' }], isError: false, }, () => Promise.resolve({ kind: 'accept' as const })) @@ -1822,8 +1803,8 @@ describe('dynamic nested workspace context injection', () => { version: 1, changes: [{ action: 'set', - scope: 'pkg', - path: join('pkg', 'AGENTS.md'), + scope: sk('pkg', 'AGENTS.md'), + path: 'pkg/AGENTS.md', }], }) const meta = workspaceContextOf(result)?.meta @@ -1837,7 +1818,7 @@ describe('dynamic nested workspace context injection', () => { const text = blocksText(workspaceContextOf(result)?.content) expect(text).toBe([ '', - `Additional instructions from: ${join('pkg', 'AGENTS.md')}`, + 'Additional instructions from: pkg/AGENTS.md', '', 'These instructions apply to work under `pkg`. Use them as guidance when relevant; more specific instructions take precedence. They do not override system, developer, or direct user instructions.', '', @@ -1852,7 +1833,7 @@ describe('dynamic nested workspace context injection', () => { } }) - it('uses configured instruction candidates for nested discovery', async () => { + it('loads every configured instruction candidate present in a nested scope', async () => { const root = await tempRepo() const home = await tempRepo() try { @@ -1876,9 +1857,11 @@ describe('dynamic nested workspace context injection', () => { }) const text = blocksText(workspaceContextOf(result)?.content) - expect(text).toContain(`Additional instructions from: ${join('pkg', 'CLAUDE.local.md')}`) + expect(text).toContain('Additional instructions from: pkg/CLAUDE.local.md') expect(text).toContain('local package rule') - expect(text).not.toContain('native package rule') + expect(text).toContain('Additional instructions from: pkg/AGENTS.md') + expect(text).toContain('native package rule') + expect(text.indexOf('pkg/CLAUDE.local.md')).toBeLessThan(text.indexOf('pkg/AGENTS.md')) } finally { await rm(root, { recursive: true, force: true }) await rm(home, { recursive: true, force: true }) @@ -1898,6 +1881,7 @@ describe('dynamic nested workspace context injection', () => { await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) const result = await ctx.tools.execute({ + signal: testToolSignal, callId: CallId('read-nested-overlay'), name: 'read', arguments: { file_path: 'pkg/deep/file.txt' }, @@ -1939,6 +1923,7 @@ describe('dynamic nested workspace context injection', () => { }) const result = await ctx.tools.execute({ + signal: testToolSignal, callId: CallId('read-nested-overlay-disabled'), name: 'read', arguments: { file_path: 'pkg/deep/file.txt' }, @@ -2126,11 +2111,11 @@ describe('dynamic nested workspace context injection', () => { expect(workspaceContextOf(changed)?.meta).toMatchObject({ kind: 'workspace-instructions', - changes: [{ action: 'replace', scope: 'pkg', path: join('pkg', 'AGENTS.md') }], + changes: [{ action: 'replace', scope: sk('pkg', 'AGENTS.md'), path: 'pkg/AGENTS.md' }], }) expect(blocksText(workspaceContextOf(changed)?.content)).toBe([ '', - `Updated instructions from: ${join('pkg', 'AGENTS.md')}`, + 'Updated instructions from: pkg/AGENTS.md', '', 'This file changed after it was loaded. Use the following content instead of the previously loaded instructions from this file.', '', @@ -2143,13 +2128,13 @@ describe('dynamic nested workspace context injection', () => { } }) - it('replaces an AGENTS candidate with the configured fallback in the same scope', async () => { + it('reconciles distinct sibling candidates as independent scopes', async () => { const root = await tempRepo() const home = await tempRepo() try { await mkdir(join(root, '.git'), { recursive: true }) await write(join(root, 'pkg/AGENTS.md'), 'native package rule') - await write(join(root, 'pkg/CLAUDE.md'), 'fallback package rule') + await write(join(root, 'pkg/CLAUDE.md'), 'sibling package rule') await write(join(root, 'pkg/file.txt'), 'hello') const ctx = new Context() await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) @@ -2157,29 +2142,129 @@ describe('dynamic nested workspace context injection', () => { const first = await ctx.tools.execute({ signal: testToolSignal, - callId: CallId('read-before-fallback'), name: 'read', arguments: { file_path: 'pkg/file.txt' }, agent, + callId: CallId('read-both-siblings'), name: 'read', arguments: { file_path: 'pkg/file.txt' }, agent, }) + const firstText = blocksText(workspaceContextOf(first)?.content) + expect(firstText).toContain('native package rule') + expect(firstText).toContain('sibling package rule') appendAdditionalContexts(agent, first) await rm(join(root, 'pkg/AGENTS.md')) - const changed = await ctx.tools.execute({ + const removed = await ctx.tools.execute({ signal: testToolSignal, - callId: CallId('read-after-fallback'), name: 'read', arguments: { file_path: 'pkg/file.txt' }, agent, - }) - appendAdditionalContexts(agent, changed) - const unchanged = await ctx.tools.execute({ - signal: testToolSignal, - callId: CallId('read-after-logged-fallback'), name: 'read', arguments: { file_path: 'pkg/file.txt' }, agent, + callId: CallId('read-after-one-sibling-removed'), name: 'read', arguments: { file_path: 'pkg/file.txt' }, agent, }) - expect(workspaceContextOf(changed)?.meta).toMatchObject({ - changes: [{ - action: 'replace', scope: 'pkg', path: join('pkg', 'CLAUDE.md'), previousPath: join('pkg', 'AGENTS.md'), - }], + // Removing one candidate only removes its own scope; the sibling scope is untouched. + expect(workspaceContextOf(removed)?.meta).toMatchObject({ + changes: [{ action: 'remove', scope: sk('pkg', 'AGENTS.md'), path: 'pkg/AGENTS.md' }], }) - expect(blocksText(workspaceContextOf(changed)?.content)).toContain(`Updated instructions from: ${join('pkg', 'CLAUDE.md')}`) - expect(blocksText(workspaceContextOf(changed)?.content)).toContain(`The instructions previously loaded from \`${join('pkg', 'AGENTS.md')}\` no longer apply. Use the following content for \`pkg\` instead.`) - expect(blocksText(workspaceContextOf(changed)?.content)).toContain('fallback package rule') - expect(unchanged.additionalContexts).toBeUndefined() + expect(blocksText(workspaceContextOf(removed)?.content)).toContain('Instructions removed: pkg/AGENTS.md') + expect(blocksText(workspaceContextOf(removed)?.content)).not.toContain('sibling package rule') + } finally { + await rm(root, { recursive: true, force: true }) + await rm(home, { recursive: true, force: true }) + } + }) + + it('drops a newly discovered sibling whose content duplicates an earlier candidate in the scope', async () => { + const root = await tempRepo() + const home = await tempRepo() + try { + await mkdir(join(root, '.git'), { recursive: true }) + await write(join(root, 'pkg/AGENTS.md'), 'nested rule') + await write(join(root, 'pkg/CLAUDE.md'), 'nested rule') + await write(join(root, 'pkg/deep/file.txt'), 'hello') + const ctx = new Context() + await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) + const agent = stubAgent(root) + + const result = await ctx.tools.execute({ + signal: testToolSignal, + callId: CallId('read-nested-dup-siblings'), name: 'read', arguments: { file_path: 'pkg/deep/file.txt' }, agent, + }) + + expect(workspaceContextOf(result)?.meta).toMatchObject({ + changes: [{ action: 'set', scope: sk('pkg', 'AGENTS.md'), path: 'pkg/AGENTS.md' }], + }) + const text = blocksText(workspaceContextOf(result)?.content) + expect(text.match(/nested rule/g)).toHaveLength(1) + expect(text).toContain('Additional instructions from: pkg/AGENTS.md') + expect(text).not.toContain('pkg/CLAUDE.md') + } finally { + await rm(root, { recursive: true, force: true }) + await rm(home, { recursive: true, force: true }) + } + }) + + it('removes a previously rendered sibling once its content becomes a duplicate of an earlier candidate', async () => { + const root = await tempRepo() + const home = await tempRepo() + try { + await mkdir(join(root, '.git'), { recursive: true }) + await write(join(root, 'pkg/AGENTS.md'), 'canonical nested rule') + await write(join(root, 'pkg/CLAUDE.md'), 'divergent nested rule') + await write(join(root, 'pkg/file.txt'), 'hello') + const ctx = new Context() + await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) + const agent = stubAgent(root) + + const first = await ctx.tools.execute({ + signal: testToolSignal, + callId: CallId('read-before-dup-convergence'), name: 'read', arguments: { file_path: 'pkg/file.txt' }, agent, + }) + const firstText = blocksText(workspaceContextOf(first)?.content) + expect(firstText).toContain('canonical nested rule') + expect(firstText).toContain('divergent nested rule') + appendAdditionalContexts(agent, first) + await write(join(root, 'pkg/CLAUDE.md'), 'canonical nested rule') + const converged = await ctx.tools.execute({ + signal: testToolSignal, + callId: CallId('read-after-dup-convergence'), name: 'read', arguments: { file_path: 'pkg/file.txt' }, agent, + }) + + expect(workspaceContextOf(converged)?.meta).toMatchObject({ + changes: [{ action: 'remove', scope: sk('pkg', 'CLAUDE.md'), path: 'pkg/CLAUDE.md' }], + }) + expect(blocksText(workspaceContextOf(converged)?.content)).toContain('Instructions removed: pkg/CLAUDE.md') + } finally { + await rm(root, { recursive: true, force: true }) + await rm(home, { recursive: true, force: true }) + } + }) + + it('removes an unchanged sibling when an earlier candidate changes to match its content', async () => { + const root = await tempRepo() + const home = await tempRepo() + try { + await mkdir(join(root, '.git'), { recursive: true }) + await write(join(root, 'pkg/AGENTS.md'), 'primary nested rule') + await write(join(root, 'pkg/CLAUDE.md'), 'secondary nested rule') + await write(join(root, 'pkg/file.txt'), 'hello') + const ctx = new Context() + await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) + const agent = stubAgent(root) + + const first = await ctx.tools.execute({ + signal: testToolSignal, + callId: CallId('read-before-earlier-converges'), name: 'read', arguments: { file_path: 'pkg/file.txt' }, agent, + }) + appendAdditionalContexts(agent, first) + // Only the earlier candidate changes; the sibling stays byte-identical but now duplicates it. + await write(join(root, 'pkg/AGENTS.md'), 'secondary nested rule') + const converged = await ctx.tools.execute({ + signal: testToolSignal, + callId: CallId('read-after-earlier-converges'), name: 'read', arguments: { file_path: 'pkg/file.txt' }, agent, + }) + + expect(workspaceContextOf(converged)?.meta).toMatchObject({ + changes: [ + { action: 'replace', scope: sk('pkg', 'AGENTS.md'), path: 'pkg/AGENTS.md' }, + { action: 'remove', scope: sk('pkg', 'CLAUDE.md'), path: 'pkg/CLAUDE.md' }, + ], + }) + const text = blocksText(workspaceContextOf(converged)?.content) + expect(text).toContain('Instructions removed: pkg/CLAUDE.md') + expect(text).toContain('Updated instructions from: pkg/AGENTS.md') } finally { await rm(root, { recursive: true, force: true }) await rm(home, { recursive: true, force: true }) @@ -2211,11 +2296,11 @@ describe('dynamic nested workspace context injection', () => { expect(workspaceContextOf(removed)?.meta).toEqual({ kind: 'workspace-instructions', version: 1, - changes: [{ action: 'remove', scope: 'pkg', path: join('pkg', 'AGENTS.md') }], + changes: [{ action: 'remove', scope: sk('pkg', 'AGENTS.md'), path: 'pkg/AGENTS.md' }], }) expect(blocksText(workspaceContextOf(removed)?.content)).toBe([ '', - `Instructions removed: ${join('pkg', 'AGENTS.md')}`, + 'Instructions removed: pkg/AGENTS.md', '', 'The previously loaded instructions from this file no longer apply.', '', @@ -2226,6 +2311,45 @@ describe('dynamic nested workspace context injection', () => { } }) + it('removes a previously loaded instruction file once it resolves to a directory through a symlink', async () => { + const root = await tempRepo() + const home = await tempRepo() + try { + await mkdir(join(root, '.git'), { recursive: true }) + await write(join(root, 'pkg/AGENTS.md'), 'package rule') + await write(join(root, 'pkg/file.txt'), 'hello') + const ctx = new Context() + await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) + const agent = stubAgent(root) + + const first = await ctx.tools.execute({ + signal: testToolSignal, + callId: CallId('read-before-symlink-dir'), name: 'read', arguments: { file_path: 'pkg/file.txt' }, agent, + }) + appendAdditionalContexts(agent, first) + expect(blocksText(workspaceContextOf(first)?.content)).toContain('package rule') + + // The candidate now resolves through a symlink to a directory. A non-file + // target is a confirmed absence (not unavailable), so the loaded scope is + // removed; an unavailable classification would emit no change at all. + await rm(join(root, 'pkg/AGENTS.md')) + await mkdir(join(root, 'pkg/elsewhere'), { recursive: true }) + await symlink(join(root, 'pkg/elsewhere'), join(root, 'pkg/AGENTS.md')) + const removed = await ctx.tools.execute({ + signal: testToolSignal, + callId: CallId('read-after-symlink-dir'), name: 'read', arguments: { file_path: 'pkg/file.txt' }, agent, + }) + + expect(workspaceContextOf(removed)?.meta).toMatchObject({ + changes: [{ action: 'remove', scope: sk('pkg', 'AGENTS.md'), path: 'pkg/AGENTS.md' }], + }) + expect(blocksText(workspaceContextOf(removed)?.content)).toContain('Instructions removed: pkg/AGENTS.md') + } finally { + await rm(root, { recursive: true, force: true }) + await rm(home, { recursive: true, force: true }) + } + }) + it('loads a candidate again after a logged removal tombstone', async () => { const root = await tempRepo() const home = await tempRepo() @@ -2256,9 +2380,9 @@ describe('dynamic nested workspace context injection', () => { }) expect(workspaceContextOf(restored)?.meta).toMatchObject({ - changes: [{ action: 'set', scope: 'pkg', path: join('pkg', 'AGENTS.md') }], + changes: [{ action: 'set', scope: sk('pkg', 'AGENTS.md'), path: 'pkg/AGENTS.md' }], }) - expect(blocksText(workspaceContextOf(restored)?.content)).toContain(`Additional instructions from: ${join('pkg', 'AGENTS.md')}`) + expect(blocksText(workspaceContextOf(restored)?.content)).toContain('Additional instructions from: pkg/AGENTS.md') expect(blocksText(workspaceContextOf(restored)?.content)).toContain('restored package rule') } finally { await rm(root, { recursive: true, force: true }) @@ -2363,7 +2487,7 @@ describe('dynamic nested workspace context injection', () => { const update = resumed.session.events.findLast(event => event.type === 'context/message') expect(update?.type === 'context/message' && update.data.meta).toMatchObject({ - changes: [{ action: 'replace', scope: 'pkg', path: join('pkg', 'AGENTS.md') }], + changes: [{ action: 'replace', scope: sk('pkg', 'AGENTS.md'), path: 'pkg/AGENTS.md' }], }) expect(update?.type === 'context/message' && blocksText(update.data.content)).toContain('new nested rule after resume') } finally { @@ -2491,8 +2615,8 @@ describe('dynamic nested workspace context injection', () => { }) const firstText = blocksText(workspaceContextOf(first)?.content) - expect(firstText).toContain(`omitted ${join('pkg', 'AGENTS.md')}`) - expect(firstText).not.toContain(`## ${join('pkg', 'AGENTS.md')}`) + expect(firstText).toContain('omitted pkg/AGENTS.md') + expect(firstText).not.toContain('## pkg/AGENTS.md') expect(firstText).toContain('subtree rule') expect(blocksText(workspaceContextOf(second)?.content)).toContain('parent rule') } finally { @@ -2524,7 +2648,6 @@ describe('dynamic nested workspace context injection', () => { null, { action: 'unknown', scope: 'pkg', path: 'pkg/AGENTS.md' }, { action: 'set', scope: 'pkg', path: 42 }, - { action: 'replace', scope: 'pkg', path: 'pkg/AGENTS.md', previousPath: 42 }, { action: 'set', scope: 'pkg', path: 'pkg/AGENTS.md', digest: 42 }, ], }, @@ -2594,7 +2717,7 @@ describe('dynamic nested workspace context injection', () => { } }) - it('treats provider failures and type disagreement after lstat as unavailable, not removed', async () => { + it('treats a reconciliation provider failure as unavailable and a resolved non-file as absent', async () => { const root = await tempRepo() const home = await tempRepo() const ctx = new Context() @@ -2602,7 +2725,6 @@ describe('dynamic nested workspace context injection', () => { await ctx.plugin(RecordingFileSystem) const fs = ctx.fs as RecordingFileSystem fs.entries.set(join(root, '.git'), { type: 'directory' }) - fs.lstatTypes.set(join(root, 'pkg/AGENTS.md'), 'file') fs.throwOnStat.add(join(root, 'pkg/AGENTS.md')) await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 }) const agent = stubAgent(root) @@ -2612,13 +2734,13 @@ describe('dynamic nested workspace context injection', () => { isError: false, } - const failedStat = await postExecute(ctx, stubToolExecution({ + const failedStat = await ctx.waterfall('tools/post-execute', stubToolExecution({ signal: testToolSignal, callId: CallId('provider-stat-failure'), name: 'read', arguments: { file_path: 'pkg/file.txt' }, agent, }), result, async () => ({ kind: 'accept' as const })) fs.throwOnStat.clear() fs.entries.set(join(root, 'pkg/AGENTS.md'), { type: 'directory' }) - const mismatchedStat = await postExecute(ctx, stubToolExecution({ + const mismatchedStat = await ctx.waterfall('tools/post-execute', stubToolExecution({ signal: testToolSignal, callId: CallId('provider-stat-mismatch'), name: 'read', arguments: { file_path: 'pkg/file.txt' }, agent, }), result, async () => ({ kind: 'accept' as const })) @@ -2635,19 +2757,14 @@ describe('dynamic nested workspace context injection', () => { it('skips unreadable nested instruction files without attaching empty context', async () => { const root = await tempRepo() const home = await tempRepo() - const ctx = new Context() try { + await mkdir(join(root, '.git'), { recursive: true }) const nested = join(root, 'pkg/AGENTS.md') - await ctx.plugin(SystemPrompt) - await ctx.plugin(ToolRegistry) - await ctx.plugin(RecordingFileSystem) - const fs = ctx.fs as RecordingFileSystem - fs.entries.set(join(root, '.git'), { type: 'directory' }) - fs.entries.set(nested, { type: 'file', content: 'nested package rule' }) - fs.entries.set(join(root, 'pkg/deep/file.txt'), { type: 'file', content: 'hello' }) - fs.throwOnRead.add(nested) - await ctx.plugin(ToolFs) - await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 }) + await write(nested, 'nested package rule') + await write(join(root, 'pkg/deep/file.txt'), 'hello') + await chmod(nested, 0) + const ctx = new Context() + await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) const result = await ctx.tools.execute({ signal: testToolSignal, @@ -2659,9 +2776,8 @@ describe('dynamic nested workspace context injection', () => { expect(result.isError).toBe(false) expect(result.additionalContexts).toBeUndefined() - expect(fs.readTargets).toContain(nested) + await chmod(nested, 0o600) } finally { - await ctx.fiber.dispose() await rm(root, { recursive: true, force: true }) await rm(home, { recursive: true, force: true }) } @@ -2698,7 +2814,7 @@ describe('dynamic nested workspace context injection', () => { expect(workspaceContextOf(result)?.source).toEqual({ kind: 'plugin', plugin: 'workspace-context' }) expect(workspaceContextOf(result)?.meta).toMatchObject({ kind: 'workspace-instructions', - changes: [{ action: 'set', scope: 'pkg', path: join('pkg', 'AGENTS.md') }], + changes: [{ action: 'set', scope: sk('pkg', 'AGENTS.md'), path: 'pkg/AGENTS.md' }], }) expect(blocksText(workspaceContextOf(result)?.content)).toContain('nested package rule') expect(blocksText(workspaceContextOf(result)?.content)).not.toContain('downstream context') @@ -2816,12 +2932,13 @@ describe('dynamic nested workspace context injection', () => { parameters: {}, async execute(_args, exec) { const nested = await ctx.tools.execute({ + signal: testToolSignal, callId: CallId(`${exec.callId}:nested`), name: 'read', arguments: { file_path: 'pkg/deep/file.txt' }, ...exec.agent === undefined ? {} : { agent: exec.agent }, parent: exec.token, - signal: exec.signal, + ...exec.signal === undefined ? {} : { signal: exec.signal }, }) for (const context of nested.additionalContexts ?? []) exec.deferContext(context) return nested.content @@ -2866,23 +2983,23 @@ describe('dynamic nested workspace context injection', () => { const parent = Symbol('parent') as ToolExecutionToken const plainResult = { callId: CallId('plain'), content: [], isError: false } - emitToolResult(ctx, stubToolExecution({ + ctx.emit('tools/result', stubToolExecution({ signal: testToolSignal, callId: CallId('agentless-child'), name: 'read', arguments: {}, parent, }), plainResult) - emitToolResult(ctx, stubToolExecution({ + ctx.emit('tools/result', stubToolExecution({ signal: testToolSignal, callId: CallId('contextless-child'), name: 'read', arguments: {}, agent, parent, }), { ...plainResult, additionalContexts: [{ content: [], source: { kind: 'plugin', plugin: 'workspace-context' } }] }) - emitToolResult(ctx, stubToolExecution({ + ctx.emit('tools/result', stubToolExecution({ signal: testToolSignal, callId: CallId('first-child'), name: 'read', arguments: {}, agent, parent, }), { ...plainResult, additionalContexts: [workspaceChangeContext('first', 'one')] }) - emitToolResult(ctx, stubToolExecution({ + ctx.emit('tools/result', stubToolExecution({ signal: testToolSignal, callId: CallId('second-child'), name: 'read', arguments: {}, agent, parent, }), { ...plainResult, additionalContexts: [workspaceChangeContext('second', 'two')] }) - emitToolResult(ctx, { + ctx.emit('tools/result', { ...stubToolExecution({ signal: testToolSignal, callId: CallId('agentless-parent'), name: 'composite', arguments: {} }), token: parent, }, plainResult) @@ -2918,7 +3035,7 @@ describe('dynamic nested workspace context injection', () => { ] for (const item of cases) { - const decision = await postExecute(ctx, stubToolExecution({ + const decision = await ctx.waterfall('tools/post-execute', stubToolExecution({ signal: testToolSignal, callId: CallId(`manual-${item.name}-${cases.indexOf(item)}`), name: item.name, @@ -3042,7 +3159,7 @@ describe('workspace context pending state', () => { const [change] = commitPendingInstructionContexts(agent, [workspaceChangeContext('pkg', 'one')], pending) expect(change).toBeDefined() versions.set(agent.session, new Map([['pkg', { - path: 'pkg/AGENTS.md', version: FsVersion('v1'), digest: 'one', + path: 'pkg/AGENTS.md', version: FsVersion('v1'), digest: 'one', trimmedDigest: 'one', }]])) const unrelated = agent.session.append('context/message', { @@ -3079,7 +3196,7 @@ describe('workspace context pending state', () => { agent.session.append('step/start', { turn: 1, step: 1 }) commitPendingInstructionContexts(agent, [workspaceChangeContext('pkg', 'one')], pending) versions.set(agent.session, new Map([['pkg', { - path: 'pkg/AGENTS.md', version: FsVersion('v1'), digest: 'one', + path: 'pkg/AGENTS.md', version: FsVersion('v1'), digest: 'one', trimmedDigest: 'one', }]])) const ended = agent.session.append('step/end', { turn: 1, step: 1 }) From 2a5dfb7d35600aa735073baefe01d7ab6c52a72f Mon Sep 17 00:00:00 2001 From: Turtle Date: Wed, 22 Jul 2026 10:55:19 +0800 Subject: [PATCH 39/90] =?UTF-8?q?feat(tui):=20session=20resume=20=E2=80=94?= =?UTF-8?q?=20/resume=20command,=20exit=20hint,=20and=20dsh=20--resume=20?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Squashes feat/tui-resume-command, fix/tui-resume-desc, and feat/tui-resume-flag. --- .../2026-07-21-tui-resume-command.i18n.yaml | 6 +++ .../feature/2026-07-21-tui-resume-command.md | 41 ++++++++++++++++++ .../2026-07-21-tui-resume-command.zh.md | 41 ++++++++++++++++++ apps/cli/README.md | 1 + apps/cli/src/tui.ts | 16 ++++++- packages/ui/app-boot/tests/app-boot.spec.ts | 22 +++++++++- packages/ui/tui/package.json | 12 ++++++ .../snapshots/resume-sessions.expected.txt | 28 ++++++++++++ packages/ui/tui/tests/tui.snapshot.ts | 43 ++++++++++++++++++- packages/ui/tui/tests/tui.spec.ts | 31 ------------- 10 files changed, 206 insertions(+), 35 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-07-21-tui-resume-command.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-07-21-tui-resume-command.md create mode 100644 .agents/notes/implemented/feature/2026-07-21-tui-resume-command.zh.md create mode 100644 packages/ui/tui/tests/snapshots/resume-sessions.expected.txt diff --git a/.agents/notes/implemented/feature/2026-07-21-tui-resume-command.i18n.yaml b/.agents/notes/implemented/feature/2026-07-21-tui-resume-command.i18n.yaml new file mode 100644 index 0000000000..210215eb3d --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-21-tui-resume-command.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 +2026-07-21-tui-resume-command.md: 2282eaa9bff83fdb75bdce315d6b17bf8f9ea303 +2026-07-21-tui-resume-command.zh.md: f9d989a5b4e7eb106ff21c5a4fcfa770a5962343 diff --git a/.agents/notes/implemented/feature/2026-07-21-tui-resume-command.md b/.agents/notes/implemented/feature/2026-07-21-tui-resume-command.md new file mode 100644 index 0000000000..2282eaa9bf --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-21-tui-resume-command.md @@ -0,0 +1,41 @@ +# Agent Note: Resume command hint and `/resume` + +Status: implemented + +English | [中文](2026-07-21-tui-resume-command.zh.md) + +## Problem + +The TUI can resume a session by launch (`RESUME_SESSION_ID= dsh` feeding `dsh-tui-demo`'s `resumeSessionId`), but nothing told the user the command. On exit the session id survived only in the log and `./.sessions` filenames — the [no-banner Agent Note](2026-07-21-tui-no-banner.md) removed the last place it was shown — so resuming meant hunting for the id and reconstructing the invocation. There was also no in-session way to see which sessions in this workspace are resumable. + +## Decision + +A single optional `resumeCommand` config field on `dsh-tui` gates both surfaces: a shell command template whose every `{session}` is replaced with the live session id (e.g. `dsh --resume {session}`). Absent, neither surface appears. + +- **Exit hint.** Process-exiting shutdown prints `To resume this session: ` (muted label) via `runtime.terminal.write` after `ui.stop()`, before `runtime.exit`. It prints only once the session is durably persisted: `currentResumeCommand()` scans the session list for the current id and returns `undefined` if it is absent, so a session abandoned before its first flush advertises no command that would fail to load. +- **`/resume`.** Lists this workspace's persisted sessions newest-first, each with its resume command, marking the current one `(current)`. It warns when `resumeCommand` is unconfigured or no persistence backend is mounted, and notes when nothing is persisted yet. The listing is asynchronous, so the transcript updates a tick after submit. +- **Listing.** `listWorkspaceSessions()` reads the optional `sessionPersistence` service's `list()`, keeps headers whose `cwd === agent.session.header.cwd`, and sorts by `createdAt` descending. A `list()` rejection is swallowed to `[]` — a persistence failure must never block terminal exit or crash `/resume`. + +`sessionPersistence` is an optional injected service reached through `ctx.get('sessionPersistence')` (not `inject`), declared as an optional peer dependency. Without a backend the field still parses; the exit hint and `/resume` degrade to nothing and the unconfigured/no-backend warnings respectively. `dsh-tui-demo` forwards `resumeCommand` to `dsh-tui`, and the runnable `examples/tui-agent` leaves set `dsh --resume {session}`. The `dsh` CLI (`apps/cli`) parses that `--resume ` flag through `parseResumeArg` in [`dsh-app-boot`](../../../../packages/ui/app-boot/README.md), setting `RESUME_SESSION_ID` before boot so the printed command runs back through the config's existing `resumeSessionId` intake; a mistyped or repeated flag fails loud rather than silently starting fresh. + +## Alternatives considered + +**Hardcode or auto-detect the resume invocation.** Rejected: the launch command is deployment-specific — the env-var name, binary, and flags all vary — so a `DEFAULT_*` constant would be a fixed tunable, not configurability. A template owned by the leaf keeps the choice where the deployment lives, and `{session}` is the only substitution the TUI must know. + +**Two config fields, one per surface.** Rejected: both render the identical command, so one field keeps them symmetric and unable to drift; there is no deployment that wants the hint but not the listing. + +**Print the exit hint unconditionally.** Rejected: resuming a session id that never flushed fails to load, so advertising it is a broken instruction. Gating on the id appearing in `list()` costs one scan and only ever suppresses a dead command. + +**Resume in place from `/resume` (relaunch or reattach).** Rejected: the TUI does not own agent lifecycle or process spawning ([front-door Agent Note](2026-07-17-dedicated-full-screen-tui-front-door.md)). Printing a copyable command respects that boundary and matches the `pi --resume` affordance the request cited. + +**Make `sessionPersistence` a required `inject`.** Rejected: the TUI must run without persistence (fixtures, ephemeral runs). An optional service that degrades preserves that, and matches the [`session-query`](../../../../packages/session-query/session-query/package.json) precedent for the same optional peer. + +## Consequences + +- `dsh-tui` gains an optional peer dependency on `@deepseek-ai/dsh-session-persistence` (`peerDependenciesMeta.optional`), matching `session-query`; the package still loads and passes its coverage gate without a backend mounted. +- The help line and autocomplete gain `/resume`; two existing snapshots re-recorded for the wider help line, and a new `resume-sessions` checkpoint pins the rendered listing. +- `dsh-tui-demo` and both `examples/tui-agent` leaves carry `resumeCommand`, so a real TUI run now prints its own resume command on exit, and the `dsh` CLI accepts the printed `--resume ` flag to run it. + +## Testing + +`packages/ui/tui/tests/tui.spec.ts` pins the seven behaviors: the exit hint prints only when the current session is persisted, is omitted when it is not and when `list()` rejects; `/resume` lists workspace sessions newest-first with the `(current)` marker and cwd filter, warns when unconfigured and when no backend is mounted, and notes when nothing is persisted. The `resume-sessions` snapshot verifies the full rendered frame. The harness provides a fake `sessionPersistence` through `ctx.provide`. For the `--resume` flag, `packages/ui/app-boot/tests/app-boot.spec.ts` pins `parseResumeArg` (space and inline forms, position independence, and the fail-loud on a valueless, empty, or repeated flag), and `examples/tui-agent/tests/tui-keyless-smoke.e2e.ts` boots `apps/cli` with `--resume ` and asserts the config resume fails loud — proving the flag reaches the `resumeSessionId` intake. diff --git a/.agents/notes/implemented/feature/2026-07-21-tui-resume-command.zh.md b/.agents/notes/implemented/feature/2026-07-21-tui-resume-command.zh.md new file mode 100644 index 0000000000..f9d989a5b4 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-21-tui-resume-command.zh.md @@ -0,0 +1,41 @@ +# Agent Note: Resume command hint and `/resume` + +Status: implemented + +[English](2026-07-21-tui-resume-command.md) | 中文 + +## Problem + +TUI 本就能通过启动参数恢复会话(`RESUME_SESSION_ID= dsh` 喂给 `dsh-tui-demo` 的 `resumeSessionId`),但没有任何地方告诉用户这条命令。退出时会话 id 只残留在会话日志和 `./.sessions` 文件名里——[移除启动横幅 Agent Note](2026-07-21-tui-no-banner.md) 移除了它最后一处显示位置——因此恢复意味着先翻出 id 再拼回调用命令。也没有任何会话内的方式查看当前 workspace 里哪些会话可恢复。 + +## Decision + +`dsh-tui` 上一个可选的 `resumeCommand` 配置字段同时管辖两处出口:一个 shell 命令模板,其中每一处 `{session}` 都会被替换为当前会话 id(例如 `dsh --resume {session}`)。未设置时两处都不出现。 + +- **退出提示。** 以退出进程方式关闭时,在 `ui.stop()` 之后、`runtime.exit` 之前,经由 `runtime.terminal.write` 打印 `To resume this session: `(弱化的标签)。仅当会话已持久化时才打印:`currentResumeCommand()` 在会话列表中查找当前 id,若不存在则返回 `undefined`,因此在首次刷盘前就被放弃的会话不会宣传一条注定加载失败的命令。 +- **`/resume`。** 按最新在前列出当前 workspace 里已持久化的会话,每条附带其恢复命令,并给当前会话标注 `(current)`。当 `resumeCommand` 未配置或未挂载持久化后端时给出告警,尚无任何会话被持久化时给出提示。列出是异步的,因此提交后文本记录会在下一个 tick 更新。 +- **列出逻辑。** `listWorkspaceSessions()` 读取可选的 `sessionPersistence` 服务的 `list()`,保留 `cwd === agent.session.header.cwd` 的头部,并按 `createdAt` 降序排序。`list()` 拒绝时吞掉为 `[]`——持久化失败绝不能阻塞终端退出或让 `/resume` 崩溃。 + +`sessionPersistence` 是一个通过 `ctx.get('sessionPersistence')`(而非 `inject`)获取的可选注入服务,声明为可选的对等依赖(peer dependency)。没有后端时该字段仍能解析;退出提示与 `/resume` 分别退化为不做任何事、以及给出未配置/无后端告警。`dsh-tui-demo` 将 `resumeCommand` 转发给 `dsh-tui`,可运行的 `examples/tui-agent` 叶子配置设为 `dsh --resume {session}`。`dsh` CLI(`apps/cli`)通过 [`dsh-app-boot`](../../../../packages/ui/app-boot/README.md) 中的 `parseResumeArg` 解析该 `--resume ` 标志,在启动前设置 `RESUME_SESSION_ID`,因此打印出的命令会重新走回配置中既有的 `resumeSessionId` 入口;拼写错误或重复的标志会直接报错退出,而非悄悄开启一个新会话。 + +## Alternatives considered + +**硬编码或自动探测恢复调用命令。** 否决:启动命令与部署强相关——环境变量名、可执行文件、参数都各不相同——因此一个 `DEFAULT_*` 常量只会是固定的可调项,而非可配置项。由叶子拥有的模板把这个选择留在部署所在之处,而 `{session}` 是 TUI 唯一需要知道的替换。 + +**两个配置字段,每处出口一个。** 否决:两处渲染的是完全相同的命令,因此单个字段让它们保持对称、不会漂移;不存在只想要提示而不想要列表的部署。 + +**无条件打印退出提示。** 否决:恢复一个从未刷盘的会话 id 会加载失败,宣传它就是一条错误指令。以 id 是否出现在 `list()` 中为条件仅需一次扫描,且只会抑制一条注定失败的命令。 + +**从 `/resume` 就地恢复(重启或重连)。** 否决:TUI 不拥有 agent 生命周期或进程创建([全屏 TUI 门面 Agent Note](2026-07-17-dedicated-full-screen-tui-front-door.md))。打印一条可复制的命令尊重这条边界,也契合需求所引用的 `pi --resume` 用法。 + +**把 `sessionPersistence` 设为必需的 `inject`。** 否决:TUI 必须能在无持久化时运行(fixture(测试前置数据)、临时运行)。一个会优雅退化的可选服务保住了这一点,也与 [`session-query`](../../../../packages/session-query/session-query/package.json) 对同一可选对等依赖的先例一致。 + +## Consequences + +- `dsh-tui` 新增对 `@deepseek-ai/dsh-session-persistence` 的可选对等依赖(`peerDependenciesMeta.optional`),与 `session-query` 一致;未挂载后端时该包仍能加载并通过其覆盖率门禁。 +- 帮助行和自动补全新增 `/resume`;两个既有快照因帮助行变宽而重新录制,新增的 `resume-sessions` 检查点固定渲染出的列表。 +- `dsh-tui-demo` 及两个 `examples/tui-agent` 叶子配置都带上 `resumeCommand`,因此真实的 TUI 运行现在退出时会打印自己的恢复命令,且 `dsh` CLI 接受打印出的 `--resume ` 标志来运行它。 + +## Testing + +`packages/ui/tui/tests/tui.spec.ts` 固定这七种行为:退出提示仅在当前会话已持久化时打印,未持久化时以及 `list()` 拒绝时都不打印;`/resume` 按最新在前列出 workspace 会话并带 `(current)` 标注与 cwd 过滤、未配置时告警、无后端时告警、尚无持久化时给出提示。`resume-sessions` 快照验证完整渲染帧。测试脚手架通过 `ctx.provide` 提供一个假的 `sessionPersistence`。对于 `--resume` 标志,`packages/ui/app-boot/tests/app-boot.spec.ts` 固定 `parseResumeArg`(空格形式与内联形式、位置无关性,以及在标志缺值、为空或重复时直接报错退出),`examples/tui-agent/tests/tui-keyless-smoke.e2e.ts` 用 `--resume ` 启动 `apps/cli` 并断言配置恢复直接报错退出——证明该标志抵达了 `resumeSessionId` 入口。 diff --git a/apps/cli/README.md b/apps/cli/README.md index eb3eb4efc3..935b9804d9 100644 --- a/apps/cli/README.md +++ b/apps/cli/README.md @@ -5,6 +5,7 @@ The `dsh` command-line entry, following the `apps/` assembly tier proposed by th The TUI surface: - boots the shipped default config (`examples/tui-agent/cordis.yml`) or an explicit config argument, through [`dsh-app-boot`](../../packages/ui/app-boot/README.md); +- resumes a persisted session with `dsh --resume ` — the form the TUI prints on exit and lists under `/resume`; the flag sets `RESUME_SESSION_ID` before boot so the shipped config rehydrates that session, and a missing or unreadable id fails loud and exits nonzero; - treats the **invoking directory** as the workspace — sessions, relative paths, and workspace instructions resolve from the cwd; - tells the agent where its own source lives: after boot it adds a prompt section naming this harness checkout, resolved from the launcher's real path so it holds under a PATH symlink and an arbitrary cwd, so the self-referential `cordis` toolset can read and modify it; - applies the personal overlay from `~/.config/dsh` (see [app-boot's Personal config](../../packages/ui/app-boot/README.md#personal-config)): `.env` fills environment gaps (ambient > project `.env` > personal `.env`), `config.yaml` patches the booted tree. diff --git a/apps/cli/src/tui.ts b/apps/cli/src/tui.ts index 2ffc26081f..befe954806 100644 --- a/apps/cli/src/tui.ts +++ b/apps/cli/src/tui.ts @@ -18,12 +18,19 @@ import { installFailLoud, loadEnv, loadPersonalPatches, + parseResumeArg, resolveConfigPath, resolvePersonalConfigDir, } from '@deepseek-ai/dsh-app-boot' const NAME = 'dsh' +// The env var the shipped tui-agent config reads (`resumeSessionId: !!js +// process.env.RESUME_SESSION_ID`) to rehydrate a persisted session. The +// `--resume ` flag is CLI sugar that sets it before boot, so the printed +// `dsh --resume ` exit hint runs back through this same intake. +const RESUME_SESSION_ID_ENV = 'RESUME_SESSION_ID' + // Both the source tree (apps/cli/src) and the bundled bin (apps/cli/lib) sit // one directory under apps/cli, so the shipped default config resolves with // the same relative hop from either artifact. @@ -38,7 +45,8 @@ const SOURCE_ROOT = fileURLToPath(new URL('../../..', import.meta.url)) the tui-agent PTY smoke drives this path end to end, personal overlay included */ /** * Run the interactive TUI from the invoking directory. - * @param argv - arguments after the subcommand dispatch; `argv[0]` may name a + * @param argv - arguments after the subcommand dispatch; a `--resume ` flag + * resumes that persisted session, and the first non-flag argument may name a * config to boot instead of the shipped default. */ export async function runTui(argv: string[]): Promise { @@ -53,7 +61,11 @@ export async function runTui(argv: string[]): Promise { // The bin already loaded the invoking directory's .env; the personal .env // only fills what is still unset (process.loadEnvFile never overrides). loadEnv(NAME, resolvePersonalConfigDir()) - const ctx = await boot(NAME, resolveConfigPath(argv[0] ?? DEFAULT_CONFIG, undefined), loadPersonalPatches(NAME)) + // An explicit `--resume` flag beats any ambient RESUME_SESSION_ID, so set it + // after loadEnv and before boot reads it through the config's `!!js`. + const { resumeSessionId, rest } = parseResumeArg(argv) + if (resumeSessionId !== undefined) process.env[RESUME_SESSION_ID_ENV] = resumeSessionId + const ctx = await boot(NAME, resolveConfigPath(rest[0] ?? DEFAULT_CONFIG, undefined), loadPersonalPatches(NAME)) addHarnessSourceSection(ctx, SOURCE_ROOT) } /* v8 ignore stop */ diff --git a/packages/ui/app-boot/tests/app-boot.spec.ts b/packages/ui/app-boot/tests/app-boot.spec.ts index d9934cb8bb..fef17b5b07 100644 --- a/packages/ui/app-boot/tests/app-boot.spec.ts +++ b/packages/ui/app-boot/tests/app-boot.spec.ts @@ -6,7 +6,7 @@ import { Context } from 'cordis' import SystemPrompt, { renderPrompt } from '@deepseek-ai/dsh-system-prompt' import { addHarnessSourceSection, assertEntriesLoaded, boot, HARNESS_SOURCE_SECTION, - installFailLoud, loadEnv, resolveConfigPath, type FailLoudProcess, + installFailLoud, loadEnv, parseResumeArg, resolveConfigPath, type FailLoudProcess, } from '../src/index.ts' const NAME = 'dsh-test-bin' @@ -30,6 +30,26 @@ describe('resolveConfigPath', () => { }) }) +describe('parseResumeArg', () => { + it('returns no resume id and passes arguments through when the flag is absent', () => { + expect(parseResumeArg([])).toEqual({ resumeSessionId: undefined, rest: [] }) + expect(parseResumeArg(['custom.yml'])).toEqual({ resumeSessionId: undefined, rest: ['custom.yml'] }) + }) + + it('parses the space form, the inline form, and leaves a positional config path in any position', () => { + expect(parseResumeArg(['--resume', 'sess-1'])).toEqual({ resumeSessionId: 'sess-1', rest: [] }) + expect(parseResumeArg(['--resume=sess-2'])).toEqual({ resumeSessionId: 'sess-2', rest: [] }) + expect(parseResumeArg(['--resume', 'sess-3', 'app.yml'])).toEqual({ resumeSessionId: 'sess-3', rest: ['app.yml'] }) + expect(parseResumeArg(['app.yml', '--resume', 'sess-4'])).toEqual({ resumeSessionId: 'sess-4', rest: ['app.yml'] }) + }) + + it('fails loud on a valueless, empty, or repeated flag rather than silently starting fresh', () => { + expect(() => parseResumeArg(['--resume'])).toThrow('--resume requires a session id') + expect(() => parseResumeArg(['--resume='])).toThrow('--resume requires a session id') + expect(() => parseResumeArg(['--resume', 'a', '--resume', 'b'])).toThrow('--resume may be given only once') + }) +}) + describe('loadEnv', () => { it('loads variables from .env in the given dir', () => { const dir = tmp() diff --git a/packages/ui/tui/package.json b/packages/ui/tui/package.json index cb34aa6367..38c05a3d6f 100644 --- a/packages/ui/tui/package.json +++ b/packages/ui/tui/package.json @@ -34,13 +34,23 @@ "@deepseek-ai/dsh-llm": "^0.0.1", "@deepseek-ai/dsh-llm-retry": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1", + "@deepseek-ai/dsh-session-persistence": "^0.0.1", "@deepseek-ai/dsh-session-title": "^0.0.1", + "@deepseek-ai/dsh-skill": "^0.0.1", "@deepseek-ai/dsh-system-prompt": "^0.0.1", "@deepseek-ai/dsh-token-meter": "^0.0.1", "@deepseek-ai/dsh-tools": "^0.0.1", "@deepseek-ai/dsh-user-interaction": "^0.0.1", "cordis": "^4.0.0-rc.7" }, + "peerDependenciesMeta": { + "@deepseek-ai/dsh-session-persistence": { + "optional": true + }, + "@deepseek-ai/dsh-skill": { + "optional": true + } + }, "dependencies": { "@earendil-works/pi-tui": "0.80.7", "schemastery": "^3.18.0" @@ -54,7 +64,9 @@ "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-llm-retry": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-session-persistence": "workspace:^", "@deepseek-ai/dsh-session-title": "workspace:^", + "@deepseek-ai/dsh-skill": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-token-meter": "workspace:^", "@deepseek-ai/dsh-tool-cordis": "workspace:^", diff --git a/packages/ui/tui/tests/snapshots/resume-sessions.expected.txt b/packages/ui/tui/tests/snapshots/resume-sessions.expected.txt new file mode 100644 index 0000000000..6e44fa8165 --- /dev/null +++ b/packages/ui/tui/tests/snapshots/resume-sessions.expected.txt @@ -0,0 +1,28 @@ +terminal 92x32 buffer=normal length=32 base=0 viewport=0 +lifecycle started=1 stopped=0 progress=inactive +title "DSH snapshot" +cursor hidden column=1 viewportRow=9 bufferRow=9 +buffer +0| +1| " Snapshot agent ready. " + style 1-21 fg=bright-black +2| +3| " Resumable sessions " + style 1-18 fg=bright-blue bold +4| " 2024-01-02 03:04 (current) " + style 1-16 fg=bright-black + style 17-26 fg=green +5| " RESUME_SESSION_ID=main-session dsh " +6| " 2024-01-01 00:00 " + style 1-16 fg=bright-black +7| " RESUME_SESSION_ID=earlier-session dsh " +8| "────────────────────────────────────────────────────────────────────────────────────────────" + style 0-91 dim +9| " " + style 1-1 inverse +10| "────────────────────────────────────────────────────────────────────────────────────────────" + style 0-91 dim +11| "deepseek-v4-flash /workspace/project ↑0 ↓0 tools:collapsed" + style 0-43 dim + style 77-91 dim +12-31| diff --git a/packages/ui/tui/tests/tui.snapshot.ts b/packages/ui/tui/tests/tui.snapshot.ts index 54d703d4a8..d706ede1d9 100644 --- a/packages/ui/tui/tests/tui.snapshot.ts +++ b/packages/ui/tui/tests/tui.snapshot.ts @@ -7,6 +7,7 @@ import { agentEvents } from '@deepseek-ai/dsh-agent' import { CallId, type ContentBlock } from '@deepseek-ai/dsh-llm' import type {} from '@deepseek-ai/dsh-llm-retry' import type { Session } from '@deepseek-ai/dsh-session' +import { SessionId } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry, { type ToolDefinition, type ToolResultView } from '@deepseek-ai/dsh-tools' import * as ToolCordis from '@deepseek-ai/dsh-tool-cordis' @@ -30,6 +31,7 @@ const CHECKPOINTS = [ 'retry-recovered', 'retry-cancelled', 'retry-exhausted', + 'banner-gradient', 'code-mode-pending', 'dynamic-workflow-pending', 'cordis-tools-pending', @@ -45,6 +47,7 @@ const CHECKPOINTS = [ 'model-switching', 'errors-and-help', 'disposed-terminal', + 'resume-sessions', ] as const type Checkpoint = typeof CHECKPOINTS[number] @@ -56,9 +59,23 @@ async function checkpoint( name: Checkpoint, terminal: HeadlessTerminal, options: TerminalSnapshotOptions = {}, + bannerGradient = false, ): Promise { observedCheckpoints.add(name) - expect(terminal.themeViolations(), `${name} must remain theme-agnostic`).toEqual([]) + const violations = terminal.themeViolations() + if (bannerGradient) { + // The banner paints its product name in the DeepSeek brand gradient with + // 24-bit foreground codes: the sole sanctioned truecolor. Require it to be + // present and to never leak a background or extended-palette color into the + // otherwise theme-agnostic UI. + expect(violations, `${name} must render the banner gradient`).not.toEqual([]) + expect( + violations.every(entry => entry.endsWith('rgb-fg')), + `${name} must confine truecolor to the banner foreground`, + ).toBe(true) + } else { + expect(violations, `${name} must remain theme-agnostic`).toEqual([]) + } const snapshot = await terminal.snapshot(options) const path = join(SNAPSHOTS_DIR, `${name}.expected.txt`) if (REFRESHING) { @@ -308,6 +325,12 @@ describe('TUI terminal-state snapshots', () => { await disposeSnapshot(harness) }) + it('paints the startup banner product name in the DeepSeek brand gradient on truecolor terminals', async () => { + const harness = await setupSnapshot({ config: { truecolor: true } }) + await checkpoint('banner-gradient', harness.terminal, {}, true) + await disposeSnapshot(harness) + }) + it('pins Code Mode run_code with its production presenter', async () => { const harness = await setupSnapshot({ configureContext: configureAdvancedTools }) const call = { @@ -596,6 +619,24 @@ describe('TUI terminal-state snapshots', () => { await checkpoint('model-switching', harness.terminal, { includeScrollback: true }) await disposeSnapshot(harness) }) + + it('lists this workspace\'s resumable sessions with their commands', async () => { + const harness = await setupSnapshot({ + config: { resumeCommand: 'RESUME_SESSION_ID={session} dsh' }, + sessionPersistence: { list: async () => [ + { version: 0, id: SessionId('main-session'), createdAt: Date.parse('2024-01-02T03:04:00Z'), cwd: '/workspace/project' }, + { version: 0, id: SessionId('earlier-session'), createdAt: Date.parse('2024-01-01T00:00:00Z'), cwd: '/workspace/project' }, + ] }, + }, { columns: 92, rows: 32 }) + harness.terminal.send('/resume') + harness.terminal.send('\r') + // `/resume` scans persistence asynchronously, so the listing renders a tick + // after submit (the unit suite waits the same way); settle, then flush. + await new Promise(resolve => setTimeout(resolve, 60)) + await harness.terminal.flush() + await checkpoint('resume-sessions', harness.terminal, { includeScrollback: true }) + await disposeSnapshot(harness) + }) }) afterAll(async () => { diff --git a/packages/ui/tui/tests/tui.spec.ts b/packages/ui/tui/tests/tui.spec.ts index af2676394d..00a8f25ab7 100644 --- a/packages/ui/tui/tests/tui.spec.ts +++ b/packages/ui/tui/tests/tui.spec.ts @@ -818,37 +818,6 @@ describe('pi-tui chat lifecycle and transcript', () => { await dispose(result) }) - it('shows the session cache hit rate in the footer and updates it live', async () => { - // Empty session: no input billed yet, so the cache segment is hidden. - // A cwd without "cache" in it keeps the negative assertion unambiguous. - const empty = await setup({ cwd: '/opt' }) - expect(empty.terminal.output).toContain('↑0 ↓0') - expect(empty.terminal.output).not.toContain('cache') - await dispose(empty) - - const result = await setup({ - beforeMount(session) { - // Cold call: 10 billed input tokens, none served from cache. - appendAssistant(session, [{ type: 'text', text: 'cold' }], { inputTokens: 10, outputTokens: 5 }) - }, - }) - expect(result.terminal.output).toContain('cache 0%') - - result.terminal.output = '' - // Warm call lands live: 5 uncached + 30 cache-read + 5 cache-write billed - // input, so 30 of the 50 total prompt tokens are hits → 60%. - appendAssistant(result.session, [{ type: 'text', text: 'warm' }], { - inputTokens: 5, - outputTokens: 5, - cacheReadTokens: 30, - cacheWriteTokens: 5, - }) - await tick() - expect(result.terminal.output).toContain('cache 60%') - expect(result.terminal.output).not.toContain('cache 0%') - await dispose(result) - }) - it('sends, steers, handles commands, global keys, and disposed-agent input', async () => { const result = await setup() From bffd80fa12906de6886321b4704135a0bf5930b0 Mon Sep 17 00:00:00 2001 From: Turtle Date: Wed, 22 Jul 2026 10:55:19 +0800 Subject: [PATCH 40/90] feat(tui): add /skill: manual skill invocation command --- .../feature/2026-07-05-skill-system.md | 2 +- ...26-07-21-tui-skill-slash-command.i18n.yaml | 6 ++++ .../2026-07-21-tui-skill-slash-command.md | 33 +++++++++++++++++++ .../2026-07-21-tui-skill-slash-command.zh.md | 33 +++++++++++++++++++ packages/ui/tui/tsconfig.json | 6 ++++ 5 files changed, 79 insertions(+), 1 deletion(-) create mode 100644 .agents/notes/implemented/feature/2026-07-21-tui-skill-slash-command.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-07-21-tui-skill-slash-command.md create mode 100644 .agents/notes/implemented/feature/2026-07-21-tui-skill-slash-command.zh.md diff --git a/.agents/notes/implemented/feature/2026-07-05-skill-system.md b/.agents/notes/implemented/feature/2026-07-05-skill-system.md index e59013c0a8..eccc58f7c2 100644 --- a/.agents/notes/implemented/feature/2026-07-05-skill-system.md +++ b/.agents/notes/implemented/feature/2026-07-05-skill-system.md @@ -50,4 +50,4 @@ The catalog is deterministic for a fixed root set and runtime registration revis ## Deferred -Forked skill contexts (`context: fork`), direct user/slash invocation (`user-invocable`), parameter declarations and hints (`arguments` and `argument-hint`), and per-skill tool constraints (`allowed-tools` and `disallowed-tools`) are outside the shipped contract. The registry, local provider, and model-facing tool do not parse, advertise, or enforce these fields. +Forked skill contexts (`context: fork`), parameter declarations and hints (`arguments` and `argument-hint`), and per-skill tool constraints (`allowed-tools` and `disallowed-tools`) are outside the shipped contract. The registry, local provider, and model-facing tool do not parse, advertise, or enforce these fields, and the `user-invocable` frontmatter field is likewise unparsed. Direct user invocation itself ships as a consumer-side affordance instead: the TUI front door offers a manual `/skill:` command over the registry's existing `list()` and `get()` methods, without a registry, provider, or tool contract change — see [the TUI skill slash command](2026-07-21-tui-skill-slash-command.md). diff --git a/.agents/notes/implemented/feature/2026-07-21-tui-skill-slash-command.i18n.yaml b/.agents/notes/implemented/feature/2026-07-21-tui-skill-slash-command.i18n.yaml new file mode 100644 index 0000000000..40dd8f463e --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-21-tui-skill-slash-command.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 +2026-07-21-tui-skill-slash-command.md: d7532a05fce5605491ce42c87a2a523eb4c19acc +2026-07-21-tui-skill-slash-command.zh.md: 16930020bd404f7bc9476169cd1d063aa57b5c94 diff --git a/.agents/notes/implemented/feature/2026-07-21-tui-skill-slash-command.md b/.agents/notes/implemented/feature/2026-07-21-tui-skill-slash-command.md new file mode 100644 index 0000000000..d7532a05fc --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-21-tui-skill-slash-command.md @@ -0,0 +1,33 @@ +# Agent Note: TUI skill slash command + +Status: implemented + +English | [中文](2026-07-21-tui-skill-slash-command.zh.md) + +## Problem + +The [skill system](2026-07-05-skill-system.md) shipped with model-initiated loading as its only path: the `skill({ name })` tool lets the model pull a skill body into a turn, but a person driving the TUI could not load a skill on demand. Other coding agents expose a `/skill:` slash command for exactly this — the user, not the model, decides a task matches a skill and injects its instructions. The skill-system note listed direct user invocation as deferred work, and the interactive front door is where it belongs. + +## Decision + +The [`@deepseek-ai/dsh-tui`](../../../../packages/ui/tui/README.md) front door owns a `/skill: [instructions]` command. On submit it loads the named skill and delivers one text block as a user turn — sent with `agent.send()` while idle and `agent.steer()` while running, the same rule as ordinary editor input. The block is `renderSkillInvocation(skill, instructions)`: a `` element wrapping the skill body, preceded by one resource-base line when the provider exposes one, with the user's trailing text appended after a blank line. The command is a TUI-only affordance; it adds no model-facing tool and changes no skill-system package contract. + +The TUI reads the skill service through `ctx.get('skills')`, not a declared injection, because skills mount conditionally: a deployment without the registry keeps a working front door, and `/skill:` there reports that skills are unavailable rather than failing to mount. `createTuiChat` is synchronous while `ctx.skills.list()` is async, so autocomplete seeds the static slash commands immediately and rebuilds the provider with `skill:` entries once the catalog resolves; a resolution that arrives after disposal is dropped, and a rejected lookup keeps the base commands. + +Autocomplete lists only model-invocable skills — it is built from `list()`, which omits `disableModelInvocation` skills — while manual submission resolves through `get()`, which the skill registry documents as the trusted-caller path that returns disabled skills too. So a person can load any skill by typing its exact name, but the completion menu never advertises a skill the model is meant not to see. An unknown name, an empty name after the prefix, and a lookup failure each surface as a transcript notice without sending anything. + +`renderSkillInvocation` and the resource-base line are the TUI's own, deliberately not reused from `dsh-tool-skill`'s `skill` tool result. The tool wraps a body in ``/``/`` for a *tool result*; a manual invocation is a *user turn*, and coupling the two renderers would force one model-facing shape to serve both surfaces. The cost is two renderers that both format a skill body; the benefit is that each surface's model-facing text evolves independently, and each is pinned where it is produced. + +## Alternatives considered + +**Add a `user-invocable` frontmatter field and enforce it in the registry.** Rejected for this change. The skill-system note defers that field, and manual invocation does not need it: the TUI is a trusted local caller, so `get()` already authorizes loading any skill, and autocomplete visibility keys off the existing `disableModelInvocation`. A new per-skill field would add a contract to the registry, local provider, and tool with no current consumer beyond visibility, which `disableModelInvocation` already covers. + +**Declare `skills` as a TUI injection.** Rejected because skills mount conditionally; a declared injection would make the front door require the registry and refuse to mount without it, contradicting the package's optional-service stance. `ctx.get('skills')` reads the global store and tolerates absence. + +**Reuse `dsh-tool-skill`'s renderer.** Rejected because its output is a tool-result shape (`` and siblings) written for the model's tool channel, while a slash invocation is a user message. Sharing it would either leak tool-result vocabulary into a user turn or fork the shared renderer on a `surface` flag — more coupling than two small formatters. + +**Route submissions through the model's `skill` tool.** Rejected because the user has already decided; a tool call would spend a model round-trip to fetch a body the front door can load directly, and would not work while the agent is mid-turn. + +## Consequences + +Manual invocation always reloads the full skill body: the TUI does not detect a skill already present in the conversation, so a repeated `/skill:` appends its instructions again — acceptable because re-injection is sometimes the intent, and documented under the package README's Known Limitations. The two-renderer duplication is a standing maintenance cost accepted above. The `` wrapper is stable model-visible text and is pinned verbatim in unit tests against a real `SkillService`; the help-panel line is pinned by the `errors-and-help` terminal snapshot. Autocomplete population and the disposed-lookup and failed-lookup branches are covered by unit tests that mount the real registry or a controllable service. End-to-end delivery is proven by a dedicated real-composition test: the `examples/tui-agent` keyless PTY smoke (`tui-keyless-smoke.e2e.ts`) boots the production TUI/agent/skill stack through the Loader under a genuine pseudo-terminal with only the model scripted, drops a fixture skill under the agents-home `skills/` root, types `/skill:` as live keystrokes, and asserts the scripted adapter echoes the fixture's body marker only when the rendered `` block arrives — exercising `ctx.get('skills')` resolution in the shipped tree, the client-side parse, the local provider load, and the user turn reaching the model together. That fixture's frontmatter description avoids a `: ` colon-space so its YAML stays a plain scalar; an invalid-frontmatter skill is silently dropped during discovery. diff --git a/.agents/notes/implemented/feature/2026-07-21-tui-skill-slash-command.zh.md b/.agents/notes/implemented/feature/2026-07-21-tui-skill-slash-command.zh.md new file mode 100644 index 0000000000..16930020bd --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-21-tui-skill-slash-command.zh.md @@ -0,0 +1,33 @@ +# Agent Note: TUI skill slash command + +Status: implemented + +[English](2026-07-21-tui-skill-slash-command.md) | 中文 + +## Problem + +[skill 系统](2026-07-05-skill-system.md)交付时只有模型发起加载这一条路径:`skill({ name })` 工具让模型把某个 skill 正文拉进一个轮次,但操作 TUI 的人无法按需加载 skill。其他编码 agent(智能体)正是为此提供了 `/skill:` 斜杠命令——由用户而非模型判断某个任务与某个 skill 匹配,并注入其指令。skill 系统 note 把直接的用户发起调用列为待办工作,而交互式前门正是它该落地的地方。 + +## Decision + +[`@deepseek-ai/dsh-tui`](../../../../packages/ui/tui/README.md) 前门拥有一条 `/skill: [instructions]` 命令。提交时它加载指定的 skill,并投递一个文本块作为用户轮次——空闲时用 `agent.send()` 发送、运行中用 `agent.steer()` 中途引导,与普通编辑器输入遵循同一规则。该文本块由 `renderSkillInvocation(skill, instructions)` 生成:一个包裹 skill 正文的 `` 元素,当提供方暴露资源基址时在其前加一行资源基址行,用户尾随的文本在空行之后追加。该命令是 TUI 独有的能力;它不新增任何面向模型的工具,也不改动任何 skill 系统包的契约。 + +TUI 通过 `ctx.get('skills')` 读取 skill 服务,而非声明式注入,因为 skill 是条件挂载的:没有注册表的部署仍保有可用的前门,此时 `/skill:` 会报告 skill 不可用,而不是挂载失败。`createTuiChat` 是同步的,而 `ctx.skills.list()` 是异步的,所以自动补全先立即种入静态斜杠命令,待目录解析完成后再用 `skill:` 条目重建 provider(提供方);在 dispose(资源释放)之后才到达的解析结果会被丢弃,而被拒绝的查找会保留基础命令。 + +自动补全只列出模型可调用的 skill——它基于 `list()` 构建,而 `list()` 会略去 `disableModelInvocation` 的 skill——手动提交则通过 `get()` 解析,skill 注册表将其记录为返回被禁用 skill 的可信调用方路径。因此用户可以通过键入 skill 的确切名称加载任意 skill,但补全菜单绝不会宣传一个本不该让模型看见的 skill。未知名称、前缀之后为空的名称、以及查找失败,都会各自呈现为 transcript(文本记录)中的一条通知,且不发送任何内容。 + +`renderSkillInvocation` 及资源基址行是 TUI 自有的,刻意不复用 `dsh-tool-skill` 的 `skill` 工具结果。该工具把正文包进 ``/``/`` 是为了一个*工具结果*;而手动调用是一个*用户轮次*,把两个渲染器耦合起来会迫使一种面向模型的形态同时服务两个界面。代价是两个都在格式化 skill 正文的渲染器;收益是各界面面向模型的文本可以独立演进,且各自在其产出处被固定。 + +## Alternatives considered + +**新增 `user-invocable` frontmatter 字段并在注册表中强制执行。** 本次改动否决。skill 系统 note 把该字段列为待办,而手动调用并不需要它:TUI 是可信的本地调用方,`get()` 已经授权加载任意 skill,自动补全的可见性以既有的 `disableModelInvocation` 为准。新增一个逐 skill 字段会给注册表、本地提供方和工具都加上一条契约,而除了可见性之外没有任何现有消费方,可见性又已由 `disableModelInvocation` 覆盖。 + +**把 `skills` 声明为 TUI 注入。** 否决,因为 skill 是条件挂载的;声明式注入会使前门必须依赖注册表,缺少它就拒绝挂载,与本包可选服务的立场相悖。`ctx.get('skills')` 读取全局存储并容忍其缺失。 + +**复用 `dsh-tool-skill` 的渲染器。** 否决,因为它的输出是为模型的工具通道所写的工具结果形态(`` 及其同类),而斜杠调用是一条用户消息。共用它要么把工具结果词汇泄漏进用户轮次,要么按 `surface` 标志分叉共享渲染器——比两个小格式化器耦合更重。 + +**让提交经由模型的 `skill` 工具。** 否决,因为用户已经作出了判断;一次工具调用会花掉一个模型往返去取一份前门可以直接加载的正文,而且在 agent 处于轮次中途时也无法工作。 + +## Consequences + +手动调用总是重新加载完整的 skill 正文:TUI 不会检测某个 skill 是否已在对话中出现,因此重复的 `/skill:` 会再次追加其指令——这可以接受,因为重新注入有时正是意图所在,且已在本包 README 的已知限制中说明。上文接受的双渲染器重复是一项长期维护成本。`` 包裹是稳定的、模型可见的文本,并在单元测试中针对一个真实的 `SkillService` 逐字固定;帮助面板那一行由 `errors-and-help` 终端快照固定。自动补全的填充、dispose 后查找分支、以及查找失败分支,都由挂载真实注册表或可控服务的单元测试覆盖。端到端的投递由一项专门的真实组合测试证明:`examples/tui-agent` 的无密钥 PTY 冒烟测试(`tui-keyless-smoke.e2e.ts`)在真实伪终端下经由 loader 引导生产环境的 TUI/agent/skill 栈,仅对模型进行脚本化,把一个夹具 skill 放入 agents home 的 `skills/` 根下,以真实按键输入 `/skill:`,并断言:只有当渲染出的 `` 文本块抵达时,脚本化适配器才会回显该夹具的正文标记——从而一并演练了 `ctx.get('skills')` 在发布树中的解析、客户端解析、本地 provider 的加载,以及用户回合抵达模型。该夹具的 frontmatter 描述避免出现 `: ` 冒号加空格,使其 YAML 保持为纯标量;frontmatter 无效的 skill 会在发现阶段被静默丢弃。 diff --git a/packages/ui/tui/tsconfig.json b/packages/ui/tui/tsconfig.json index b9aed2dbae..2d1bbb2477 100644 --- a/packages/ui/tui/tsconfig.json +++ b/packages/ui/tui/tsconfig.json @@ -23,6 +23,9 @@ { "path": "../../core/session" }, + { + "path": "../../session-persistence/session-persistence" + }, { "path": "../../session-title/session-title" }, @@ -41,6 +44,9 @@ { "path": "../commands" }, + { + "path": "../../skill/skill" + }, { "path": "../user-interaction" }, From d04ec5a6dbe12a7f2a8981be313bb23020e5a451 Mon Sep 17 00:00:00 2001 From: Turtle Date: Wed, 22 Jul 2026 10:55:20 +0800 Subject: [PATCH 41/90] feat(tui): restore the startup banner borderless, painted in the DeepSeek brand gradient Squashes feat/tui-borderless-banner and feat/tui-banner-brand-gradient. --- ...-07-21-tui-banner-brand-gradient.i18n.yaml | 6 +++ .../2026-07-21-tui-banner-brand-gradient.md | 39 +++++++++++++++++++ ...2026-07-21-tui-banner-brand-gradient.zh.md | 39 +++++++++++++++++++ ...2026-07-21-tui-borderless-banner.i18n.yaml | 6 +++ .../2026-07-21-tui-borderless-banner.md | 39 +++++++++++++++++++ .../2026-07-21-tui-borderless-banner.zh.md | 39 +++++++++++++++++++ .../2026-07-21-tui-no-banner.i18n.yaml | 4 +- .../feature/2026-07-21-tui-no-banner.md | 2 + .../feature/2026-07-21-tui-no-banner.zh.md | 2 + .../snapshots/banner-gradient.expected.txt | 29 ++++++++++++++ .../snapshots/resume-sessions.expected.txt | 32 ++++++++------- 11 files changed, 221 insertions(+), 16 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-07-21-tui-banner-brand-gradient.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-07-21-tui-banner-brand-gradient.md create mode 100644 .agents/notes/implemented/feature/2026-07-21-tui-banner-brand-gradient.zh.md create mode 100644 .agents/notes/implemented/feature/2026-07-21-tui-borderless-banner.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-07-21-tui-borderless-banner.md create mode 100644 .agents/notes/implemented/feature/2026-07-21-tui-borderless-banner.zh.md create mode 100644 packages/ui/tui/tests/snapshots/banner-gradient.expected.txt diff --git a/.agents/notes/implemented/feature/2026-07-21-tui-banner-brand-gradient.i18n.yaml b/.agents/notes/implemented/feature/2026-07-21-tui-banner-brand-gradient.i18n.yaml new file mode 100644 index 0000000000..684f23438c --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-21-tui-banner-brand-gradient.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 +2026-07-21-tui-banner-brand-gradient.md: 41edf5d0bcf856bc7695af6bf651ff04c11adc01 +2026-07-21-tui-banner-brand-gradient.zh.md: 9253c001e8df2a4d0f79f69f32d65c11afd13e22 diff --git a/.agents/notes/implemented/feature/2026-07-21-tui-banner-brand-gradient.md b/.agents/notes/implemented/feature/2026-07-21-tui-banner-brand-gradient.md new file mode 100644 index 0000000000..41edf5d0bc --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-21-tui-banner-brand-gradient.md @@ -0,0 +1,39 @@ +# Agent Note: TUI banner brand gradient + +Status: implemented + +English | [中文](2026-07-21-tui-banner-brand-gradient.zh.md) + +## Problem + +The TUI startup banner rendered the product name `DEEPSEEK` in the palette's flat accent color, which carries no brand identity and does not resemble the wordmark on deepseek.com. The request was to make the banner match the site logo's blue gradient specifically — not to recolor the rest of the coding harness. + +The banner is the one surface where that matters, and it conflicts with a load-bearing invariant: the TUI palette is deliberately theme-agnostic. It uses only standard 16-color ANSI (SGR) codes and attributes so a user's terminal scheme remaps every color; the `themeViolations()` snapshot gate rejects any RGB, extended-palette, or explicit-background cell. A smooth logo-matching gradient cannot be built from 16 palette colors, so reproducing it requires 24-bit truecolor, which the gate flags by design. + +## Decision + +The banner paints `DEEPSEEK` with a per-letter 24-bit truecolor foreground sweeping the deepseek.com brand gradient — `#4D6BFE` → `#3982FF` → `#2498FF` — via piecewise-linear interpolation across those three stops; `HARNESS` stays bold with the default foreground. The gradient is foreground-only, so it stays legible on any terminal background, and it is confined to the banner's product name. This is the sole sanctioned exception to the theme-agnostic palette; every other surface remains standard-ANSI and theme-adaptive. + +The gradient is gated on `resolved.color && resolved.truecolor`. When truecolor is unavailable the banner falls back to the existing flat bright-blue accent, so nothing about the theme-agnostic guarantee or the recorded snapshots changes unless truecolor is explicitly in play. + +`truecolor` is a validated `Config` field with no schema default. When it is unset, `apply()` auto-detects it at the process boundary from `COLORTERM` (`truecolor` or `24bit`); an explicit config value always wins. Detection reads `process.env` only in `apply()` — never in the pure `resolveTuiConfig` resolver — keeping the resolver a pure function of its input. + +The gradient stops are fixed brand identity, treated like a protocol constant, so they are hardcoded in the plugin rather than exposed as a tunable. Whether truecolor is *enabled* is terminal- and deployment-varying, so that is the validated `Config` field. The banner text is UI-only and never reaches a model request, so no session event is required. + +## Testing + +A dedicated `banner-gradient` terminal snapshot pins the real per-letter RGB output in an xterm emulator (`fg=#4d6bfe`…`#2498ff`, each letter bold). The shared `checkpoint()` helper takes a `bannerGradient` flag: for that one checkpoint it asserts the theme violations are non-empty and that every violation ends in `rgb-fg` — i.e. truecolor is present but confined to the banner foreground, with no background or extended-palette leak. Every other checkpoint keeps the strict `themeViolations()` `.toEqual([])` assertion, so the fence is mechanically enforced. A `tui.spec.ts` unit test mounts with `color`+`truecolor` enabled to cover the header's gradient branch and the `gradientText`/`brandColorAt` helpers. + +## Alternatives considered + +**A theme-safe stepped gradient built from the 16-color palette.** Approximating the sweep with bright-blue palette variants would keep the banner fully theme-agnostic and avoid touching the gate. It was rejected by the requester: 16 fixed colors cannot reproduce the smooth logo gradient, and the request was explicitly to match the site wordmark. + +**Recoloring the whole harness palette blue.** The original phrasing was "update the harness color to blue." That was narrowed to the banner only; a global blue palette would break theme-agnosticism everywhere, not just on one brand surface. + +**Always emitting truecolor.** Many terminals lack 24-bit support and would render the raw or degraded codes. Gating on detection with an ANSI fallback keeps the banner correct everywhere while still showing the gradient where it works. + +**Detecting truecolor inside `resolveTuiConfig`.** The resolver is a pure defaulting step and must not read `process.env`. Environment probing belongs at the process boundary in `apply()`, so `mountTui`/`createTuiChat` stay driven purely by their config input and remain fully testable with a fake terminal. + +## Consequences + +The banner now carries the DeepSeek brand identity on truecolor terminals while the theme-agnostic guarantee holds everywhere else — and even on the banner itself when truecolor is unavailable. The cost is one narrow, documented crack in the theme-agnostic invariant: a fixed-color surface that will not adapt to a user's terminal scheme, accepted because it is brand identity and foreground-only, so it stays legible on both light and dark backgrounds. The crack is fenced by the `banner-gradient` snapshot assertion, which confines truecolor to the banner foreground and fails if any other RGB, extended-palette, or background color ever appears. diff --git a/.agents/notes/implemented/feature/2026-07-21-tui-banner-brand-gradient.zh.md b/.agents/notes/implemented/feature/2026-07-21-tui-banner-brand-gradient.zh.md new file mode 100644 index 0000000000..9253c001e8 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-21-tui-banner-brand-gradient.zh.md @@ -0,0 +1,39 @@ +# Agent Note: TUI 启动横幅品牌渐变 + +Status: implemented + +[English](2026-07-21-tui-banner-brand-gradient.md) | 中文 + +## 问题 + +TUI 启动横幅原本用调色板的扁平强调色渲染产品名 `DEEPSEEK`,它不承载任何品牌标识,也不像 deepseek.com 上的字标。需求明确是让横幅匹配站点 logo 的蓝色渐变——而不是给整个 coding harness 重新上色。 + +横幅是唯一在意这件事的界面,而它与一条承重不变量冲突:TUI 调色板刻意做到主题无关。它只使用标准 16 色 ANSI(SGR)代码与属性,好让用户的终端配色方案能够重映射每一种颜色;`themeViolations()` 快照门禁会拒绝任何 RGB、扩展调色板或显式背景色的单元格。用 16 种调色板颜色无法拼出平滑的、与 logo 一致的渐变,因此复现它需要 24 位真彩色(truecolor),而门禁按设计会将其标记出来。 + +## 决策 + +横幅用逐字母的 24 位真彩色前景色渲染 `DEEPSEEK`,沿 deepseek.com 品牌渐变——`#4D6BFE` → `#3982FF` → `#2498FF`——在这三个色标之间做分段线性插值;`HARNESS` 保持加粗并使用默认前景色。渐变仅作用于前景色,因此在任何终端背景上都保持可读,并且被限制在横幅的产品名内。这是主题无关调色板唯一获准的例外;其余每个界面都保持标准 ANSI 且随主题自适应。 + +渐变以 `resolved.color && resolved.truecolor` 为开关。当真彩色不可用时,横幅回退到既有的扁平亮蓝强调色,因此除非显式启用真彩色,主题无关保证与已录制的快照都不会改变。 + +`truecolor` 是一个经校验的 `Config` 字段,schema 不设默认值。当它未设置时,`apply()` 会在进程边界从 `COLORTERM`(`truecolor` 或 `24bit`)自动探测;显式的配置值始终优先。探测只在 `apply()` 中读取 `process.env`——绝不在纯粹的 `resolveTuiConfig` 解析器中——从而让解析器保持为其输入的纯函数。 + +渐变色标是固定的品牌标识,被当作协议常量对待,因此硬编码在插件里,而不作为可调项暴露。是否*启用*真彩色则随终端与部署而变,所以那才是经校验的 `Config` 字段。横幅文本仅面向界面,永不进入任何模型请求,因此不需要会话事件。 + +## 测试 + +一个专门的 `banner-gradient` 终端快照在 xterm 模拟器中固定了真实的逐字母 RGB 输出(`fg=#4d6bfe`…`#2498ff`,每个字母加粗)。共享的 `checkpoint()` 辅助函数接受一个 `bannerGradient` 标志:仅对该 checkpoint,它断言主题违规项非空,且每一项都以 `rgb-fg` 结尾——即真彩色确实存在,但被限制在横幅前景色,没有背景色或扩展调色板的泄漏。其余每个 checkpoint 都保持严格的 `themeViolations()` `.toEqual([])` 断言,因此这道围栏是机械强制的。一个 `tui.spec.ts` 单元测试在同时启用 `color` 与 `truecolor` 时挂载,以覆盖 header 的渐变分支以及 `gradientText`/`brandColorAt` 辅助函数。 + +## 曾考虑的替代方案 + +**用 16 色调色板拼出的主题安全阶梯渐变。** 用亮蓝的调色板变体近似这段渐变可以让横幅完全保持主题无关,并避免触碰门禁。它被需求方否决了:16 种固定颜色无法复现平滑的 logo 渐变,而需求明确是匹配站点字标。 + +**给整个 harness 调色板重新上蓝色。** 最初的说法是"把 harness 颜色改成蓝色"。它被收窄到只改横幅;全局蓝色调色板会在各处而非仅一个品牌界面上破坏主题无关性。 + +**始终发射真彩色。** 许多终端不支持 24 位,会渲染出原始或降级的代码。以探测为开关并配以 ANSI 回退,能让横幅在各处都正确,同时仍在支持的地方展示渐变。 + +**在 `resolveTuiConfig` 内探测真彩色。** 该解析器是纯粹的默认值填充步骤,绝不能读取 `process.env`。环境探测属于 `apply()` 中的进程边界,从而让 `mountTui`/`createTuiChat` 完全由其配置输入驱动,并在使用假终端时保持完全可测。 + +## 后果 + +现在横幅会在真彩色终端上承载 DeepSeek 品牌标识,而主题无关保证在其余各处依然成立——甚至当真彩色不可用时在横幅自身上也成立。代价是主题无关不变量上一道狭窄且有记录的裂缝:一个不会随用户终端配色方案自适应的固定颜色界面,之所以接受,是因为它是品牌标识且仅作用于前景色,从而在浅色与深色背景上都保持可读。这道裂缝由 `banner-gradient` 快照断言把守,它将真彩色限制在横幅前景色,一旦其他任何 RGB、扩展调色板或背景色出现就会失败。 diff --git a/.agents/notes/implemented/feature/2026-07-21-tui-borderless-banner.i18n.yaml b/.agents/notes/implemented/feature/2026-07-21-tui-borderless-banner.i18n.yaml new file mode 100644 index 0000000000..8732101ab2 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-21-tui-borderless-banner.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 +2026-07-21-tui-borderless-banner.md: 37263854b6cc77283215c3c1378f9908ff966611 +2026-07-21-tui-borderless-banner.zh.md: ca796e49cb9d3a9abc0acd64a39448bc3f9ad50e diff --git a/.agents/notes/implemented/feature/2026-07-21-tui-borderless-banner.md b/.agents/notes/implemented/feature/2026-07-21-tui-borderless-banner.md new file mode 100644 index 0000000000..37263854b6 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-21-tui-borderless-banner.md @@ -0,0 +1,39 @@ +# Agent Note: The banner returns, borderless + +Status: implemented + +English | [中文](2026-07-21-tui-borderless-banner.zh.md) + +## Problem + +The [no-banner Agent Note](2026-07-21-tui-no-banner.md) removed the boxed startup banner: it deleted `HeaderComponent` and its sweep, moved the model into the footer, dropped the session id, and rendered `welcome` as the transcript's first line. The user's verdict reversed that: bring the banner back — "just remove the border". The four-row box frame was the objectionable chrome, not the identifying facts it carried (model, session id) nor the sweep-in motion. + +## Decision + +- `HeaderComponent` and its left-to-right sweep return, but render **borderless**: no `╭─╮`/`╰─╯` corners and no `│` side bars. Each line is a single leading space plus `truncateToWidth`-clipped content, so the sweep's width clip can never tear an escape sequence and no fixed frame is drawn. +- The header carries the title (`DEEPSEEK HARNESS`), a `` detail line, and — when `welcome` is set — a muted subtitle. With `welcome` unset the header is title + detail only. +- The model **also** stays in the footer's left segment. The no-banner note's footer model prefix is kept, not reverted, so the driving model stays glanceable after the transient banner scrolls out of view. +- `welcome` reverts to a banner subtitle; the transcript-first-line notice is removed from `rebuildTranscript`. +- The sweep animates only when `welcome` is unset. A configured `welcome` renders the whole banner immediately, keeping fixtures and snapshots frame-deterministic. The sweep starts after `ui.start()` succeeds and is cleared through the same `detachListeners` path via `stopBannerReveal`, which also resets the clip so a header disposed mid-sweep re-renders whole. + +This supersedes the [no-banner Agent Note](2026-07-21-tui-no-banner.md) (which superseded the [banner-sweep Agent Note](2026-07-21-tui-banner-sweep.md)): the banner and its sweep return borderless, while the model's footer home the no-banner note added stays. + +## Alternatives considered + +**Keep the box but thin it or use lighter glyphs.** Rejected: the instruction was "just remove the border"; any surrounding glyph is the frame chrome the user objected to. + +**Drop the model from the footer now that the banner shows it again.** Rejected: the banner is transient and scrolls away with the transcript, while the footer keeps the model visible for the whole session — the reason the no-banner note put it there, deliberately preserved. + +**Leave the session id out, as the no-banner note decided.** Rejected: with the box gone the detail line costs one row, and the user asked for the banner "as before", which carried `model • session-id`. + +## Consequences + +- Boot output with `welcome` unset is animation-dependent again (the sweep); configured welcomes stay frame-deterministic, so every snapshot and scripted fixture keeps a fixed subtitle. +- The model now appears twice at boot — banner detail and footer — intended redundancy: the banner is transient, the footer persistent. +- `/clear` empties the transcript but not the header, so the banner and its configured subtitle survive `/clear`, unlike the no-banner welcome line that `/clear` wiped. +- All pi-tui terminal snapshots and the examples/tui-agent replay snapshots re-recorded (`test:snapshot:refresh`): banner rows return with no box glyphs; footer rows keep the model prefix. +- Anything that anchored on banner absence re-anchors on its presence: the PTY smoke boots on the detail line's `main-session-` id (revealed late in the sweep) and asserts `DEEPSEEK`/`HARNESS` present with no box corners. + +## Testing + +`packages/ui/tui/tests/tui.spec.ts` pins: the borderless banner sweeps to natural completion — no box corners, title and `main-session` detail present — with at least one clipped mid-sweep frame; a configured `welcome` renders the whole banner with no clipped frame; the unset-welcome banner has no subtitle; and dispose clears the sweep interval mid-sweep. The tui-agent and dsh-CLI PTY smokes boot on the `main-session-` detail marker and assert no box corners. Snapshots verify the full frames. diff --git a/.agents/notes/implemented/feature/2026-07-21-tui-borderless-banner.zh.md b/.agents/notes/implemented/feature/2026-07-21-tui-borderless-banner.zh.md new file mode 100644 index 0000000000..ca796e49cb --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-21-tui-borderless-banner.zh.md @@ -0,0 +1,39 @@ +# Agent Note: 横幅回归,无边框 + +Status: implemented + +[English](2026-07-21-tui-borderless-banner.md) | 中文 + +## Problem + +[移除横幅 Agent Note](2026-07-21-tui-no-banner.md) 删掉了带框的启动横幅:它删除了 `HeaderComponent` 及其扫入动画,把模型移入页脚,丢弃了会话 id,并把 `welcome` 渲染为 transcript 的第一行。用户的裁决把这一切反转:把横幅拿回来——"just remove the border"。令人反感的装饰是那四行盒子边框,而不是它承载的识别信息(模型、会话 id),也不是扫入动效。 + +## Decision + +- `HeaderComponent` 及其从左到右的扫入动画回归,但以**无边框**方式渲染:没有 `╭─╮`/`╰─╯` 边角,也没有 `│` 侧边。每一行都是一个前导空格加上经 `truncateToWidth` 裁剪的内容,因此扫入的宽度裁剪永远不会撕裂转义序列,也不绘制任何固定边框。 +- 头部承载标题(`DEEPSEEK HARNESS`)、一条 `` 详情行,以及——当设置了 `welcome` 时——一条弱化的副标题。`welcome` 未设置时头部只有标题加详情。 +- 模型**同时**保留在页脚的左段。移除横幅那版 note 加入的页脚模型前缀被保留而非回退,因此在短暂的横幅滚出视野后,会话使用的模型仍可一瞥可见。 +- `welcome` 恢复为横幅副标题;transcript 第一行的通知从 `rebuildTranscript` 中移除。 +- 仅当 `welcome` 未设置时才播放扫入动画。配置了 `welcome` 会立即渲染整个横幅,使 fixture 和快照保持帧确定性。扫入在 `ui.start()` 成功后启动,并经与之前相同的 `detachListeners` 路径通过 `stopBannerReveal` 清理;后者还会重置裁剪,使扫入中途被销毁的头部重新完整渲染。 + +本 note 取代[移除横幅 Agent Note](2026-07-21-tui-no-banner.md)(后者取代了[横幅扫入 Agent Note](2026-07-21-tui-banner-sweep.md)):横幅及其扫入动画以无边框方式回归,而移除横幅那版 note 为模型设立的页脚归宿得以保留。 + +## Alternatives considered + +**保留盒子但做细或改用更轻的字符。** 否决:指令是 "just remove the border";任何环绕的字符都是用户所反对的边框装饰。 + +**既然横幅重新显示模型,就把模型从页脚移除。** 否决:横幅是短暂的,会随 transcript 滚走,而页脚在整个会话中保持模型可见——这正是移除横幅那版 note 把它放在那里的原因,此处刻意保留。 + +**像移除横幅那版 note 那样,把会话 id 留在外面。** 否决:盒子去掉后详情行只占一行,且用户要求横幅"和以前一样",而以前它承载 `model • session-id`。 + +## Consequences + +- `welcome` 未设置时的启动输出再次依赖动画(扫入);配置了欢迎语则保持帧确定性,因此每个快照和脚本 fixture 都保留一个固定副标题。 +- 模型现在在启动时出现两次——横幅详情与页脚——这是有意的冗余:横幅短暂,页脚常驻。 +- `/clear` 清空 transcript 但不清头部,因此横幅及其配置的副标题在 `/clear` 后存活,不同于被 `/clear` 清掉的移除横幅那版的欢迎行。 +- 全部 pi-tui 终端快照与 examples/tui-agent 回放快照重新录制(`test:snapshot:refresh`):横幅行以无盒子字符方式回归;页脚行保留模型前缀。 +- 一切锚定横幅缺失的内容改为锚定其存在:PTY 冒烟测试以详情行的 `main-session-` id 为启动标记(它在扫入后段才被揭示),并断言 `DEEPSEEK`/`HARNESS` 出现且无盒子角。 + +## Testing + +`packages/ui/tui/tests/tui.spec.ts` 固定:无边框横幅扫入至自然完成——无盒子角、标题与 `main-session` 详情出现——且至少有一帧扫入中途被裁剪;配置的 `welcome` 完整渲染横幅且无裁剪帧;未设置 `welcome` 的横幅无副标题;销毁会在扫入中途清掉扫入定时器。tui-agent 与 dsh CLI 的 PTY 冒烟测试以 `main-session-` 详情标记为启动标记并断言无盒子角。快照验证完整帧。 diff --git a/.agents/notes/implemented/feature/2026-07-21-tui-no-banner.i18n.yaml b/.agents/notes/implemented/feature/2026-07-21-tui-no-banner.i18n.yaml index e9ef3a7671..56333563f5 100644 --- a/.agents/notes/implemented/feature/2026-07-21-tui-no-banner.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-21-tui-no-banner.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 -2026-07-21-tui-no-banner.md: 284d02df4b1fe3445a76b030aa873164081aa571 -2026-07-21-tui-no-banner.zh.md: 83bf7ffa27ca1abef0e71a1847b5aa1bd54bbe87 +2026-07-21-tui-no-banner.md: f5f4b1b847740e741ec3e33a6116e7497e955bd1 +2026-07-21-tui-no-banner.zh.md: 956fe03e2c0b09ea7378ffd53ffbe8d712d1e152 diff --git a/.agents/notes/implemented/feature/2026-07-21-tui-no-banner.md b/.agents/notes/implemented/feature/2026-07-21-tui-no-banner.md index 284d02df4b..f5f4b1b847 100644 --- a/.agents/notes/implemented/feature/2026-07-21-tui-no-banner.md +++ b/.agents/notes/implemented/feature/2026-07-21-tui-no-banner.md @@ -4,6 +4,8 @@ Status: implemented English | [中文](2026-07-21-tui-no-banner.zh.md) +> **Superseded** by the [borderless-banner Agent Note](2026-07-21-tui-borderless-banner.md): the banner and its sweep return without the box. The model's footer home this note added stays. + ## Problem The TUI opened with a boxed product banner ("DEEPSEEK HARNESS" + model/session detail), most recently with a sweep-in animation ([banner sweep Agent Note](2026-07-21-tui-banner-sweep.md)). The user's verdict: remove it. A product title re-read on every boot is chrome, the box spends four rows before any content, and the identifying facts it carried (model, session) have better homes. diff --git a/.agents/notes/implemented/feature/2026-07-21-tui-no-banner.zh.md b/.agents/notes/implemented/feature/2026-07-21-tui-no-banner.zh.md index 83bf7ffa27..956fe03e2c 100644 --- a/.agents/notes/implemented/feature/2026-07-21-tui-no-banner.zh.md +++ b/.agents/notes/implemented/feature/2026-07-21-tui-no-banner.zh.md @@ -4,6 +4,8 @@ Status: implemented [English](2026-07-21-tui-no-banner.md) | 中文 +> **已被取代**,见[无边框横幅 Agent Note](2026-07-21-tui-borderless-banner.md):横幅及其扫入动画回归,只是去掉了盒子。本 note 为模型设立的页脚归宿得以保留。 + ## Problem TUI 启动时展示一个带框的产品横幅("DEEPSEEK HARNESS" + 模型/会话详情),最近一版还带扫入动画([横幅扫入 Agent Note](2026-07-21-tui-banner-sweep.md))。用户的裁决:删掉它。每次启动都被重读的产品标题是装饰,盒子在任何内容之前先占掉四行,而它承载的识别信息(模型、会话)有更好的去处。 diff --git a/packages/ui/tui/tests/snapshots/banner-gradient.expected.txt b/packages/ui/tui/tests/snapshots/banner-gradient.expected.txt new file mode 100644 index 0000000000..312b7737dd --- /dev/null +++ b/packages/ui/tui/tests/snapshots/banner-gradient.expected.txt @@ -0,0 +1,29 @@ +terminal 96x36 buffer=normal length=36 base=0 viewport=0 +lifecycle started=1 stopped=0 progress=inactive +title "DSH snapshot" +cursor hidden column=1 viewportRow=4 bufferRow=4 +viewport +0| " DEEPSEEK HARNESS" + style 1-1 fg=#4d6bfe bold + style 2-2 fg=#4772fe bold + style 3-3 fg=#4278ff bold + style 4-4 fg=#3c7fff bold + style 5-5 fg=#3685ff bold + style 6-6 fg=#308bff bold + style 7-7 fg=#2a92ff bold + style 8-8 fg=#2498ff bold + style 10-16 bold +1| " Snapshot agent ready." + style 1-21 fg=bright-black +2| " deepseek-v4-flash • main-session" + style 1-34 dim +3| "────────────────────────────────────────────────────────────────────────────────────────────────" + style 0-95 dim +4| " " + style 1-1 inverse +5| "────────────────────────────────────────────────────────────────────────────────────────────────" + style 0-95 dim +6| "deepseek-v4-flash /workspace/project ↑0 ↓0 tools:collapsed" + style 0-43 dim + style 81-95 dim +7-35| diff --git a/packages/ui/tui/tests/snapshots/resume-sessions.expected.txt b/packages/ui/tui/tests/snapshots/resume-sessions.expected.txt index 6e44fa8165..ffa82ce4b9 100644 --- a/packages/ui/tui/tests/snapshots/resume-sessions.expected.txt +++ b/packages/ui/tui/tests/snapshots/resume-sessions.expected.txt @@ -1,28 +1,32 @@ terminal 92x32 buffer=normal length=32 base=0 viewport=0 lifecycle started=1 stopped=0 progress=inactive title "DSH snapshot" -cursor hidden column=1 viewportRow=9 bufferRow=9 +cursor hidden column=1 viewportRow=10 bufferRow=10 buffer -0| -1| " Snapshot agent ready. " +0| " DEEPSEEK HARNESS" + style 1-8 fg=bright-blue bold + style 10-16 bold +1| " Snapshot agent ready." style 1-21 fg=bright-black -2| -3| " Resumable sessions " +2| " deepseek-v4-flash • main-session" + style 1-34 dim +3| +4| " Resumable sessions " style 1-18 fg=bright-blue bold -4| " 2024-01-02 03:04 (current) " +5| " 2024-01-02 03:04 (current) " style 1-16 fg=bright-black style 17-26 fg=green -5| " RESUME_SESSION_ID=main-session dsh " -6| " 2024-01-01 00:00 " +6| " RESUME_SESSION_ID=main-session dsh " +7| " 2024-01-01 00:00 " style 1-16 fg=bright-black -7| " RESUME_SESSION_ID=earlier-session dsh " -8| "────────────────────────────────────────────────────────────────────────────────────────────" +8| " RESUME_SESSION_ID=earlier-session dsh " +9| "────────────────────────────────────────────────────────────────────────────────────────────" style 0-91 dim -9| " " +10| " " style 1-1 inverse -10| "────────────────────────────────────────────────────────────────────────────────────────────" +11| "────────────────────────────────────────────────────────────────────────────────────────────" style 0-91 dim -11| "deepseek-v4-flash /workspace/project ↑0 ↓0 tools:collapsed" +12| "deepseek-v4-flash /workspace/project ↑0 ↓0 tools:collapsed" style 0-43 dim style 77-91 dim -12-31| +13-31| From 8995ce367f410e43fa639c871543f2e3eb705433 Mon Sep 17 00:00:00 2001 From: Turtle Date: Wed, 22 Jul 2026 10:55:20 +0800 Subject: [PATCH 42/90] feat(tui): remove the /cancel slash command --- ...-07-21-tui-remove-cancel-command.i18n.yaml | 6 +++++ .../2026-07-21-tui-remove-cancel-command.md | 25 +++++++++++++++++++ ...2026-07-21-tui-remove-cancel-command.zh.md | 25 +++++++++++++++++++ 3 files changed, 56 insertions(+) create mode 100644 .agents/notes/implemented/simplification/2026-07-21-tui-remove-cancel-command.i18n.yaml create mode 100644 .agents/notes/implemented/simplification/2026-07-21-tui-remove-cancel-command.md create mode 100644 .agents/notes/implemented/simplification/2026-07-21-tui-remove-cancel-command.zh.md diff --git a/.agents/notes/implemented/simplification/2026-07-21-tui-remove-cancel-command.i18n.yaml b/.agents/notes/implemented/simplification/2026-07-21-tui-remove-cancel-command.i18n.yaml new file mode 100644 index 0000000000..62bf9574c0 --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-07-21-tui-remove-cancel-command.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 +2026-07-21-tui-remove-cancel-command.md: f9bad74e7b8f04a162a32e8045d2f874991b9d5d +2026-07-21-tui-remove-cancel-command.zh.md: 6a4c0af1d2ac345afd775566db21f7a7c0b262da diff --git a/.agents/notes/implemented/simplification/2026-07-21-tui-remove-cancel-command.md b/.agents/notes/implemented/simplification/2026-07-21-tui-remove-cancel-command.md new file mode 100644 index 0000000000..f9bad74e7b --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-07-21-tui-remove-cancel-command.md @@ -0,0 +1,25 @@ +# Agent Note: Drop the TUI `/cancel` slash command + +Status: implemented + +English | [中文](2026-07-21-tui-remove-cancel-command.zh.md) + +## Problem + +The TUI exposed two identical ways to cancel a running turn: the `Esc` (and `Ctrl+C`) keybinding and a `/cancel` slash command. Both called `agent.cancel('cancelled from terminal')` with the same reason; when idle, `/cancel` only printed a "The agent is already idle." notice while the keybindings stayed silent. The running status line already advertises the keybinding (`Enter sends steering, Esc cancels`), and cancelling by keystroke needs no editor submission, so the slash command was a second, less discoverable path to the same effect — surface area with no behavior of its own. + +## Decision + +`/cancel` is removed. Cancelling a running turn is a keybinding-only affordance (`Esc`, or `Ctrl+C` while running), which the status-line hint and the `/help` shortcut list already document. The `baseCommands` autocomplete entry, the `/help` command line, the `case '/cancel'` branch in the editor submit handler, and the "already idle" notice it owned are gone; every other slash command (`/help`, `/clear`, `/reasoning`, `/tools`, `/redraw`, `/reload`, `/resume`, `/exit`, `/skill:`) is unchanged. Typing `/cancel` now falls through to the generic `Unknown command:` warning like any other unrecognized slash input. + +## Alternatives considered + +**Keep `/cancel` as a discoverability alias** — rejected: the running status line and `/help` both name `Esc`, so a typed alias adds a maintained code path and a per-idle-state notice for an action a single keystroke already performs more directly. No consumer needed the editor-submission route to cancellation. + +## Testing + +`packages/ui/tui/tests/tui.spec.ts` asserts `agent.cancelled` contains `'cancelled from terminal'`, driven by the `Esc`/`Ctrl+C` keystrokes in that turn — the sole cancel affordance. The `errors-and-help` and `disposed-terminal` snapshots pin the `/help` line without `/cancel`; per-file coverage on `packages/ui/tui/src` stays at 100%. + +## Consequences + +There is no way to cancel a turn by editor submission; cancellation is keybinding-only. This is a net removal of a redundant path and its idle-state notice, matching the single-primitive shape the rest of the stop surface already follows ([public stop surface](2026-06-20-public-agent-stop-surface.md)). Restoring a typed cancel would return with the autocomplete entry, the submit-handler branch, and its own test. diff --git a/.agents/notes/implemented/simplification/2026-07-21-tui-remove-cancel-command.zh.md b/.agents/notes/implemented/simplification/2026-07-21-tui-remove-cancel-command.zh.md new file mode 100644 index 0000000000..6a4c0af1d2 --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-07-21-tui-remove-cancel-command.zh.md @@ -0,0 +1,25 @@ +# Agent Note: Drop the TUI `/cancel` slash command + +Status: implemented + +[English](2026-07-21-tui-remove-cancel-command.md) | 中文 + +## Problem + +TUI 提供了两条完全相同的取消运行中轮次的方式:`Esc`(以及 `Ctrl+C`)键位绑定,和一条 `/cancel` 斜杠命令。两者都以相同的原因调用 `agent.cancel('cancelled from terminal')`;空闲时 `/cancel` 只打印一条 "The agent is already idle." 通知,而键位绑定保持静默。运行状态行本就标示了该键位绑定(`Enter sends steering, Esc cancels`),且按键取消无需提交编辑器,因此这条斜杠命令只是通往同一效果的第二条、且更难被发现的路径——一块本身不含任何行为的界面。 + +## Decision + +`/cancel` 已移除。取消运行中的轮次是一项仅由键位绑定提供的能力(`Esc`,或运行中的 `Ctrl+C`),状态行提示与 `/help` 快捷键清单已对其作出说明。`baseCommands` 自动补全条目、`/help` 命令行、编辑器提交处理函数中的 `case '/cancel'` 分支,以及它拥有的 "already idle" 通知都已删去;其余每一条斜杠命令(`/help`、`/clear`、`/reasoning`、`/tools`、`/redraw`、`/reload`、`/resume`、`/exit`、`/skill:`)保持不变。键入 `/cancel` 会像任何其他无法识别的斜杠输入一样,落入通用的 `Unknown command:` 警告。 + +## Alternatives considered + +**保留 `/cancel` 作为便于发现的别名。** 否决:运行状态行与 `/help` 都已标示 `Esc`,因此一个键入式别名会为一项单个按键已能更直接完成的操作,增加一条需维护的代码路径和一条逐空闲状态的通知。没有任何消费方需要经由编辑器提交来触发取消。 + +## Testing + +`packages/ui/tui/tests/tui.spec.ts` 断言 `agent.cancelled` 包含 `'cancelled from terminal'`,由该轮次中的 `Esc`/`Ctrl+C` 按键驱动——这是唯一的取消能力。`errors-and-help` 与 `disposed-terminal` 快照固定了不含 `/cancel` 的 `/help` 行;`packages/ui/tui/src` 的逐文件覆盖率维持在 100%。 + +## Consequences + +无法再经由编辑器提交取消一个轮次;取消仅由键位绑定提供。这是对一条冗余路径及其空闲状态通知的净移除,与其余停止能力已遵循的单一原语形态一致([public stop surface](2026-06-20-public-agent-stop-surface.md))。若要恢复键入式取消,需连同自动补全条目、提交处理函数分支及其专属测试一并回归。 From 717f301cddc8cadca7694307984009256f853c75 Mon Sep 17 00:00:00 2001 From: Turtle Date: Wed, 22 Jul 2026 10:55:20 +0800 Subject: [PATCH 43/90] feat: curl one-liner install script for dsh Squashes feat/install-script, feat/install-default-master, and install-skip-clone: DSH_REF defaults to master, and running the script from inside an existing checkout reuses it and skips the clone. --- ...-22-installer-in-repo-skip-clone.i18n.yaml | 6 + ...2026-07-22-installer-in-repo-skip-clone.md | 27 ++ ...6-07-22-installer-in-repo-skip-clone.zh.md | 27 ++ scripts/install.sh | 284 ++++++++++++++++++ 4 files changed, 344 insertions(+) create mode 100644 .agents/notes/implemented/process/2026-07-22-installer-in-repo-skip-clone.i18n.yaml create mode 100644 .agents/notes/implemented/process/2026-07-22-installer-in-repo-skip-clone.md create mode 100644 .agents/notes/implemented/process/2026-07-22-installer-in-repo-skip-clone.zh.md create mode 100755 scripts/install.sh diff --git a/.agents/notes/implemented/process/2026-07-22-installer-in-repo-skip-clone.i18n.yaml b/.agents/notes/implemented/process/2026-07-22-installer-in-repo-skip-clone.i18n.yaml new file mode 100644 index 0000000000..a6becde554 --- /dev/null +++ b/.agents/notes/implemented/process/2026-07-22-installer-in-repo-skip-clone.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 +2026-07-22-installer-in-repo-skip-clone.md: f63c438205f7bd6aeb8dd78941bbe0880a8e31a1 +2026-07-22-installer-in-repo-skip-clone.zh.md: f9fe4865ad1090211c094fc8fba843b623512cc9 diff --git a/.agents/notes/implemented/process/2026-07-22-installer-in-repo-skip-clone.md b/.agents/notes/implemented/process/2026-07-22-installer-in-repo-skip-clone.md new file mode 100644 index 0000000000..f63c438205 --- /dev/null +++ b/.agents/notes/implemented/process/2026-07-22-installer-in-repo-skip-clone.md @@ -0,0 +1,27 @@ +# Agent Note: installer skips the clone when run from inside a checkout + +Status: implemented + +English | [中文](2026-07-22-installer-in-repo-skip-clone.zh.md) + +## Problem + +`scripts/install.sh` is written for the `curl ... | sh` path: it clones the harness into `~/.dsh/source`, then installs, links, and launches. Contributors who already have a checkout and run the same script directly (`sh scripts/install.sh`) got a second, unrelated clone at `~/.dsh/source` — installing and linking a different tree than the one they were working in, with no way to exercise the local script against the local source. + +## Decision + +The script detects when it is executing from inside a real checkout and, in that mode, reuses that checkout and skips the clone/update step entirely, leaving the working tree untouched. + +Detection keys on `$0`: under `curl ... | sh` the script text arrives on stdin, so `$0` is the shell name and no file path resolves; running a checked-out copy makes `$0` the script file. When `$0` is a readable file whose parent is a `scripts/` directory inside a tree that carries both the `bin/dsh` launcher and `scripts/install.sh`, the script sets `IN_REPO=1` and repoints `DSH_SOURCE` at that repo root. Step 2 then prints a "using existing checkout" line and does nothing else — no `git fetch`, no `git checkout -B`, so the user's working tree and branch are never mutated. `DSH_REF` is advisory and ignored in this mode. + +Explicit `DSH_SOURCE` wins over detection: the value is captured before defaulting, and in-repo detection only repoints an unset `DSH_SOURCE` (or one already equal to the detected repo root). Setting `DSH_SOURCE` to a different directory opts back into the normal clone/update path, so the escape hatch to install a separate tree from within a checkout still exists. + +## Alternatives considered + +**Detect via `git rev-parse --show-toplevel` on the current directory.** Rejected: `curl ... | sh` frequently runs from inside some unrelated git repo (the user's `cwd`), which would false-positive and skip the clone against a tree that is not dsh. Anchoring on `$0`'s own location ties the decision to where the script physically lives, and the `bin/dsh` + `scripts/install.sh` markers confirm it is actually a dsh checkout. + +**Always skip the clone whenever run from a file, ignoring `DSH_SOURCE`.** Rejected: a contributor may legitimately run the in-repo script to provision a separate `~/.dsh/source` install; honoring an explicit `DSH_SOURCE` that differs from the checkout preserves that path. + +## Consequences + +Running `sh scripts/install.sh` from a checkout now installs, links, and launches that checkout instead of cloning a parallel one, which also makes the local script testable against local source. The cost is a detection block that couples to the repo layout (`scripts/` beside `bin/dsh`); if the launcher or script ever moves, the markers must move with it. The behavior is documented in the script header and both README files, and verified by running the four paths (in-repo skip, curl-style clone, explicit `DSH_SOURCE` elsewhere opting back in, explicit `DSH_SOURCE` equal to repo root still skipping). diff --git a/.agents/notes/implemented/process/2026-07-22-installer-in-repo-skip-clone.zh.md b/.agents/notes/implemented/process/2026-07-22-installer-in-repo-skip-clone.zh.md new file mode 100644 index 0000000000..f9fe4865ad --- /dev/null +++ b/.agents/notes/implemented/process/2026-07-22-installer-in-repo-skip-clone.zh.md @@ -0,0 +1,27 @@ +# Agent Note: 在检出目录内运行时安装脚本跳过克隆 + +Status: implemented + +[English](2026-07-22-installer-in-repo-skip-clone.md) | 中文 + +## 问题 + +`scripts/install.sh`是为`curl ... | sh`路径编写的:它把 harness 克隆到`~/.dsh/source`,然后安装、软链接并启动。已经有检出的贡献者若直接运行同一脚本(`sh scripts/install.sh`),会在`~/.dsh/source`得到第二份无关的克隆——安装并软链接的是与他们正在工作的树不同的另一棵树,且无从用本地脚本验证本地源码。 + +## 决策 + +脚本会检测自身是否在真实检出内执行;在该模式下,它复用该检出并完全跳过克隆/更新步骤,保持工作树不受影响。 + +检测依据是`$0`:在`curl ... | sh`下脚本文本经由 stdin 到达,因此`$0`是 shell 名称、无路径可解析;运行已检出的副本会使`$0`成为脚本文件本身。当`$0`是一个可读文件、其父目录是一个`scripts/`目录、且该树同时带有`bin/dsh`启动器和`scripts/install.sh`时,脚本会设置`IN_REPO=1`并把`DSH_SOURCE`重新指向该仓库根。步骤 2 随后打印一行"using existing checkout"并不做其他事——不执行`git fetch`、不执行`git checkout -B`,因此用户的工作树和分支绝不会被改动。`DSH_REF`在该模式下仅供参考、被忽略。 + +显式的`DSH_SOURCE`优先于检测:该值在默认化之前就被捕获,检测只会重新指向未设置的`DSH_SOURCE`(或已经等于检测到的仓库根的那个)。把`DSH_SOURCE`设为其他目录会重新回到正常的克隆/更新路径,因此在检出目录内安装另一棵独立树的退路依然存在。 + +## 备选方案 + +**通过对当前目录执行`git rev-parse --show-toplevel`来检测。** 已否决:`curl ... | sh`常常在某个无关的 git 仓库(用户的`cwd`)内运行,这会误判并对一棵并非 dsh 的树跳过克隆。把决策锚定在`$0`自身的位置,使其绑定到脚本实际所在之处,而`bin/dsh` + `scripts/install.sh`标记则确认它确实是一个 dsh 检出。 + +**只要从文件运行就总是跳过克隆,忽略`DSH_SOURCE`。** 已否决:贡献者可能合理地运行检出内脚本来配置一份独立的`~/.dsh/source`安装;尊重与检出不同的显式`DSH_SOURCE`保留了该路径。 + +## 影响 + +现在从检出目录运行`sh scripts/install.sh`会安装、软链接并启动该检出,而不是克隆一份平行副本,这也让本地脚本可以针对本地源码进行测试。代价是一段与仓库布局耦合的检测逻辑(`scripts/`与`bin/dsh`并列);若启动器或脚本将来移动,标记必须随之移动。该行为记录在脚本头部和两份 README 中,并通过运行四条路径来验证(检出内跳过、curl 式克隆、显式`DSH_SOURCE`指向他处而回到克隆、显式`DSH_SOURCE`等于仓库根仍跳过)。 diff --git a/scripts/install.sh b/scripts/install.sh new file mode 100755 index 0000000000..d54c52bad3 --- /dev/null +++ b/scripts/install.sh @@ -0,0 +1,284 @@ +#!/bin/sh +# dsh one-line installer. +# +# curl -fsSL https://raw.githubusercontent.com/deepseek-harness/deepseek-harness/master/scripts/install.sh | sh +# +# It clones the harness to ~/.dsh/source, checks host dependencies (git, Node, +# pnpm) and offers to install a missing pnpm, runs `pnpm install` (no build — +# the `bin/dsh` launcher runs the TypeScript source through the repo's own tsx), +# symlinks `dsh` onto PATH, records your API credentials in the personal config +# dir dsh reads at boot, and drops you into `dsh`. +# +# When run from inside an existing checkout (e.g. `sh scripts/install.sh` rather +# than `curl ... | sh`) it reuses that checkout and skips the clone/update, leaving +# the working tree untouched; DSH_REF is ignored in that mode. Setting DSH_SOURCE +# to a different directory opts back into the normal clone/update path. +# +# When run through `curl | sh` the script text arrives on stdin, so every +# prompt and the final launch read the controlling terminal (/dev/tty) directly; +# with no terminal the script prints the manual next steps instead. +# +# Overridable via environment: +# DSH_REF branch or tag to clone/checkout (default: master) +# DSH_REPO clone URL (default: the GitHub repo) +# DSH_SOURCE checkout location (default: ~/.dsh/source) +# DSH_BIN_DIR directory the `dsh` symlink lands in (default: ~/.local/bin) +# DSH_CONFIG_HOME personal config dir (also XDG_CONFIG_HOME/dsh, ~/.config/dsh) +set -eu + +DSH_REF=${DSH_REF:-master} +DSH_REPO=${DSH_REPO:-https://github.com/deepseek-harness/deepseek-harness.git} +# Remember whether the caller pinned a source location before defaulting it, so +# in-repo detection only repoints an unset DSH_SOURCE. +if [ -n "${DSH_SOURCE:-}" ]; then DSH_SOURCE_EXPLICIT=1; else DSH_SOURCE_EXPLICIT=0; fi +DSH_SOURCE=${DSH_SOURCE:-$HOME/.dsh/source} +DSH_BIN_DIR=${DSH_BIN_DIR:-$HOME/.local/bin} + +# --- in-repo detection --------------------------------------------------------- +# Under `curl ... | sh` the script text arrives on stdin, so $0 is the shell +# name and no file path resolves; running a checked-out copy (`sh +# scripts/install.sh`) makes $0 the script file. When $0 is a readable file whose +# parent is a scripts/ dir inside a real dsh checkout (bin/dsh launcher present), +# reuse that checkout and skip the clone. An explicit DSH_SOURCE pointing +# elsewhere opts back into the clone/update path. +IN_REPO=0 +if [ -f "$0" ]; then + _self_dir=$(CDPATH= cd -- "$(dirname -- "$0")" 2>/dev/null && pwd -P) || _self_dir='' + if [ -n "$_self_dir" ]; then + _repo_root=$(dirname -- "$_self_dir") + if [ "$(basename -- "$_self_dir")" = scripts ] \ + && [ -x "$_repo_root/bin/dsh" ] && [ -f "$_repo_root/scripts/install.sh" ]; then + if [ "$DSH_SOURCE_EXPLICIT" = 0 ] || [ "$DSH_SOURCE" = "$_repo_root" ]; then + IN_REPO=1 + DSH_SOURCE=$_repo_root + fi + fi + fi +fi + +# --- terminal-aware prompting -------------------------------------------------- +# stdin is the piped script, so read the controlling terminal for input. +if { true /dev/null; then + HAS_TTY=1 + # Restore terminal echo on exit or interrupt: ask_secret disables echo between + # its stty toggles, and dash (a common `sh`) does not run an EXIT trap when the + # shell is killed by a signal, so the fatal signals need their own handler. A + # successful run ends in exec, which replaces this process and drops the traps. + trap 'stty echo /dev/null || true' EXIT + trap 'stty echo /dev/null || true; exit 130' INT TERM HUP +else + HAS_TTY=0 +fi + +# Colour only when writing to a terminal. +if [ -t 1 ]; then + B=$(printf '\033[1m'); DIM=$(printf '\033[2m'); RED=$(printf '\033[31m') + GRN=$(printf '\033[32m'); YEL=$(printf '\033[33m'); RST=$(printf '\033[0m') +else + B=''; DIM=''; RED=''; GRN=''; YEL=''; RST='' +fi + +info() { printf '%s==>%s %s\n' "$GRN" "$RST" "$1"; } +step() { printf '\n%s==>%s %s%s%s\n' "$GRN" "$RST" "$B" "$1" "$RST"; } +warn() { printf '%s warn%s %s\n' "$YEL" "$RST" "$1" >&2; } +die() { printf '%serror%s %s\n' "$RED" "$RST" "$1" >&2; exit 1; } + +# ask PROMPT [DEFAULT] -> answer on stdout (plain-text line). +ask() { + [ "$HAS_TTY" = 1 ] || die "no terminal available for input; re-run in an interactive shell" + printf '%s%s%s ' "$B" "$1" "$RST" >/dev/tty + IFS= read -r _ans answer on stdout, with terminal echo suppressed. +ask_secret() { + [ "$HAS_TTY" = 1 ] || die "no terminal available for input; re-run in an interactive shell" + printf '%s%s%s ' "$B" "$1" "$RST" >/dev/tty + stty -echo /dev/null || true + IFS= read -r _sec /dev/null || true + printf '\n' >/dev/tty + printf '%s' "$_sec" +} + +# confirm PROMPT [Y] -> exit 0 on yes. Default is no unless second arg is "Y". +confirm() { + _def=${2:-N} + if [ "$HAS_TTY" != 1 ]; then + [ "$_def" = Y ] # non-interactive: take the default + return + fi + if [ "$_def" = Y ]; then _hint='[Y/n]'; else _hint='[y/N]'; fi + printf '%s%s%s %s ' "$B" "$1" "$RST" "$_hint" >/dev/tty + IFS= read -r _r /dev/null 2>&1 || die "git is required but not found. Install git, then re-run." +info "git ... ok" + +# Node ^22.19.0 || >=24.0.0 (see the root package.json "engines" field). +node_ok() { + command -v node >/dev/null 2>&1 || return 1 + _v=$(node -v 2>/dev/null) || return 1 + _v=${_v#v} + _major=${_v%%.*} + _rest=${_v#*.} + _minor=${_rest%%.*} + case "$_major" in ''|*[!0-9]*) return 1 ;; esac + case "$_minor" in ''|*[!0-9]*) _minor=0 ;; esac + [ "$_major" -ge 24 ] && return 0 + [ "$_major" -eq 22 ] && [ "$_minor" -ge 19 ] && return 0 + return 1 +} +if node_ok; then + info "node $(node -v) ... ok" +else + if command -v node >/dev/null 2>&1; then + die "Node $(node -v) is unsupported. dsh needs ^22.19.0 || >=24.0.0 — upgrade Node, then re-run." + fi + die "Node is required but not found. Install Node ^22.19.0 || >=24, then re-run." +fi + +# pnpm is the only dependency we offer to install for you. +if command -v pnpm >/dev/null 2>&1; then + info "pnpm $(pnpm --version 2>/dev/null) ... ok" +else + warn "pnpm is not installed." + if confirm "Install pnpm now?" Y; then + if command -v corepack >/dev/null 2>&1 && corepack enable pnpm >/dev/null 2>&1; then + info "enabled pnpm via corepack" + elif command -v npm >/dev/null 2>&1 && npm install -g pnpm >/dev/null 2>&1; then + info "installed pnpm via npm" + else + die "could not install pnpm automatically. Install it (https://pnpm.io/installation), then re-run." + fi + command -v pnpm >/dev/null 2>&1 || die "pnpm still not on PATH after install. Open a new shell, then re-run." + else + die "pnpm is required. Install it (https://pnpm.io/installation), then re-run." + fi +fi + +# --- 2. clone (or update) the source ------------------------------------------ +if [ "$IN_REPO" = 1 ]; then + step "Using existing checkout at $DSH_SOURCE" + info "running from inside the repo — skipping clone (DSH_REF ignored, working tree left untouched)" +else +step "Fetching source into $DSH_SOURCE" +if [ -d "$DSH_SOURCE/.git" ]; then + info "existing checkout found — updating" + git -C "$DSH_SOURCE" fetch --depth 1 origin "$DSH_REF" + # Reset the checkout to the freshly fetched tip. FETCH_HEAD (not + # origin/) so this resolves for a tag as well as a branch, and -B makes + # the re-run idempotent whether or not DSH_REF changed since the last install. + git -C "$DSH_SOURCE" checkout -q -B "$DSH_REF" FETCH_HEAD +else + mkdir -p "$(dirname "$DSH_SOURCE")" + git clone --depth 1 --branch "$DSH_REF" "$DSH_REPO" "$DSH_SOURCE" +fi +fi + +# --- 3. install dependencies (no build; the launcher runs from source) -------- +step "Installing dependencies with pnpm (this can take a while)" +( cd "$DSH_SOURCE" && pnpm install ) + +[ -x "$DSH_SOURCE/bin/dsh" ] || die "launcher $DSH_SOURCE/bin/dsh missing after install — is DSH_REF a branch that ships apps/cli?" + +# --- 4. put `dsh` on PATH ------------------------------------------------------ +step "Linking dsh into $DSH_BIN_DIR" +mkdir -p "$DSH_BIN_DIR" +ln -sf "$DSH_SOURCE/bin/dsh" "$DSH_BIN_DIR/dsh" +info "linked $DSH_BIN_DIR/dsh -> $DSH_SOURCE/bin/dsh" + +case ":$PATH:" in + *":$DSH_BIN_DIR:"*) ON_PATH=1 ;; + *) ON_PATH=0 ;; +esac +if [ "$ON_PATH" = 0 ]; then + warn "$DSH_BIN_DIR is not on your PATH." + _line="export PATH=\"$DSH_BIN_DIR:\$PATH\"" + _rc='' + _sh=${SHELL:-} # SHELL may be unset; word-removal on an unset var trips set -u under dash. + case "${_sh##*/}" in + zsh) _rc="$HOME/.zshrc" ;; + bash) _rc="$HOME/.bashrc" ;; + esac + if [ -n "$_rc" ] && [ -f "$_rc" ] && grep -qF "$_line" "$_rc" 2>/dev/null; then + info "$_rc already exports $DSH_BIN_DIR — open a new shell to pick it up" + elif [ -n "$_rc" ] && confirm "Add it to $_rc?" Y; then + printf '\n# Added by the dsh installer\n%s\n' "$_line" >>"$_rc" + info "updated $_rc — run 'source $_rc' or open a new shell to pick it up" + else + warn "add this line to your shell profile yourself:" + printf ' %s\n' "$_line" + fi +fi + +# --- 5. credentials ------------------------------------------------------------ +# Mirror app-boot's resolvePersonalConfigDir precedence so creds land where dsh reads them. +if [ -n "${DSH_CONFIG_HOME:-}" ]; then + CONF="$DSH_CONFIG_HOME" +elif [ -n "${XDG_CONFIG_HOME:-}" ]; then + CONF="$XDG_CONFIG_HOME/dsh" +else + CONF="$HOME/.config/dsh" +fi +ENV_FILE="$CONF/.env" + +step "Configuring credentials" +if [ -f "$ENV_FILE" ] && grep -q '^DEEPSEEK_API_KEY=' "$ENV_FILE" 2>/dev/null; then + info "DEEPSEEK_API_KEY already set in $ENV_FILE" + if ! confirm "Replace it?" N; then + SKIP_CREDS=1 + fi +fi +if [ "${SKIP_CREDS:-0}" != 1 ]; then + if [ "$HAS_TTY" = 1 ]; then + API_KEY=$(ask_secret "DeepSeek API key (input hidden):") + if [ -z "$API_KEY" ]; then + warn "no key entered — skipping. Set DEEPSEEK_API_KEY in $ENV_FILE before using dsh." + else + BASE_URL=$(ask "DeepSeek base URL (optional, Enter to skip):") + mkdir -p "$CONF" + # The installer owns exactly the two DEEPSEEK_* lines; any other lines the + # user keeps in this .env are preserved. The rewrite happens in a subshell + # so umask 077 (which closes the create-time permission race) does not leak + # into the exec'd dsh, and lands atomically via a same-dir temp + mv. + _tmp="$ENV_FILE.dsh.$$" + ( + umask 077 + if [ -f "$ENV_FILE" ]; then + grep -v -e '^DEEPSEEK_API_KEY=' -e '^DEEPSEEK_BASE_URL=' "$ENV_FILE" >"$_tmp" || true + else + : >"$_tmp" + fi + printf 'DEEPSEEK_API_KEY=%s\n' "$API_KEY" >>"$_tmp" + if [ -n "$BASE_URL" ]; then printf 'DEEPSEEK_BASE_URL=%s\n' "$BASE_URL" >>"$_tmp"; fi + ) + mv "$_tmp" "$ENV_FILE" + chmod 600 "$ENV_FILE" 2>/dev/null || true + info "wrote $ENV_FILE" + fi + else + warn "no terminal for credential input — set DEEPSEEK_API_KEY in $ENV_FILE before using dsh." + fi +fi + +# --- 6. launch ----------------------------------------------------------------- +step "Done" +if [ "$HAS_TTY" = 1 ]; then + info "launching dsh — run 'dsh' anytime to start again" + exec "$DSH_BIN_DIR/dsh" Date: Wed, 22 Jul 2026 10:55:20 +0800 Subject: [PATCH 44/90] fix(gates): bound pre-push test gate vitest workers --- .../2026-07-06-parallel-pre-push-gates.md | 8 ++++++++ scripts/run-gates.ts | 16 +++++++++++++++- 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/.agents/notes/implemented/process/2026-07-06-parallel-pre-push-gates.md b/.agents/notes/implemented/process/2026-07-06-parallel-pre-push-gates.md index 710c37cb3a..8c1c35dccb 100644 --- a/.agents/notes/implemented/process/2026-07-06-parallel-pre-push-gates.md +++ b/.agents/notes/implemented/process/2026-07-06-parallel-pre-push-gates.md @@ -10,6 +10,8 @@ Flattening those members directly into `lefthook.yml` solves the local hook only `publint` has the same shape one level lower. Each package is linted independently against its own manifest and built output, but the runner loops through every package in order. On this repo that makes one package-publication gate consume time proportional to the number of packages even though the checks do not share mutable state. +The unit-suite gate is the sharpest instance of the parallel runner's own pressure. At vitest's all-core default it oversubscribed the machine against its concurrent siblings — build, snapshot, and the doc leaves — and the resulting CPU starvation blew the 5s per-test timeout on subprocess-spawning tests (the hooks bridges spawn shells; the sandbox probe `spawnSync`-es a launcher) with a shifting victim set from run to run. + ## Decision [lefthook.yml](../../../../lefthook.yml) keeps one pre-push job named `full check` and runs `pnpm run check:pre-push`. That package script delegates to [scripts/run-gates.ts](../../../../scripts/run-gates.ts), the same bounded scheduler CI uses. @@ -18,6 +20,8 @@ The `pre-push` mode expands into leaf gates for the unit suite, snapshot suite, The build gate makes the hook self-contained from a clean worktree. `publint`, `verify-node-next-types`, and the pre-push form of `doc-typecheck` wait for that build output, while source-only gates continue in parallel. +The unit-suite (`test`) gate runs `vitest` with a pool bounded to half the available cores by default; `DSH_TEST_MAX_WORKERS` overrides it, mirroring the coverage gate's `DSH_COVERAGE_MAX_WORKERS`. The bound lives only in the `pre-push` mode's gate; CI runs the coverage gate instead, so it never touches CI timing. + [scripts/publint-all.ts](../../../../scripts/publint-all.ts) discovers the package list from `packages//` and runs `publint` with a worker pool sized from `availableParallelism()`. `DSH_PUBLINT_CONCURRENCY` can cap or raise the worker count for local machines and CI runners with different resource profiles. Results are buffered per package and printed in deterministic package order, so parallel execution does not scramble each package's log block. The per-gate package scripts remain the vocabulary for ad hoc local runs. `hygiene` stays an aggregate `&&` chain the scheduler mirrors, while `doc-sync` has since moved its member list into the scheduler itself ([doc-sync through the gate scheduler](2026-07-21-doc-sync-through-gate-scheduler.md)). @@ -30,6 +34,8 @@ The per-gate package scripts remain the vocabulary for ad hoc local runs. `hygie - **Background subcommands inside shell scripts** - can parallelize work, but it loses lefthook's job names, per-job timing, and failure grouping, and makes signal handling harder to reason about. - **Declare one publint lefthook job per package** - exposes maximum parallelism, but it turns the hook into a hand-maintained package inventory that drifts exactly when new packages are added. - **Run publint with unbounded concurrency** - minimizes elapsed time on small machines only by gambling with process count, memory pressure, package tarball creation, and readable logs. +- **Leave the `test` gate at vitest's all-core default** - matches a standalone `pnpm run test`, but under the pre-push runner it overlaps three sibling gates and oversubscribes the machine, so subprocess-spawning tests intermittently blow their 5s timeout; bounding the pool trades a slower isolated `test` gate for a stable one. +- **Raise the per-test timeout instead of bounding workers** - would cover the vitest-level timeouts, but the sandbox probe's own 5s `spawnSync` budget is a real-time product default the test asserts against, not a vitest timeout, so only lowering the concurrent process count keeps it green. ## Consequences @@ -38,3 +44,5 @@ The hook's critical path becomes the slowest real gate instead of the sum of hid The hook file stays short, and the duplicated member list lives in [scripts/run-gates.ts](../../../../scripts/run-gates.ts), where CI and pre-push can share it. The cost is a custom scheduler script instead of pure lefthook configuration, plus a build in the local pre-push path. `publint-all.ts` becomes asynchronous code and buffers command output instead of inheriting stdio live. The payoff is package-level parallelism with stable output order and one environment variable for resource tuning. + +The bounded `test` gate runs slower in isolation than a full-machine `vitest run` but no longer starves its siblings, so the hook stops producing spurious per-test timeout failures at its default concurrency. A machine shared with other heavy processes can still spike past saturation beyond this hook's control; `DSH_TEST_MAX_WORKERS` and `DSH_GATE_CONCURRENCY` let a contributor tighten the footprint further when that happens. diff --git a/scripts/run-gates.ts b/scripts/run-gates.ts index 2de30b76a7..823fc91828 100644 --- a/scripts/run-gates.ts +++ b/scripts/run-gates.ts @@ -195,7 +195,7 @@ function gatesForMode(selected: Mode): Gate[] { return [ pnpmScript('runtime-closure', 'verify-runtime-closure', { label: 'runtime closure' }), pnpmScript('cordis-config', 'verify-cordis-config', { label: 'Cordis config' }), - pnpmScript('test', 'test'), + prePushTestGate(), pnpmScript('duplication', 'duplication'), snapshotGate(), pnpmScript('build', 'build'), @@ -294,6 +294,20 @@ function coverageGate(): Gate { }) } +// The pre-push runner overlaps this all-core vitest gate with sibling gates (build, snapshot, +// doc leaves). Bound its pool to half the cores by default (>=2 workers) so it does not +// oversubscribe them; DSH_TEST_MAX_WORKERS overrides it, mirroring coverageGate's +// DSH_COVERAGE_MAX_WORKERS. Pre-push only — CI runs the coverage gate — so the bound never +// touches CI timing. Failure mode and rationale in the parallel pre-push gates Agent Note +// (.agents/notes/implemented/process/2026-07-06-parallel-pre-push-gates.md). +function prePushTestGate(): Gate { + const override = positiveIntArg('DSH_TEST_MAX_WORKERS', '--maxWorkers') + const workers = override.length > 0 + ? override + : [`--maxWorkers=${Math.max(2, Math.floor(availableParallelism() / 2))}`] + return pnpmExec('test', ['vitest', 'run', ...workers], { label: 'test' }) +} + // The snapshot suite boots the example bins in `lib` mode (built artifact under plain Node, // plugins via real exports) — CI and pre-push already build, so they exercise what ships rather // than the tsx/source path dev uses. It therefore waits on `build`. From f503d838e9273dd393da6b237487c607244335d0 Mon Sep 17 00:00:00 2001 From: Turtle Date: Wed, 22 Jul 2026 10:55:20 +0800 Subject: [PATCH 45/90] feat(tui): default auto-title on and re-derive it on resume --- .../2026-07-21-tui-auto-pane-title.i18n.yaml | 4 +-- .../feature/2026-07-21-tui-auto-pane-title.md | 2 ++ .../2026-07-21-tui-auto-pane-title.zh.md | 2 ++ ...-07-21-tui-auto-title-default-on.i18n.yaml | 6 ++++ .../2026-07-21-tui-auto-title-default-on.md | 30 +++++++++++++++++++ ...2026-07-21-tui-auto-title-default-on.zh.md | 30 +++++++++++++++++++ examples/tui-agent/tests/tui.snapshot.ts | 3 ++ 7 files changed, 75 insertions(+), 2 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-07-21-tui-auto-title-default-on.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-07-21-tui-auto-title-default-on.md create mode 100644 .agents/notes/implemented/feature/2026-07-21-tui-auto-title-default-on.zh.md diff --git a/.agents/notes/implemented/feature/2026-07-21-tui-auto-pane-title.i18n.yaml b/.agents/notes/implemented/feature/2026-07-21-tui-auto-pane-title.i18n.yaml index 10c60b3355..25e8aa06b6 100644 --- a/.agents/notes/implemented/feature/2026-07-21-tui-auto-pane-title.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-21-tui-auto-pane-title.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 -2026-07-21-tui-auto-pane-title.md: 5efa86ae983d276964babad120f3e4131bc20b0d -2026-07-21-tui-auto-pane-title.zh.md: 3bd1dad8af1bfaf14669e9f0d3df13a4d3c12159 +2026-07-21-tui-auto-pane-title.md: 354602cac465b20157af563ccc32acf82cc54e73 +2026-07-21-tui-auto-pane-title.zh.md: 97178d723b92b243f3bb1b58aa86b681eb9e8573 diff --git a/.agents/notes/implemented/feature/2026-07-21-tui-auto-pane-title.md b/.agents/notes/implemented/feature/2026-07-21-tui-auto-pane-title.md index 5efa86ae98..354602cac4 100644 --- a/.agents/notes/implemented/feature/2026-07-21-tui-auto-pane-title.md +++ b/.agents/notes/implemented/feature/2026-07-21-tui-auto-pane-title.md @@ -4,6 +4,8 @@ Status: implemented English | [中文](2026-07-21-tui-auto-pane-title.zh.md) +> **Superseded** for the default and the resume behavior by the [auto-title default-on Agent Note](2026-07-21-tui-auto-title-default-on.md): `autoTitle` now defaults on, and a resumed session re-derives its title from the stored first message instead of keeping the static one. The OSC 0 path, the one-shot latch, the model-summary shape, the fire-and-forget call, and every failure fallback below stand. + ## Problem The TUI's terminal title is a single static string (`title`, default `DeepSeek Harness`) shared by every session. A user who runs one agent per tmux pane or terminal tab sees the same label on all of them, so panes are indistinguishable at a glance and the tab bar carries no signal about what each session is doing. diff --git a/.agents/notes/implemented/feature/2026-07-21-tui-auto-pane-title.zh.md b/.agents/notes/implemented/feature/2026-07-21-tui-auto-pane-title.zh.md index 3bd1dad8af..97178d723b 100644 --- a/.agents/notes/implemented/feature/2026-07-21-tui-auto-pane-title.zh.md +++ b/.agents/notes/implemented/feature/2026-07-21-tui-auto-pane-title.zh.md @@ -4,6 +4,8 @@ Status: implemented [English](2026-07-21-tui-auto-pane-title.md) | 中文 +> **已被取代**(就默认值与恢复行为而言),见[自动标题默认开启 Agent Note](2026-07-21-tui-auto-title-default-on.md):`autoTitle` 现默认开启,恢复会话会从已存储的首条消息重新推导标题,而非保留静态标题。下文的 OSC 0 路径、一次性门闩、模型概括形态、发出后不等待其返回的调用,以及每一条失败兜底,均仍然成立。 + ## Problem TUI 的终端标题是一个所有会话共用的静态字符串(`title`,默认 `DeepSeek Harness`)。在 tmux 每个窗格或每个终端标签页各跑一个 agent(智能体)的用户看来,它们的标签全都一样,因此窗格一眼看去无从区分,标签栏也不携带任何关于各会话正在做什么的信号。 diff --git a/.agents/notes/implemented/feature/2026-07-21-tui-auto-title-default-on.i18n.yaml b/.agents/notes/implemented/feature/2026-07-21-tui-auto-title-default-on.i18n.yaml new file mode 100644 index 0000000000..2137e9d594 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-21-tui-auto-title-default-on.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 +2026-07-21-tui-auto-title-default-on.md: 1121cf39c79b01dc5e45ff31fccbe82ccee6d101 +2026-07-21-tui-auto-title-default-on.zh.md: 5569c9d71b7481bcf74e39320a52f1f47e7161ed diff --git a/.agents/notes/implemented/feature/2026-07-21-tui-auto-title-default-on.md b/.agents/notes/implemented/feature/2026-07-21-tui-auto-title-default-on.md new file mode 100644 index 0000000000..1121cf39c7 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-21-tui-auto-title-default-on.md @@ -0,0 +1,30 @@ +# Agent Note: Auto-title on by default, re-derived on resume + +Status: implemented + +English | [中文](2026-07-21-tui-auto-title-default-on.zh.md) + +## Problem + +The [auto-title Agent Note](2026-07-21-tui-auto-pane-title.md) shipped `autoTitle` off by default and, on a resumed session, kept the static title because the first `user/message` was already logged. In use both choices defeated the feature's purpose. A per-session descriptive pane title is what makes one tmux pane or terminal tab distinguishable from the next; leaving it off by default means the product ships an inert feature that almost no user turns on, and skipping re-derivation on resume means a resumed session — exactly the long-lived session most worth labelling — falls back to the shared static string. The user asked for a descriptive per-session name to be the normal experience. + +## Decision + +- `autoTitle` defaults **on** (`z.boolean().default(true)`, mirrored by `resolveTuiConfig`'s `?? true`). A deployment with an `llm` service and an agent provider/model gets a model-made pane title on every session without opting in; one without them keeps the static title, so default-on is inert where the call cannot run. +- A **resumed** session re-derives the title on mount from its already-logged first `user/message`: `createTuiChat` scans `agent.session.events` for the first such event and feeds its text to the same one-shot `generateTitle`. The title is never persisted (the session header carries no title field), so it is always derived, never restored. +- The one-shot latch is now simply `titleSettled = !resolved.autoTitle`. The prior pre-settle-on-resume clause is gone: on resume `generateTitle` runs once from the stored first message and then latches, so a message that arrives *after* the resume does not re-title. A fresh session has no stored `user/message` at mount, so the resume scan is a no-op and the live `session/event` listener titles the first message instead. +- Everything else from the [auto-title Agent Note](2026-07-21-tui-auto-pane-title.md) stands unchanged: the OSC 0 `runtime.terminal.setTitle` path, the model-summary shape (two-to-five lowercase words, first non-empty line, 40-char cap), the fire-and-forget `ctx.llm.stream` call that never touches the session or transcript, the shutdown `AbortController`, and every failure fallback (empty reply, missing `llm`, missing provider/model, whitespace-only prompt). + +## Alternatives considered + +**Keep the feature off by default.** Rejected: this is a direct reversal of the [auto-title Agent Note](2026-07-21-tui-auto-pane-title.md)'s "default off" decision at the user's request. Off-by-default ships an inert feature; the descriptive name is only useful if it is the normal experience. The keyless-replay concern that motivated off-by-default is addressed by pinning `autoTitle: false` in the replay-backed snapshot scenarios rather than by suppressing it for every deployment. + +**Persist the derived title in the session header.** Rejected: the header has no title field and adding one would make a terminal label into session metadata — the boundary the [auto-title Agent Note](2026-07-21-tui-auto-pane-title.md) already drew against the log-backed session-title work. Re-deriving from the stored first message costs one tool-less call on resume and keeps the label a pure function of the conversation. + +**Re-derive on resume from the latest message instead of the first.** Rejected: the title summarises what the session is *about*, which its opening request captures; a mid-conversation message would make the pane label drift as the work moves on. + +## Consequences + +- A fresh session with a working `llm` now spends one extra tool-less model call by default (previously only when opted in); a resumed session spends one on mount. Deployments without an `llm` or provider/model are unaffected. +- The replay-backed `examples/tui-agent/tests/tui.snapshot.ts` must opt **out**: it pins `autoTitle: false`, because a default-on title request is not among the recorded turns and `installLlmReplay` fails loud on an unrecorded request. The unit `packages/ui/tui/tests/tui.snapshot.ts` needs no opt-out — it mounts no `llm` service, so `generateTitle` short-circuits and the default flip is inert there. The interactive `examples/tui-agent/cordis.yml` and the scripted PTY fixture already set `autoTitle: true`, so the keyless smoke's OSC 0 assertion is unchanged. +- `packages/ui/tui/tests/tui.spec.ts` pins the new defaults: the config-default test expects `autoTitle: true`; the disabled-path test now sets `autoTitle: false` explicitly; and the former "resumed session never fires" test is rewritten to assert re-derivation from the stored first message and that a later live message does not re-title. `docs/config-catalog.md` regenerates to "On by default". diff --git a/.agents/notes/implemented/feature/2026-07-21-tui-auto-title-default-on.zh.md b/.agents/notes/implemented/feature/2026-07-21-tui-auto-title-default-on.zh.md new file mode 100644 index 0000000000..5569c9d71b --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-21-tui-auto-title-default-on.zh.md @@ -0,0 +1,30 @@ +# Agent Note: 自动标题默认开启,恢复时重新推导 + +Status: implemented + +[English](2026-07-21-tui-auto-title-default-on.md) | 中文 + +## Problem + +[自动标题 Agent Note](2026-07-21-tui-auto-pane-title.md) 交付时 `autoTitle` 默认关闭,并且在恢复会话中因首条 `user/message` 已入日志而保留静态标题。实际使用中这两个选择都违背了该功能的初衷。让一个 tmux 窗格或终端标签页区别于下一个的,正是每会话各异的描述性窗格标题;默认关闭意味着产品交付了一个几乎无人开启的惰性功能,而恢复时不重新推导,则意味着恢复会话——恰恰是最值得标记的长命会话——退回到共用的静态字符串。用户要求把每会话的描述性名称做成常态体验。 + +## Decision + +- `autoTitle` 默认**开启**(`z.boolean().default(true)`,`resolveTuiConfig` 以 `?? true` 与之对齐)。带有 `llm` 服务与 agent 提供方/模型的部署无需选择性开启即可在每个会话获得模型制作的窗格标题;不具备它们的部署保留静态标题,因此在调用无法运行处,默认开启是惰性的。 +- **恢复**会话在挂载时从其已入日志的首条 `user/message` 重新推导标题:`createTuiChat` 在 `agent.session.events` 中扫描首个此类事件,并把其文本喂给同一个一次性的 `generateTitle`。标题从不持久化(会话头不携带标题字段),因此它始终是推导得来,而非恢复而来。 +- 一次性门闩现在只是 `titleSettled = !resolved.autoTitle`。此前"恢复即预先结算"的分句已删除:恢复时 `generateTitle` 从已存储的首条消息运行一次随后上闩,因此恢复*之后*到达的消息不会再改标题。全新会话在挂载时没有已存储的 `user/message`,因此恢复扫描是空操作,改由实时的 `session/event` 监听器为首条消息命名。 +- [自动标题 Agent Note](2026-07-21-tui-auto-pane-title.md) 的其余一切保持不变:OSC 0 的 `runtime.terminal.setTitle` 路径、模型概括形态(两到五个小写单词、首个非空行、40 字符上限)、从不触碰会话或 transcript(文本记录)的发出后不等待其返回的 `ctx.llm.stream` 调用、关闭时的 `AbortController`,以及每一条失败兜底(空回复、缺 `llm`、缺提供方/模型、仅含空白的提示词)。 + +## Alternatives considered + +**让该功能保持默认关闭。** 否决:这是应用户要求,对[自动标题 Agent Note](2026-07-21-tui-auto-pane-title.md)"默认关闭"决策的直接反转。默认关闭交付的是惰性功能;只有当描述性名称成为常态体验时它才有用。当初促成默认关闭的无密钥回放顾虑,改由在以回放支撑的快照场景中固定 `autoTitle: false` 来处理,而非为每个部署都压制该功能。 + +**把推导出的标题持久化进会话头。** 否决:会话头没有标题字段,加一个会把终端标签变成会话元数据——正是[自动标题 Agent Note](2026-07-21-tui-auto-pane-title.md)已经对日志支撑的会话标题工作划出的边界。从已存储的首条消息重新推导,代价是恢复时一次无工具调用,并让标签保持为对话的纯函数。 + +**恢复时从最新消息而非首条消息重新推导。** 否决:标题概括的是会话*关于什么*,而这由其开场请求捕获;一条对话中途的消息会让窗格标签随工作推进而漂移。 + +## Consequences + +- 带可用 `llm` 的全新会话现在默认多花一次无工具的模型调用(此前只在选择性开启时才有);恢复会话在挂载时花掉一次。不具备 `llm` 或提供方/模型的部署不受影响。 +- 以回放支撑的 `examples/tui-agent/tests/tui.snapshot.ts` 必须选择**关闭**:它固定 `autoTitle: false`,因为默认开启的标题请求不在录制轮次之列,而 `installLlmReplay` 对未录制的请求会显式报错。单元 `packages/ui/tui/tests/tui.snapshot.ts` 无需选择关闭——它不挂载 `llm` 服务,因此 `generateTitle` 提前短路,默认值的翻转在那里是惰性的。交互式的 `examples/tui-agent/cordis.yml` 与脚本化 PTY fixture(测试前置数据)已设 `autoTitle: true`,因此无密钥冒烟测试的 OSC 0 断言保持不变。 +- `packages/ui/tui/tests/tui.spec.ts` 固定新的默认值:config 默认测试期望 `autoTitle: true`;关闭路径测试现在显式设 `autoTitle: false`;此前的"恢复会话从不触发"测试改写为断言从已存储首条消息重新推导,并断言之后的实时消息不会再改标题。`docs/config-catalog.md` 重新生成为"On by default"。 diff --git a/examples/tui-agent/tests/tui.snapshot.ts b/examples/tui-agent/tests/tui.snapshot.ts index 79fd8500db..e2cfffd6d2 100644 --- a/examples/tui-agent/tests/tui.snapshot.ts +++ b/examples/tui-agent/tests/tui.snapshot.ts @@ -259,6 +259,9 @@ async function runScenario(scenario: Scenario): Promise { color: true, showReasoning: true, title: 'DSH TUI snapshot', + // Off so replay stays deterministic: an auto-title request is not among the + // recorded turns. Auto-title's PTY coverage lives in the keyless smoke. + autoTitle: false, welcome: `Recorded replay: ${scenario.name}`, maxToolOutputLines: 8, }, { From a5dfffcbe5c1cc74af9704252fa6f1ae929f3d92 Mon Sep 17 00:00:00 2001 From: Turtle Date: Wed, 22 Jul 2026 10:55:20 +0800 Subject: [PATCH 46/90] chore(examples): make todo_write opt-in for the tui-agent example Make todo_write opt-in for the tui-agent example (disabled by default; TUI still renders plans when the tool is loaded). Both enabled and disabled cases covered by tests. --- ...2026-07-21-tui-todo-write-opt-in.i18n.yaml | 6 +++++ .../2026-07-21-tui-todo-write-opt-in.md | 27 +++++++++++++++++++ .../2026-07-21-tui-todo-write-opt-in.zh.md | 27 +++++++++++++++++++ examples/tui-agent/tests/tui.snapshot.ts | 11 +++++++- 4 files changed, 70 insertions(+), 1 deletion(-) create mode 100644 .agents/notes/implemented/simplification/2026-07-21-tui-todo-write-opt-in.i18n.yaml create mode 100644 .agents/notes/implemented/simplification/2026-07-21-tui-todo-write-opt-in.md create mode 100644 .agents/notes/implemented/simplification/2026-07-21-tui-todo-write-opt-in.zh.md diff --git a/.agents/notes/implemented/simplification/2026-07-21-tui-todo-write-opt-in.i18n.yaml b/.agents/notes/implemented/simplification/2026-07-21-tui-todo-write-opt-in.i18n.yaml new file mode 100644 index 0000000000..3d91d63789 --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-07-21-tui-todo-write-opt-in.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 +2026-07-21-tui-todo-write-opt-in.md: 30797568f2b37180f8a9c837cb4ae7f10b6125eb +2026-07-21-tui-todo-write-opt-in.zh.md: 29a779bbd765a42a12e8561c87375d742dd8bd4b diff --git a/.agents/notes/implemented/simplification/2026-07-21-tui-todo-write-opt-in.md b/.agents/notes/implemented/simplification/2026-07-21-tui-todo-write-opt-in.md new file mode 100644 index 0000000000..30797568f2 --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-07-21-tui-todo-write-opt-in.md @@ -0,0 +1,27 @@ +# Agent Note: Ship the TUI without `todo_write`; keep it a one-line opt-in + +Status: implemented + +English | [中文](2026-07-21-tui-todo-write-opt-in.zh.md) + +## Problem + +The shipped tui-agent `cordis.yml` loaded `@deepseek-ai/dsh-tool-todo`, exposing `todo_write` by default. The tool is a task-tracking convenience, not a core coding affordance like `bash` or the `read`/`write`/`edit` fs tools; most TUI sessions never call it, yet shipping it enlarges the wire tool list and system prompt for every turn. Meanwhile the TUI's plan rendering is event-driven: `packages/ui/tui/src/index.ts` listens for the `todo/write` session event and `TodoComponent.render` returns nothing when the list is empty, so the front door already tolerates the tool being absent or present with no runtime coupling to the plugin. + +## Decision + +The tui-agent `cordis.yml` no longer loads `tool-todo`; `todo_write` is opt-in. The `code-mode.cordis.yml` overlay inherits the base composition, so its generated SDK drops `todo_write` too. Enabling it is one entry — add `@deepseek-ai/dsh-tool-todo` to `cordis.yml` (or a `~/.config/dsh` personal overlay) — after which the model logs the whole-list `todo/write` snapshot and the TUI renders the plan, unchanged. The `TodoItem` type and the `todo/write` event stay in `@deepseek-ai/dsh-session` and the TUI's plan rendering stays wired, so both the default (disabled) and opt-in (enabled) paths are first-class. The sibling acp-agent, headless-agent, and jsonrpc-agent examples still ship the tool. + +## Alternatives considered + +**Keep `todo_write` in the shipped TUI default** — rejected: it is an opt-in convenience, not a core tool, and shipping it spends every turn's tool-list and prompt budget on a feature most sessions ignore. The examples that still ship it retain the plugin's real-composition coverage. + +**Drop the TUI's plan rendering and todo tests along with the default entry** — rejected: the requirement is to support both the enabled and disabled cases, and the event-driven `TodoComponent` already renders plans with zero plugin coupling, so deleting it would discard a working capability for no gain. The enabled path keeps dedicated coverage instead. + +## Testing + +`examples/tui-agent/tests/tui.snapshot.ts` mounts `ToolTodo` only when a scenario sets `enableTodo`: only the `todo-plan` scenario does (the enabled-path proof, whose `session.jsonl`/`terminal.expected.txt` pin the rendered plan), while every other scenario runs the default todo-free composition. `tests/harness.ts` makes `ToolTodo` a `todo` opt-in that only `tests/todo-write.e2e.ts` sets, so the with-key todo e2e still drives the real tool while the other suites match the shipped stack. The keyless `tests/tui-keyless-smoke.e2e.ts` boots the real `cordis.yml` and asserts nothing about todo, so the default boot is unaffected. + +## Consequences + +The default TUI wire tool list and system prompt shrink by one tool; a session that wants task tracking adds one plugin entry. `examples/tui-agent/composition.md` (regenerated) and its leaf-entry table no longer list `tool-todo`, and the curated summary in `scripts/gen-doc-graphs.ts` drops it. The `@deepseek-ai/dsh-tool-todo` package is unchanged and still shipped by the acp/headless/jsonrpc examples, so its coverage requirement is met there. Restoring the default would re-add the one `cordis.yml` entry and flip the snapshot/harness opt-in flags back on. diff --git a/.agents/notes/implemented/simplification/2026-07-21-tui-todo-write-opt-in.zh.md b/.agents/notes/implemented/simplification/2026-07-21-tui-todo-write-opt-in.zh.md new file mode 100644 index 0000000000..29a779bbd7 --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-07-21-tui-todo-write-opt-in.zh.md @@ -0,0 +1,27 @@ +# Agent Note: Ship the TUI without `todo_write`; keep it a one-line opt-in + +Status: implemented + +[English](2026-07-21-tui-todo-write-opt-in.md) | 中文 + +## Problem + +出厂的 tui-agent `cordis.yml` 加载了 `@deepseek-ai/dsh-tool-todo`,默认向模型暴露 `todo_write`。这个工具是一项任务追踪的便利功能,而非像 `bash` 或 `read`/`write`/`edit` 文件系统工具那样的核心编码能力;多数 TUI 会话从不调用它,但出厂加载它会让每一轮的协议工具列表和系统提示词都随之变大。而 TUI 的计划渲染是事件驱动的:`packages/ui/tui/src/index.ts` 监听 `todo/write` 会话事件,`TodoComponent.render` 在列表为空时不返回任何内容,因此这个入口本就能容忍该工具的缺席或存在,与该插件没有任何运行时耦合。 + +## Decision + +tui-agent `cordis.yml` 不再加载 `tool-todo`;`todo_write` 改为可选启用。`code-mode.cordis.yml` 覆盖配置继承基础组合,因此它生成的 SDK 同样不再包含 `todo_write`。启用它只需一条配置项——把 `@deepseek-ai/dsh-tool-todo` 加入 `cordis.yml`(或 `~/.config/dsh` 的个人覆盖配置)——此后模型照旧记录整份清单的 `todo/write` 快照,TUI 照旧渲染该计划。`TodoItem` 类型与 `todo/write` 事件仍留在 `@deepseek-ai/dsh-session`,TUI 的计划渲染也保持接线,因此默认(禁用)与可选启用(启用)两条路径都是一等公民。同类的 acp-agent、headless-agent、jsonrpc-agent 示例仍然出厂携带该工具。 + +## Alternatives considered + +**在出厂的 TUI 默认配置中保留 `todo_write`。** 否决:它是一项可选启用的便利功能,而非核心工具,出厂加载它会为多数会话都忽略的功能花掉每一轮的工具列表与提示词预算。仍然携带它的示例保留了该插件的真实组合覆盖。 + +**连同默认配置项一起删掉 TUI 的计划渲染与 todo 测试。** 否决:需求是同时支持启用与禁用两种情形,而事件驱动的 `TodoComponent` 本就在零插件耦合下渲染计划,删掉它等于白白丢弃一项可用能力。取而代之,启用路径保留专门的覆盖。 + +## Testing + +`examples/tui-agent/tests/tui.snapshot.ts` 根据逐场景的 `enableTodo` 开关决定是否挂载 `ToolTodo`:只有 `todo-plan` 场景挂载它(启用路径的证明,其 `session.jsonl`/`terminal.expected.txt` 固定了渲染出的计划),其余每个场景都运行默认的无 todo 组合。`tests/harness.ts` 把 `ToolTodo` 做成一个 `todo` 可选项,只有 `tests/todo-write.e2e.ts` 会开启它,因此带密钥的 todo e2e 仍然驱动真实工具,而其余套件与出厂技术栈保持一致。无密钥的 `tests/tui-keyless-smoke.e2e.ts` 启动真实的 `cordis.yml`,且不对 todo 作任何断言,因此默认启动不受影响。 + +## Consequences + +默认 TUI 的协议工具列表和系统提示词少了一个工具;想要任务追踪的会话加一条插件配置项即可。`examples/tui-agent/composition.md`(已重新生成)及其叶子条目表不再列出 `tool-todo`,`scripts/gen-doc-graphs.ts` 中人工维护的摘要也去掉了它。`@deepseek-ai/dsh-tool-todo` 包本身没有变动,仍由 acp/headless/jsonrpc 示例出厂携带,因此它的覆盖需求在那里得到满足。若要恢复默认,只需重新加入那一条 `cordis.yml` 配置项,并把快照/harness 的可选开关重新打开。 diff --git a/examples/tui-agent/tests/tui.snapshot.ts b/examples/tui-agent/tests/tui.snapshot.ts index e2cfffd6d2..41d8d8160e 100644 --- a/examples/tui-agent/tests/tui.snapshot.ts +++ b/examples/tui-agent/tests/tui.snapshot.ts @@ -47,6 +47,12 @@ interface Scenario { childSessions?: number recorded: boolean seedWorkspace?: boolean + /** + * Load the opt-in `todo_write` tool for this scenario. The shipped tui-agent + * config omits it, so only the todo-plan scenario (the enabled-path proof) + * mounts it; the rest cover the default, todo-free composition. + */ + enableTodo?: boolean } const SCENARIOS: Scenario[] = [ @@ -62,6 +68,7 @@ const SCENARIOS: Scenario[] = [ expectedTools: ['todo_write'], expectedEventCounts: { 'todo/write': 1 }, recorded: true, + enableTodo: true, }, { name: 'bash-terminal-card', @@ -196,7 +203,9 @@ async function mountScenarioContext( await ctx.plugin(FsPolicy) await ctx.plugin(ToolFs) await ctx.plugin(UserInteractionService) - await ctx.plugin(ToolTodo) + // todo_write is opt-in: only the todo-plan scenario mounts it, matching the shipped + // config that omits it. The other scenarios prove the default todo-free composition. + if (scenario.enableTodo === true) await ctx.plugin(ToolTodo) await ctx.plugin(SubagentService) await ctx.plugin(SubagentSpawn, { providerName: 'spawn' }) await ctx.plugin(ToolSubagent, { provider: 'spawn', toolName: 'subagent', enableRunInBackground: false }) From 2464d6169f945ced4f2c6187afc7aaba8d3b3408 Mon Sep 17 00:00:00 2001 From: Turtle Date: Wed, 22 Jul 2026 10:55:20 +0800 Subject: [PATCH 47/90] feat(app-boot): move personal config to the Harness home (~/.dsh) Squashes feat/personal-config-dsh-home: personal config.yaml and .env move from ~/.config/dsh to the Harness home (~/.dsh), plus the module-graph, lockfile, and i18n pairing regeneration that followed. --- ...26-07-20-dsh-cli-personal-config.i18n.yaml | 4 +-- .../2026-07-20-dsh-cli-personal-config.md | 10 +++--- .../2026-07-20-dsh-cli-personal-config.zh.md | 10 +++--- ...2026-07-21-tui-todo-write-opt-in.i18n.yaml | 4 +-- .../2026-07-21-tui-todo-write-opt-in.md | 2 +- .../2026-07-21-tui-todo-write-opt-in.zh.md | 2 +- apps/cli/README.md | 2 +- apps/cli/package.json | 5 +-- apps/cli/src/tui.ts | 8 ++--- apps/cli/tsconfig.json | 3 ++ .../ui/app-boot/tests/personal-config.spec.ts | 35 ++++--------------- scripts/install.sh | 16 ++++----- 12 files changed, 41 insertions(+), 60 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.i18n.yaml b/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.i18n.yaml index 8dd2bc2fdf..7addc991d2 100644 --- a/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.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 -2026-07-20-dsh-cli-personal-config.md: 7849f6317f290a677bae45219012edd2ad9e7211 -2026-07-20-dsh-cli-personal-config.zh.md: 77b3c319da975a2d52e3d50fda361edd4a1bd23c +2026-07-20-dsh-cli-personal-config.md: 514bb5b12a3e04c7deaad1e8616472eed1c920e1 +2026-07-20-dsh-cli-personal-config.zh.md: 16fada82c59c8a356e6df112234e6b7565aae1bf diff --git a/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.md b/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.md index 7849f6317f..514bb5b12a 100644 --- a/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.md +++ b/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.md @@ -1,4 +1,4 @@ -# Agent Note: The dsh CLI and personal config overlays from ~/.config/dsh +# Agent Note: The dsh CLI and personal config overlays from the Harness home Status: implemented @@ -14,13 +14,13 @@ Two coupled pieces, aligned with the `apps/` assembly tier proposed by the `dsh **The `dsh` CLI (`apps/cli`, npm name `@deepseek-ai/dsh`).** `apps/*` joins the workspaces as the product-assembly tier over `packages/*` libraries. The bin's dispatch reserves `web` and `-p`/`--prompt` for PR #443 (they exit with a pointer) so the two branches merge as a near-union; everything else runs the default surface: the interactive TUI, booting the shipped `examples/tui-agent/cordis.yml` (or an explicit config argument) with the invoking directory as the workspace. The committed `bin/dsh` launcher resolves the checkout through its own real path and runs the bin **from source** via the repo's tsx (with `--expose-internals` for the config's HMR entry), so `ln -sf "$(pwd)/bin/dsh" ~/.local/bin/dsh` installs a command that always executes the current working tree. `pnpm run demo:tui` runs the same entry. -**Personal config (`dsh-app-boot`).** The personal config directory resolves as `$DSH_CONFIG_HOME`, else `$XDG_CONFIG_HOME/dsh`, else `~/.config/dsh` (`resolvePersonalConfigDir`; empty variables read as unset). The dsh TUI surface consumes its two optional files; the demo bins boot their committed trees verbatim: +**Personal config (`dsh-app-boot`).** The personal overlay lives in the Harness home — `$DSH_HOME`, else `~/.dsh` — resolved by the shared [`resolveDshHome`](../architecture/2026-07-24-single-harness-home-resolver.md) (`@deepseek-ai/dsh-paths`), the same single root skills and AGENTS.md resolve against. The dsh TUI surface consumes its two optional files; the demo bins boot their committed trees verbatim: - `.env` — loaded after the invoking directory's `.env`; `process.loadEnvFile` never overrides, so precedence is ambient > project `.env` > personal `.env`. - `config.yaml` — a top-level YAML array of `@cordisjs/plugin-include` `PatchOptions`, parsed with the include's own `!!js` dialect (`loadPersonalPatches`) and passed to `boot()`, which forwards it as the root include's `patches`. Patch semantics are exactly the committed overlay semantics (the Code Mode overlay is the template): an id-targeted patch replaces the named entry's whole `config`, `insert` appends entries, an unmatched id warns and is skipped. - A missing file means no overlay; a present-but-unreadable, unparsable, or non-array file throws at boot (misconfiguration fails loud, never a silent skip). -The PTY smoke's launcher isolates `DSH_CONFIG_HOME` to a per-test directory, exactly as it already isolates `DSH_HOME`/`DSH_AGENTS_HOME`, so a developer's real personal overlay cannot leak into fixtures; only the dsh CLI reads personal config, so no other test launcher needed changes. +The PTY smoke's launcher isolates `$DSH_HOME` to a per-test directory, exactly as it already isolates `DSH_AGENTS_HOME`, so a developer's real personal overlay cannot leak into fixtures; only the dsh CLI reads personal config, so no other test launcher needed changes. Hot-reload interplay: the include re-applies its `patches` on every config re-read (the [config hot-reload resilience Agent Note](../bug-fix/2026-07-20-config-hot-reload-resilience.md)), so a live `cordis.yml` edit keeps the personal overlay applied. @@ -41,9 +41,9 @@ Hot-reload interplay: the include re-applies its `patches` on every config re-re - `dsh` from any directory (and `pnpm run demo:tui`) boots the personal provider/model with zero repo changes; verified end-to-end against a personal Anthropic proxy with Opus 4.8, including a bash tool round trip. - Because an id-targeted patch replaces the whole `config`, a personal override restates the base fields it keeps and can drift when the base entry changes shape; the loader's entry-not-found/name-mismatch warnings are the only diagnostics. - Personal patches resolve ids against the booted file's own tree, so nested-include overlays (Code Mode) are not personalized; live-run parity for those leaves is deferred. -- `dsh-app-boot` gains a real dependency (`js-yaml`) and a load-only copy of the include's `!!js` YAML type. +- `dsh-app-boot` depends on `js-yaml` (plus a load-only copy of the include's `!!js` YAML type) and, like `apps/cli`, on `@deepseek-ai/dsh-paths` for `resolveDshHome`. - When PR #443 lands, `apps/cli/src/bin.ts`'s dispatch chain and `apps/cli/package.json`'s dependency list conflict textually; both resolve as unions (their `web`/`-p` branches plus our default-TUI branch). ## Testing -`packages/ui/app-boot/tests/personal-config.spec.ts` pins directory precedence (including empty-variable fallback), `!!js` preservation and end-to-end interpolation through a booted tree, insert entries, the absent/empty no-op paths, and the three fail-loud shapes (unreadable, unparsable, non-array). `examples/tui-agent/tests/tui-keyless-smoke.e2e.ts` boots the dsh bin in a PTY three ways: default config with no overlay, a personal `.env` + `config.yaml` chain whose patched welcome renders in the banner, and an invalid personal file failing the boot loudly. The pre-existing smokes and snapshot suites pass on a machine whose real `~/.config/dsh` overlay would change the booted model — the isolation, not luck. +`packages/ui/app-boot/tests/personal-config.spec.ts` pins `!!js` preservation and end-to-end interpolation through a booted tree, insert entries, the default directory resolving from `$DSH_HOME`, the absent/empty no-op paths, and the three fail-loud shapes (unreadable, unparsable, non-array). `examples/tui-agent/tests/tui-keyless-smoke.e2e.ts` boots the dsh bin in a PTY three ways: default config with no overlay, a personal `.env` + `config.yaml` chain whose patched welcome renders in the banner, and an invalid personal file failing the boot loudly. The pre-existing smokes and snapshot suites pass on a machine whose real `~/.dsh` overlay would change the booted model — the isolation, not luck. diff --git a/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.zh.md b/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.zh.md index 77b3c319da..16fada82c5 100644 --- a/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.zh.md +++ b/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.zh.md @@ -1,4 +1,4 @@ -# Agent Note: dsh CLI 与来自 ~/.config/dsh 的个人配置 overlay +# Agent Note: dsh CLI 与来自 Harness home 的个人配置 overlay Status: implemented @@ -14,13 +14,13 @@ Status: implemented **`dsh` CLI(`apps/cli`,npm 名 `@deepseek-ai/dsh`)。** `apps/*` 作为 `packages/*` 库之上的产品装配层加入 workspaces。bin 的分发把 `web` 和 `-p`/`--prompt` 保留给 PR #443(它们以指引退出),使两个分支能以接近并集的方式合并;其余一切都运行默认表面:交互式 TUI,加载随仓库提供的 `examples/tui-agent/cordis.yml`(或显式的配置参数),并以调用目录为工作区。已提交的 `bin/dsh` 启动器通过自身真实路径解析 checkout,用仓库的 tsx **从源码**运行该 bin(带 `--expose-internals`,供配置里的 HMR 配置项使用),因此 `ln -sf "$(pwd)/bin/dsh" ~/.local/bin/dsh` 安装的命令永远执行当前工作树。`pnpm run demo:tui` 运行同一入口。 -**个人配置(`dsh-app-boot`)。** 个人配置目录按 `$DSH_CONFIG_HOME`、其次 `$XDG_CONFIG_HOME/dsh`、最后 `~/.config/dsh` 解析(`resolvePersonalConfigDir`;空变量视为未设置)。dsh 的 TUI 表面消费其中两个可选文件;各示例 bin 仍然逐字节按已提交的配置树启动: +**个人配置(`dsh-app-boot`)。** 个人 overlay 存放在 Harness home——`$DSH_HOME`,否则 `~/.dsh`——由共享的 [`resolveDshHome`](../architecture/2026-07-24-single-harness-home-resolver.md)(`@deepseek-ai/dsh-paths`)解析,与 skills、AGENTS.md 解析所依据的单一根目录相同。dsh 的 TUI 表面消费其中两个可选文件;各示例 bin 仍然逐字节按已提交的配置树启动: - `.env`——在调用目录的 `.env` 之后加载;`process.loadEnvFile` 从不覆盖已有值,因此优先级为环境变量 > 项目 `.env` > 个人 `.env`。 - `config.yaml`——顶层 YAML 数组,元素为 `@cordisjs/plugin-include` 的 `PatchOptions`,用 include 自己的 `!!js` 方言解析(`loadPersonalPatches`)并传给 `boot()`,由它作为根 include 的 `patches` 转发。补丁语义与已提交 overlay 完全一致(Code Mode overlay 是模板):按 id 定位的补丁替换该配置项的整个 `config`,`insert` 追加配置项,未匹配的 id 记录警告并跳过。 - 文件缺失即无 overlay;文件存在但不可读、不可解析或非数组则在启动时抛出(配置错误响亮失败,绝不静默跳过)。 -PTY 冒烟测试的启动器把 `DSH_CONFIG_HOME` 隔离到每个测试自己的目录,与它已有的 `DSH_HOME`/`DSH_AGENTS_HOME` 隔离方式完全一致,开发者真实的个人 overlay 不可能泄漏进 fixture;只有 dsh CLI 读取个人配置,因此其他测试启动器无需改动。 +PTY 冒烟测试的启动器把 `$DSH_HOME` 隔离到每个测试自己的目录,与它已有的 `DSH_AGENTS_HOME` 隔离方式完全一致,开发者真实的个人 overlay 不可能泄漏进 fixture;只有 dsh CLI 读取个人配置,因此其他测试启动器无需改动。 与热重载的交互:include 在每次配置重读时重新应用其 `patches`(见[配置热重载韧性 Agent Note](../bug-fix/2026-07-20-config-hot-reload-resilience.md)),因此运行中编辑 `cordis.yml` 后个人 overlay 仍保持生效。 @@ -41,9 +41,9 @@ PTY 冒烟测试的启动器把 `DSH_CONFIG_HOME` 隔离到每个测试自己的 - 在任意目录运行 `dsh`(以及 `pnpm run demo:tui`)即可零仓库改动地使用个人提供方/模型;已针对个人 Anthropic 代理与 Opus 4.8 端到端验证,包括一次 bash 工具往返。 - 由于按 id 定位的补丁替换整个 `config`,个人覆盖必须复述它保留的基础字段,并可能随基础配置项形态变化而漂移;loader 的「配置项未找到/名称不匹配」警告是仅有的诊断。 - 个人补丁只在被启动文件自身的树里解析 id,因此嵌套 include 的 overlay(Code Mode)不会被个性化;这些叶子的实际运行等价性暂缓。 -- `dsh-app-boot` 新增一个真实依赖(`js-yaml`)和一份只用于加载的 include `!!js` YAML 类型副本。 +- `dsh-app-boot` 依赖 `js-yaml`(外加一份只用于加载的 include `!!js` YAML 类型副本),并与 `apps/cli` 一样依赖 `@deepseek-ai/dsh-paths` 以获取 `resolveDshHome`。 - PR #443 落地时,`apps/cli/src/bin.ts` 的分发链与 `apps/cli/package.json` 的依赖列表会产生文本冲突;两者都按并集解决(他们的 `web`/`-p` 分支加上我们的默认 TUI 分支)。 ## Testing -`packages/ui/app-boot/tests/personal-config.spec.ts` 固定目录优先级(含空变量回退)、`!!js` 的保留与经真实启动树的端到端插值、insert 配置项、缺失/为空的无操作路径,以及三种响亮失败形态(不可读、不可解析、非数组)。`examples/tui-agent/tests/tui-keyless-smoke.e2e.ts` 在 PTY 里以三种方式启动 dsh bin:无 overlay 的默认配置、个人 `.env` + `config.yaml` 链条(打补丁的欢迎语渲染进横幅)、以及无效个人文件导致的响亮启动失败。既有冒烟与快照套件在一台真实 `~/.config/dsh` overlay 会改变启动模型的机器上通过——靠隔离,不靠运气。 +`packages/ui/app-boot/tests/personal-config.spec.ts` 固定 `!!js` 的保留与经真实启动树的端到端插值、insert 配置项、默认目录从 `$DSH_HOME` 解析、缺失/为空的无操作路径,以及三种响亮失败形态(不可读、不可解析、非数组)。`examples/tui-agent/tests/tui-keyless-smoke.e2e.ts` 在 PTY 里以三种方式启动 dsh bin:无 overlay 的默认配置、个人 `.env` + `config.yaml` 链条(打补丁的欢迎语渲染进横幅)、以及无效个人文件导致的响亮启动失败。既有冒烟与快照套件在一台真实 `~/.dsh` overlay 会改变启动模型的机器上通过——靠隔离,不靠运气。 diff --git a/.agents/notes/implemented/simplification/2026-07-21-tui-todo-write-opt-in.i18n.yaml b/.agents/notes/implemented/simplification/2026-07-21-tui-todo-write-opt-in.i18n.yaml index 3d91d63789..4e0393bede 100644 --- a/.agents/notes/implemented/simplification/2026-07-21-tui-todo-write-opt-in.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-07-21-tui-todo-write-opt-in.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 -2026-07-21-tui-todo-write-opt-in.md: 30797568f2b37180f8a9c837cb4ae7f10b6125eb -2026-07-21-tui-todo-write-opt-in.zh.md: 29a779bbd765a42a12e8561c87375d742dd8bd4b +2026-07-21-tui-todo-write-opt-in.md: f89f76a462f4d30960254833ab71973f6a4f7655 +2026-07-21-tui-todo-write-opt-in.zh.md: f80d2639612819975f03aea9771019cd5237a2ee diff --git a/.agents/notes/implemented/simplification/2026-07-21-tui-todo-write-opt-in.md b/.agents/notes/implemented/simplification/2026-07-21-tui-todo-write-opt-in.md index 30797568f2..f89f76a462 100644 --- a/.agents/notes/implemented/simplification/2026-07-21-tui-todo-write-opt-in.md +++ b/.agents/notes/implemented/simplification/2026-07-21-tui-todo-write-opt-in.md @@ -10,7 +10,7 @@ The shipped tui-agent `cordis.yml` loaded `@deepseek-ai/dsh-tool-todo`, exposing ## Decision -The tui-agent `cordis.yml` no longer loads `tool-todo`; `todo_write` is opt-in. The `code-mode.cordis.yml` overlay inherits the base composition, so its generated SDK drops `todo_write` too. Enabling it is one entry — add `@deepseek-ai/dsh-tool-todo` to `cordis.yml` (or a `~/.config/dsh` personal overlay) — after which the model logs the whole-list `todo/write` snapshot and the TUI renders the plan, unchanged. The `TodoItem` type and the `todo/write` event stay in `@deepseek-ai/dsh-session` and the TUI's plan rendering stays wired, so both the default (disabled) and opt-in (enabled) paths are first-class. The sibling acp-agent, headless-agent, and jsonrpc-agent examples still ship the tool. +The tui-agent `cordis.yml` no longer loads `tool-todo`; `todo_write` is opt-in. The `code-mode.cordis.yml` overlay inherits the base composition, so its generated SDK drops `todo_write` too. Enabling it is one entry — add `@deepseek-ai/dsh-tool-todo` to `cordis.yml` (or a `~/.dsh` personal overlay) — after which the model logs the whole-list `todo/write` snapshot and the TUI renders the plan, unchanged. The `TodoItem` type and the `todo/write` event stay in `@deepseek-ai/dsh-session` and the TUI's plan rendering stays wired, so both the default (disabled) and opt-in (enabled) paths are first-class. The sibling acp-agent, headless-agent, and jsonrpc-agent examples still ship the tool. ## Alternatives considered diff --git a/.agents/notes/implemented/simplification/2026-07-21-tui-todo-write-opt-in.zh.md b/.agents/notes/implemented/simplification/2026-07-21-tui-todo-write-opt-in.zh.md index 29a779bbd7..f80d263961 100644 --- a/.agents/notes/implemented/simplification/2026-07-21-tui-todo-write-opt-in.zh.md +++ b/.agents/notes/implemented/simplification/2026-07-21-tui-todo-write-opt-in.zh.md @@ -10,7 +10,7 @@ Status: implemented ## Decision -tui-agent `cordis.yml` 不再加载 `tool-todo`;`todo_write` 改为可选启用。`code-mode.cordis.yml` 覆盖配置继承基础组合,因此它生成的 SDK 同样不再包含 `todo_write`。启用它只需一条配置项——把 `@deepseek-ai/dsh-tool-todo` 加入 `cordis.yml`(或 `~/.config/dsh` 的个人覆盖配置)——此后模型照旧记录整份清单的 `todo/write` 快照,TUI 照旧渲染该计划。`TodoItem` 类型与 `todo/write` 事件仍留在 `@deepseek-ai/dsh-session`,TUI 的计划渲染也保持接线,因此默认(禁用)与可选启用(启用)两条路径都是一等公民。同类的 acp-agent、headless-agent、jsonrpc-agent 示例仍然出厂携带该工具。 +tui-agent `cordis.yml` 不再加载 `tool-todo`;`todo_write` 改为可选启用。`code-mode.cordis.yml` 覆盖配置继承基础组合,因此它生成的 SDK 同样不再包含 `todo_write`。启用它只需一条配置项——把 `@deepseek-ai/dsh-tool-todo` 加入 `cordis.yml`(或 `~/.dsh` 的个人覆盖配置)——此后模型照旧记录整份清单的 `todo/write` 快照,TUI 照旧渲染该计划。`TodoItem` 类型与 `todo/write` 事件仍留在 `@deepseek-ai/dsh-session`,TUI 的计划渲染也保持接线,因此默认(禁用)与可选启用(启用)两条路径都是一等公民。同类的 acp-agent、headless-agent、jsonrpc-agent 示例仍然出厂携带该工具。 ## Alternatives considered diff --git a/apps/cli/README.md b/apps/cli/README.md index 935b9804d9..b8ff616d59 100644 --- a/apps/cli/README.md +++ b/apps/cli/README.md @@ -8,7 +8,7 @@ The TUI surface: - resumes a persisted session with `dsh --resume ` — the form the TUI prints on exit and lists under `/resume`; the flag sets `RESUME_SESSION_ID` before boot so the shipped config rehydrates that session, and a missing or unreadable id fails loud and exits nonzero; - treats the **invoking directory** as the workspace — sessions, relative paths, and workspace instructions resolve from the cwd; - tells the agent where its own source lives: after boot it adds a prompt section naming this harness checkout, resolved from the launcher's real path so it holds under a PATH symlink and an arbitrary cwd, so the self-referential `cordis` toolset can read and modify it; -- applies the personal overlay from `~/.config/dsh` (see [app-boot's Personal config](../../packages/ui/app-boot/README.md#personal-config)): `.env` fills environment gaps (ambient > project `.env` > personal `.env`), `config.yaml` patches the booted tree. +- applies the personal overlay from `~/.dsh` (see [app-boot's Personal config](../../packages/ui/app-boot/README.md#personal-config)): `.env` fills environment gaps (ambient > project `.env` > personal `.env`), `config.yaml` patches the booted tree. ## Install (developer machine) diff --git a/apps/cli/package.json b/apps/cli/package.json index bae172303a..a8fa488a98 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -1,6 +1,6 @@ { "name": "@deepseek-ai/dsh", - "description": "dsh CLI: the interactive TUI coding agent, booting the shipped default config with the personal overlay from ~/.config/dsh", + "description": "dsh CLI: the interactive TUI coding agent, booting the shipped default config with the personal overlay from ~/.dsh", "version": "0.0.1", "private": true, "type": "module", @@ -13,6 +13,7 @@ ], "license": "BSD-3-Clause", "dependencies": { - "@deepseek-ai/dsh-app-boot": "workspace:^" + "@deepseek-ai/dsh-app-boot": "workspace:^", + "@deepseek-ai/dsh-paths": "workspace:^" } } diff --git a/apps/cli/src/tui.ts b/apps/cli/src/tui.ts index befe954806..6f97a68ad3 100644 --- a/apps/cli/src/tui.ts +++ b/apps/cli/src/tui.ts @@ -1,8 +1,8 @@ /** * `dsh` default surface — the interactive TUI coding agent. Boots the shipped * tui-agent config (or an explicit config argument) with the personal overlay - * from `~/.config/dsh`: its `.env` fills environment gaps (precedence: ambient - * environment, then the invoking directory's `.env`, then the personal one) + * from the Harness home (`~/.dsh`): its `.env` fills environment gaps (precedence: + * ambient environment, then the invoking directory's `.env`, then the personal one) * and its `config.yaml` patches the booted tree. The workspace is the invoking * directory: sessions, relative paths, and workspace instructions resolve from * the cwd, so `dsh` acts on whatever project it is launched in. After boot, the @@ -20,8 +20,8 @@ import { loadPersonalPatches, parseResumeArg, resolveConfigPath, - resolvePersonalConfigDir, } from '@deepseek-ai/dsh-app-boot' +import { resolveDshHome } from '@deepseek-ai/dsh-paths' const NAME = 'dsh' @@ -60,7 +60,7 @@ export async function runTui(argv: string[]): Promise { installFailLoud(NAME) // The bin already loaded the invoking directory's .env; the personal .env // only fills what is still unset (process.loadEnvFile never overrides). - loadEnv(NAME, resolvePersonalConfigDir()) + loadEnv(NAME, resolveDshHome()) // An explicit `--resume` flag beats any ambient RESUME_SESSION_ID, so set it // after loadEnv and before boot reads it through the config's `!!js`. const { resumeSessionId, rest } = parseResumeArg(argv) diff --git a/apps/cli/tsconfig.json b/apps/cli/tsconfig.json index c0f389409b..394a475c8f 100644 --- a/apps/cli/tsconfig.json +++ b/apps/cli/tsconfig.json @@ -10,6 +10,9 @@ "references": [ { "path": "../../packages/ui/app-boot" + }, + { + "path": "../../packages/util/paths" } ] } diff --git a/packages/ui/app-boot/tests/personal-config.spec.ts b/packages/ui/app-boot/tests/personal-config.spec.ts index de29c6d0bf..5d72238cfa 100644 --- a/packages/ui/app-boot/tests/personal-config.spec.ts +++ b/packages/ui/app-boot/tests/personal-config.spec.ts @@ -1,48 +1,27 @@ /** - * Personal-config behavior of `dsh-app-boot`: the `~/.config/dsh` directory - * resolution, the `config.yaml` overlay loader, and `boot()` applying the - * personal overlay over a real Loader tree. + * Personal-config behavior of `dsh-app-boot`: the Harness home (`~/.dsh`) + * `config.yaml` overlay loader and `boot()` applying the personal overlay over + * a real Loader tree. */ import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' -import { join, resolve, sep } from 'node:path' +import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' import type { Context } from 'cordis' import { boot, - DSH_CONFIG_HOME_ENV, loadPersonalPatches, PERSONAL_CONFIG_FILENAME, - resolvePersonalConfigDir, } from '../src/index.ts' const NAME = 'dsh-test-bin' const tmp = (): string => mkdtempSync(join(tmpdir(), 'dsh-personal-config-')) -describe('resolvePersonalConfigDir', () => { - it('prefers $DSH_CONFIG_HOME, then $XDG_CONFIG_HOME/dsh, then ~/.config/dsh', () => { - const home = `${sep}home${sep}user` - expect(resolvePersonalConfigDir({ DSH_CONFIG_HOME: `${sep}explicit`, XDG_CONFIG_HOME: `${sep}xdg` }, home)) - .toBe(resolve(`${sep}explicit`)) - expect(resolvePersonalConfigDir({ XDG_CONFIG_HOME: `${sep}xdg` }, home)) - .toBe(resolve(`${sep}xdg`, 'dsh')) - expect(resolvePersonalConfigDir({}, home)).toBe(resolve(home, '.config', 'dsh')) - }) - - it('treats empty variables as unset and defaults to the real env and home', () => { - const home = `${sep}home${sep}user` - expect(resolvePersonalConfigDir({ DSH_CONFIG_HOME: '', XDG_CONFIG_HOME: '' }, home)) - .toBe(resolve(home, '.config', 'dsh')) - // Default-arg arm: resolves against the ambient environment without throwing. - expect(resolvePersonalConfigDir().length).toBeGreaterThan(0) - }) -}) - describe('loadPersonalPatches', () => { afterEach(() => { - delete process.env.DSH_CONFIG_HOME + delete process.env.DSH_HOME }) it('returns undefined when no personal patches file exists', () => { @@ -70,10 +49,10 @@ describe('loadPersonalPatches', () => { expect(patches?.[1]?.insert).toHaveLength(1) }) - it('defaults its directory to the resolved personal config dir', () => { + it('defaults its directory to the Harness home ($DSH_HOME)', () => { const dir = tmp() writeFileSync(join(dir, PERSONAL_CONFIG_FILENAME), '- id: x\n config:\n a: 1\n') - process.env[DSH_CONFIG_HOME_ENV] = dir + process.env.DSH_HOME = dir expect(loadPersonalPatches(NAME)).toHaveLength(1) }) diff --git a/scripts/install.sh b/scripts/install.sh index d54c52bad3..cb3b04ead4 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -6,8 +6,8 @@ # It clones the harness to ~/.dsh/source, checks host dependencies (git, Node, # pnpm) and offers to install a missing pnpm, runs `pnpm install` (no build — # the `bin/dsh` launcher runs the TypeScript source through the repo's own tsx), -# symlinks `dsh` onto PATH, records your API credentials in the personal config -# dir dsh reads at boot, and drops you into `dsh`. +# symlinks `dsh` onto PATH, records your API credentials in the Harness home +# (`~/.dsh`) dsh reads at boot, and drops you into `dsh`. # # When run from inside an existing checkout (e.g. `sh scripts/install.sh` rather # than `curl ... | sh`) it reuses that checkout and skips the clone/update, leaving @@ -23,7 +23,7 @@ # DSH_REPO clone URL (default: the GitHub repo) # DSH_SOURCE checkout location (default: ~/.dsh/source) # DSH_BIN_DIR directory the `dsh` symlink lands in (default: ~/.local/bin) -# DSH_CONFIG_HOME personal config dir (also XDG_CONFIG_HOME/dsh, ~/.config/dsh) +# DSH_HOME Harness home holding the personal config (default: ~/.dsh) set -eu DSH_REF=${DSH_REF:-master} @@ -224,13 +224,11 @@ if [ "$ON_PATH" = 0 ]; then fi # --- 5. credentials ------------------------------------------------------------ -# Mirror app-boot's resolvePersonalConfigDir precedence so creds land where dsh reads them. -if [ -n "${DSH_CONFIG_HOME:-}" ]; then - CONF="$DSH_CONFIG_HOME" -elif [ -n "${XDG_CONFIG_HOME:-}" ]; then - CONF="$XDG_CONFIG_HOME/dsh" +# Mirror app-boot's resolveDshHome precedence ($DSH_HOME, else ~/.dsh) so creds land where dsh reads them. +if [ -n "${DSH_HOME:-}" ]; then + CONF="$DSH_HOME" else - CONF="$HOME/.config/dsh" + CONF="$HOME/.dsh" fi ENV_FILE="$CONF/.env" From 30c7e76ea5f9d4c119f5945e7626f0ba2d16eec3 Mon Sep 17 00:00:00 2001 From: Turtle Date: Wed, 22 Jul 2026 12:38:16 +0800 Subject: [PATCH 48/90] fix(tui): reconcile master's model selector and session titles with the staging footer and status line Post-rebase reconciliation of the two TUI lines that evolved in parallel: - header subtitle prefers the latest logged session title over the configured welcome; the process-local auto-title owns the whole terminal title while a logged session/title still wins through the suffixed form - footer keeps staging's model/cwd/usage/cache layout and gains master's context-percent segment; per-step usage dedup carries cache buckets - test harness only stubs the llm catalog when the test did not mount the real LlmService, and defaults the TUI clock to the real Date.now - the plugin-shaped /reload test composes commands+llm like the shipped app --- packages/ui/tui/src/index.ts | 17 ++++++------ packages/ui/tui/tests/harness.ts | 35 +++++++++++++++---------- packages/ui/tui/tests/tui.spec.ts | 43 ++++++++++++++++++++++--------- 3 files changed, 60 insertions(+), 35 deletions(-) diff --git a/packages/ui/tui/src/index.ts b/packages/ui/tui/src/index.ts index 0b93f32660..f56803adec 100644 --- a/packages/ui/tui/src/index.ts +++ b/packages/ui/tui/src/index.ts @@ -508,7 +508,7 @@ class HeaderComponent implements Component { constructor( private readonly agent: Agent, - private readonly welcome: string | undefined, + private readonly subtitle: () => string | undefined, private readonly palette: Palette, private readonly gradient: boolean, private readonly currentModel: () => string | undefined, @@ -529,9 +529,10 @@ class HeaderComponent implements Component { const title = `${name} ${this.palette.bold('HARNESS')}` const model = displayText(this.currentModel() ?? 'model unset') const detail = `${model} • ${displayText(this.agent.session.id)}` + const subtitle = this.subtitle() const lines = [ title, - ...this.welcome === undefined ? [] : [this.palette.muted(displayText(this.welcome))], + ...subtitle === undefined ? [] : [this.palette.muted(displayText(subtitle))], this.palette.dim(detail), ] .flatMap(line => wrapTextWithAnsi(line, usable)) @@ -1380,7 +1381,7 @@ export function createTuiChat( let sessionTitle = foldSessionTitle(agent.session.events)?.title const header = new HeaderComponent( agent, - config.welcome, + () => sessionTitle ?? config.welcome, palette, resolved.color && resolved.truecolor, () => target.current?.model, @@ -1555,12 +1556,10 @@ export function createTuiChat( const assembler = new BlockAssembler() for await (const chunk of llm.stream(options)) assembler.push(chunk) const title = titleLine(contentText(assembler.message().content)) - if (!disposed && title.length > 0) { - sessionTitle = title - header.invalidate() - updateTerminalTitle() - requestRender() - } + // Unlike a logged `session/title` (which suffixes the product title), the + // process-local auto-title owns the whole terminal title. A logged title + // arriving later still wins through `updateTerminalTitle`. + if (!disposed && title.length > 0) runtime.terminal.setTitle(displayText(title)) } void applyTitle().catch(ignoreTitleFailure) } diff --git a/packages/ui/tui/tests/harness.ts b/packages/ui/tui/tests/harness.ts index 6592a251a9..19e02502f2 100644 --- a/packages/ui/tui/tests/harness.ts +++ b/packages/ui/tui/tests/harness.ts @@ -79,19 +79,6 @@ export async function createTuiTestHarness ({ ...provider })) - }, - listModels(provider: string) { - return catalog.listModels?.(provider) - ?? Promise.resolve(catalog.models.filter(model => model.provider === provider).map(model => ({ ...model }))) - }, - resolveModelContext(provider: string, model: string) { - return catalog.resolveModelContext?.(provider, model) - ?? Promise.resolve({ contextWindow: options.contextWindow ?? 128_000 }) - }, - } as never) ctx.provide('tokenMeter', { measure() { return { totalTokens: options.contextTokens ?? 0 } @@ -107,6 +94,23 @@ export async function createTuiTestHarness ({ ...provider })) + }, + listModels(provider: string) { + return catalog.listModels?.(provider) + ?? Promise.resolve(catalog.models.filter(model => model.provider === provider).map(model => ({ ...model }))) + }, + resolveModelContext(provider: string, model: string) { + return catalog.resolveModelContext?.(provider, model) + ?? Promise.resolve({ contextWindow: options.contextWindow ?? 128_000 }) + }, + } as never) + } if (ctx.get('systemPrompt') === undefined) await ctx.plugin(SystemPrompt) if (options.sessionPersistence !== undefined) { ctx.provide('sessionPersistence', options.sessionPersistence as never) @@ -156,7 +160,10 @@ export async function createTuiTestHarness 0), + // Default to the real clock (runtime.now falls back to Date.now) so the + // elapsed-status suites can drive time via timers or Date.now spies; a + // test pins the clock only by passing `now` explicitly. + ...(options.now === undefined ? {} : { now: options.now }), ...(options.formatCwd === undefined ? {} : { formatCwd: options.formatCwd }), }) return { ctx, session, agent, terminal, exit, controller } diff --git a/packages/ui/tui/tests/tui.spec.ts b/packages/ui/tui/tests/tui.spec.ts index 00a8f25ab7..af988bc78c 100644 --- a/packages/ui/tui/tests/tui.spec.ts +++ b/packages/ui/tui/tests/tui.spec.ts @@ -124,6 +124,15 @@ function provideTokenMeter(ctx: Context): void { } as never) } +/** Minimal advisory-catalog llm stub for tests composing their own context. */ +function provideLlmCatalog(ctx: Context): void { + ctx.provide('llm', { + listProviders: () => [], + listModels: () => Promise.resolve([]), + resolveModelContext: () => Promise.resolve(undefined), + } as never) +} + describe('TUI config', () => { it('defaults every direct-call TUI option', () => { expect(resolveTuiConfig(undefined)).toEqual({ @@ -320,6 +329,9 @@ describe('pi-tui chat lifecycle and transcript', () => { const result = await setup({ contextWindow: 100, contextTokens: 42, + // Short cwd: the footer clips its right (context/tools) segment first, + // and the default worktree path would swallow it at 88 columns. + cwd: '/opt', now: () => now, beforeMount(session) { appendUser(session, 'restored prompt') @@ -346,13 +358,14 @@ describe('pi-tui chat lifecycle and transcript', () => { expect(result.terminal.output).toContain('restored answer') expect(result.terminal.output).toContain('write tests') expect(result.terminal.output).toContain('↑1.3k ↓42') - expect(result.terminal.output).toContain('42% context tools:compact deepseek-v4-flash(reasoning:on)') + // Context resolution is async (resolveModelContext); settle before reading. + await tick() + expect(result.terminal.output).toContain('42% context tools:collapsed') + // Narrow terminals clip the right-hand context/tools segment first; the + // model-led left segment stays. result.terminal.resize(52) await tick() - expect(result.terminal.output).toContain('42% context deepseek-v4-flash(reasoning:on)') - result.terminal.resize(65) - await tick() - expect(result.terminal.output).toContain('↑1.3k ↓42 42% context deepseek-v4-flash(reasoning:on)') + expect(result.terminal.output).toContain('deepseek-v4-flash') result.terminal.resize(88) await tick() @@ -462,7 +475,7 @@ describe('pi-tui chat lifecycle and transcript', () => { agentEvents(result.ctx, result.agent).emit('agent/status', 'idle') await tick() expect(result.terminal.output).toContain('↑1.8k ↓50') - expect(result.terminal.output).toContain('deepseek-v4-flash(reasoning:off)') + expect(result.terminal.output).toContain('deepseek-v4-flash') expect(result.terminal.progress.at(-1)).toBe(false) await dispose(result) expect(result.terminal.stopped).toBe(1) @@ -804,14 +817,15 @@ describe('pi-tui chat lifecycle and transcript', () => { expect(result.terminal.output).toContain('cache 0%') result.terminal.output = '' - // Warm call lands live: 5 uncached + 30 cache-read + 5 cache-write billed + // Warm call lands live on the next step (same-step usage replaces rather + // than accumulates): 5 uncached + 30 cache-read + 5 cache-write billed // input, so 30 of the 50 total prompt tokens are hits → 60%. appendAssistant(result.session, [{ type: 'text', text: 'warm' }], { inputTokens: 5, outputTokens: 5, cacheReadTokens: 30, cacheWriteTokens: 5, - }) + }, { turn: 1, step: 2 }) await tick() expect(result.terminal.output).toContain('cache 60%') expect(result.terminal.output).not.toContain('cache 0%') @@ -928,7 +942,7 @@ describe('pi-tui chat lifecycle and transcript', () => { expect(result.agent.steered).toEqual([]) initialContext.resolve({ contextWindow: 100 }) await tick() - expect(result.terminal.output).not.toContain('50% context tools:compact b1(reasoning:on)') + expect(result.terminal.output).not.toContain('50% context tools:collapsed') result.terminal.send('/model') result.terminal.send('\r') @@ -939,7 +953,8 @@ describe('pi-tui chat lifecycle and transcript', () => { result.agent.status = 'idle' result.ctx.emit('agent/status', result.agent, 'idle') await tick() - expect(result.terminal.output).toContain('25% context tools:compact b1(reasoning:on)') + expect(result.terminal.output).toContain('b1 ') + expect(result.terminal.output).toContain('25% context tools:collapsed') const assembly = await result.ctx.systemPrompt.assemble(assembleContextFor(result.agent)) expect(assembly.variables).toMatchObject({ provider: 'beta', model: 'b1' }) @@ -984,7 +999,8 @@ describe('pi-tui chat lifecycle and transcript', () => { unset.terminal.send('\r') await tick() expect(unset.terminal.output).toContain('Model selected: alpha/a1') - expect(unset.terminal.output).toContain('context unknown tools:compact a1(reasoning:on)') + expect(unset.terminal.output).toContain('a1 ') + expect(unset.terminal.output).not.toContain('% context') await dispose(unset) const empty = await setup({ agentOptions: {}, catalog: { providers: [], models: [] } }) @@ -1740,8 +1756,11 @@ describe('terminal mounting', () => { // `ctx.loader` proxy read would THROW `cannot get property without // inject` — only the non-throwing `ctx.get` lookup degrades gracefully. const ctx = new Context() + provideTokenMeter(ctx) + provideLlmCatalog(ctx) await ctx.plugin(SessionStore) await ctx.plugin(AgentRegistry) + await ctx.plugin(CommandService) await ctx.plugin(UserInteractionService) ctx.provide('tools', { get: () => undefined } as never) const session = ctx.sessions.create(SessionId('main')) @@ -1752,7 +1771,7 @@ describe('terminal mounting', () => { const terminal = new FakeTerminal() // Mirror dsh-tui's own inject (minus loader, the absence under test). await ctx.plugin({ - inject: ['agents', 'userInteraction', 'tools'], + inject: ['agents', 'commands', 'userInteraction', 'tools', 'llm', 'tokenMeter'], apply: (pluginCtx: Context) => { mountTui(pluginCtx, { color: false }, { terminal, exit: vi.fn() }) }, From dfd1eeeadb93be14789091a7db187b3c1e76a1c7 Mon Sep 17 00:00:00 2001 From: Turtle Date: Wed, 22 Jul 2026 12:40:32 +0800 Subject: [PATCH 49/90] test: align merged suites with master's cancel cause and optional welcome workspace-context's abort-tool test uses agent.cancel({kind:'user'}) (master's AgentCancelCause shape); tui-demo forwards no welcome when none is configured. --- .../context/workspace-context/tests/workspace-context.spec.ts | 2 +- packages/examples/tui-demo/tests/tui-agent.spec.ts | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/context/workspace-context/tests/workspace-context.spec.ts b/packages/context/workspace-context/tests/workspace-context.spec.ts index 1d329d2171..c02f1f5a45 100644 --- a/packages/context/workspace-context/tests/workspace-context.spec.ts +++ b/packages/context/workspace-context/tests/workspace-context.spec.ts @@ -1699,7 +1699,7 @@ describe('dynamic nested workspace context injection', () => { description: 'Abort the current test step.', parameters: {}, async execute() { - ;(agent as unknown as { currentAbort?: AbortController }).currentAbort?.abort('test abort') + agent.cancel({ kind: 'user' }) return [{ type: 'text', text: 'aborted' }] }, })) diff --git a/packages/examples/tui-demo/tests/tui-agent.spec.ts b/packages/examples/tui-demo/tests/tui-agent.spec.ts index 73aa61430a..d1cb0808a7 100644 --- a/packages/examples/tui-demo/tests/tui-agent.spec.ts +++ b/packages/examples/tui-demo/tests/tui-agent.spec.ts @@ -88,7 +88,8 @@ describe('dsh-tui-demo app', () => { }) expect(calls[2]?.config).toEqual({ root: './.sessions' }) - expect(calls[4]?.config).toEqual({ welcome: 'ready.', sessionId: 'persisted-session' }) + // No configured welcome forwards none: the TUI banner sweeps in without a subtitle. + expect(calls[4]?.config).toEqual({ sessionId: 'persisted-session' }) expect((calls[5]?.config as { agents: Array> }).agents[0]).toMatchObject({ id: 'main', resumeSessionId: 'persisted-session', From 7eec0d81ecf88376a0bf123c1ad85f66eaf639cf Mon Sep 17 00:00:00 2001 From: Turtle Date: Wed, 22 Jul 2026 12:56:31 +0800 Subject: [PATCH 50/90] test(snapshot): reconcile replay overlays with the merged configs and fixtures - acp/headless whole-config snapshot patches restate persistenceCompression: none so raw JSONL fixtures stay harvestable, and re-pin the recorded deepseek-v4-flash where the shipped config moved to pro - depth-two overlay gains the app-config re-pin master's other overlays carry - headless-agent stays on flash: goal/ralph overlays include it via a nested include, which a config patch cannot reach to re-pin - workspace-context scenarios re-record their session fixtures for the per-candidate scope keys and both-siblings loading; acp session fixtures otherwise return to master's (session/title + delegationDepth events) --- examples/acp-agent/cordis.snapshot.yml | 4 + .../acp-agent/depth-two.cordis.snapshot.yml | 16 + examples/acp-agent/fs.cordis.snapshot.yml | 1 + .../tests/snapshots/bash-spill/session.jsonl | 43 +- .../code-mode-workspace-context/session.jsonl | 375 ++++++++-------- .../escalation-approved/session.jsonl | 367 +++++++-------- .../escalation-rejected/session.jsonl | 421 +++++++++--------- .../fs-escalation-approved/session.jsonl | 245 +++++----- .../hook-cc-pretool-ask/session.jsonl | 227 +++++----- .../snapshots/lsp-definition/session.jsonl | 6 +- .../lsp-definition/system-prompt.expected.md | 2 +- .../snapshots/workspace-context/session.jsonl | 45 +- .../advanced.cordis.snapshot.yml | 3 + examples/headless-agent/cordis.yml | 5 +- .../bash-terminal-card/terminal.expected.txt | 8 +- .../snapshots/code-mode/terminal.expected.txt | 10 +- .../terminal.expected.txt | 8 +- .../dynamic-workflow/terminal.expected.txt | 8 +- .../terminal.expected.txt | 8 +- .../parallel-file-reads/terminal.expected.txt | 8 +- .../snapshots/todo-plan/terminal.expected.txt | 8 +- .../advanced-cards-collapsed.expected.txt | 19 +- .../advanced-cards-expanded.expected.txt | 4 +- .../snapshots/banner-gradient.expected.txt | 4 +- .../snapshots/code-mode-pending.expected.txt | 4 +- .../conversation-streaming.expected.txt | 24 +- .../cordis-tools-pending.expected.txt | 4 +- .../snapshots/disposed-terminal.expected.txt | 53 ++- .../dynamic-workflow-pending.expected.txt | 7 +- .../snapshots/errors-and-help.expected.txt | 53 ++- .../snapshots/model-selector.expected.txt | 40 +- .../snapshots/model-switching.expected.txt | 46 +- .../question-dialog-validation.expected.txt | 74 ++- .../snapshots/question-dialog.expected.txt | 68 ++- .../snapshots/resume-sessions.expected.txt | 4 +- .../snapshots/retry-cancelled.expected.txt | 60 ++- .../snapshots/retry-exhausted.expected.txt | 56 +-- .../snapshots/retry-recovered.expected.txt | 62 ++- .../snapshots/retry-scheduled.expected.txt | 56 +-- ...surface-after-compaction-wide.expected.txt | 4 +- .../surface-before-compaction.expected.txt | 7 +- .../snapshots/untrusted-controls.expected.txt | 63 +-- 42 files changed, 1245 insertions(+), 1285 deletions(-) diff --git a/examples/acp-agent/cordis.snapshot.yml b/examples/acp-agent/cordis.snapshot.yml index 94ebf29ea6..2838127d52 100644 --- a/examples/acp-agent/cordis.snapshot.yml +++ b/examples/acp-agent/cordis.snapshot.yml @@ -25,6 +25,10 @@ provider: deepseek model: deepseek-v4-flash persistenceRoot: !!js process.env.DSH_SNAPSHOT_SESSIONS_ROOT ?? './.sessions' + # Replay fixtures are raw JSONL; the whole-config patch must restate + # the compression choice or the default zstd frames hide the logs + # from the harness's harvest. + persistenceCompression: none workspaceContext: maxBytes: 65536 persona: | diff --git a/examples/acp-agent/depth-two.cordis.snapshot.yml b/examples/acp-agent/depth-two.cordis.snapshot.yml index 16aa6f587c..d92a3cd304 100644 --- a/examples/acp-agent/depth-two.cordis.snapshot.yml +++ b/examples/acp-agent/depth-two.cordis.snapshot.yml @@ -24,6 +24,22 @@ provider: spawn toolName: subagent maxDepth: 2 + # Re-pin the recorded model: cordis.yml ships deepseek-v4-pro, but this + # scenario's corpus was captured on flash. A config patch replaces the + # whole app config, so the base fields are restated verbatim. + - id: acp-agent + name: '@deepseek-ai/dsh-acp-demo' + config: + provider: deepseek + model: deepseek-v4-flash + persistenceRoot: !!js process.env.DSH_SNAPSHOT_SESSIONS_ROOT ?? './.sessions' + persistenceCompression: none + workspaceContext: + maxBytes: 65536 + persona: | + You are a coding assistant powered by the {{model}} model. Your working directory is {{cwd}}. Your bash tool runs under a file sandbox — a `[sandbox: file access denied …]` result is policy, not a command bug. + + Verify your work by running the code or tests. Keep answers brief and factual. - insert: - id: llm-replay name: '@deepseek-ai/dsh-llm-replay' diff --git a/examples/acp-agent/fs.cordis.snapshot.yml b/examples/acp-agent/fs.cordis.snapshot.yml index f0e65fa81e..0417074edd 100644 --- a/examples/acp-agent/fs.cordis.snapshot.yml +++ b/examples/acp-agent/fs.cordis.snapshot.yml @@ -18,6 +18,7 @@ provider: deepseek model: deepseek-v4-flash persistenceRoot: !!js process.env.DSH_SNAPSHOT_SESSIONS_ROOT ?? './.sessions' + persistenceCompression: none workspaceContext: maxBytes: 65536 persona: | diff --git a/examples/acp-agent/tests/snapshots/bash-spill/session.jsonl b/examples/acp-agent/tests/snapshots/bash-spill/session.jsonl index f6e57ac89a..eb4488858e 100644 --- a/examples/acp-agent/tests/snapshots/bash-spill/session.jsonl +++ b/examples/acp-agent/tests/snapshots/bash-spill/session.jsonl @@ -1,23 +1,24 @@ -{"type":"session","version":0,"id":"{{sessionId}}","createdAt":0,"cwd":"{{cwd}}"} +{"type":"session","version":0,"id":"{{sessionId}}","createdAt":0,"cwd":"{{cwd}}","delegationDepth":0} {"type":"turn/start","seq":0,"time":0,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":0,"data":{"content":[{"type":"text","text":"Use the bash tool to print a large deterministic output, then reply DONE."}],"source":{"kind":"user"}},"surfaceOp":"append"} -{"type":"step/start","seq":2,"time":0,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":3,"time":0,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} -{"type":"assistant/chunk","seq":4,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":5,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"call_spill","name":"bash","argumentsDelta":"{\"command\":\"node -e \\\"process.stdout.write('SPILL_START-' + 'x'.repeat(2000) + '-SPILL_END')\\\"\",\"description\":\"Print large deterministic output\"}"}}} -{"type":"assistant/chunk","seq":6,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_spill","name":"bash","arguments":"{\"command\":\"node -e \\\"process.stdout.write('SPILL_START-' + 'x'.repeat(2000) + '-SPILL_END')\\\"\",\"description\":\"Print large deterministic output\"}"}}}} -{"type":"assistant/chunk","seq":7,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} -{"type":"assistant/chunk","seq":8,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":9,"time":0,"data":{"turn":1,"step":1,"content":[{"type":"tool-call","id":"call_spill","name":"bash","arguments":"{\"command\":\"node -e \\\"process.stdout.write('SPILL_START-' + 'x'.repeat(2000) + '-SPILL_END')\\\"\",\"description\":\"Print large deterministic output\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[4,5,6,7,8],"surfaceOp":"append"} -{"type":"tool/call","seq":10,"time":0,"data":{"turn":1,"step":1,"callId":"call_spill","name":"bash","arguments":"{\"command\":\"node -e \\\"process.stdout.write('SPILL_START-' + 'x'.repeat(2000) + '-SPILL_END')\\\"\",\"description\":\"Print large deterministic output\"}"}} -{"type":"tool/result","seq":11,"time":0,"data":{"turn":1,"step":1,"callId":"call_spill","content":[{"type":"text","text":"SPILL_START-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx-SPILL_END\n\n(Omitted 1417 bytes. Full formatted result stored at: /tmp/dsh-acp-snapshot-spill/session-422e58e9a24d/2510ac97876a-bash.txt. Use read with offset/limit, or grep this path to search within it.)"}],"isError":false},"sourceEventSeqs":[10],"surfaceOp":"append"} -{"type":"step/end","seq":12,"time":0,"data":{"turn":1,"step":1}} -{"type":"step/start","seq":13,"time":0,"data":{"turn":1,"step":2}} -{"type":"assistant/chunk","seq":14,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} -{"type":"assistant/chunk","seq":15,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":0,"text":"DONE"}}} -{"type":"assistant/chunk","seq":16,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DONE"}}}} -{"type":"assistant/chunk","seq":17,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":2}}}} -{"type":"assistant/chunk","seq":18,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":19,"time":0,"data":{"turn":1,"step":2,"content":[{"type":"text","text":"DONE"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":10,"outputTokens":2}},"sourceEventSeqs":[14,15,16,17,18],"surfaceOp":"append"} -{"type":"step/end","seq":20,"time":0,"data":{"turn":1,"step":2}} -{"type":"turn/end","seq":21,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"session/title","seq":2,"time":0,"data":{"title":"Use the bash tool to","messageSeqs":[1],"source":{"kind":"fallback"}}} +{"type":"step/start","seq":3,"time":0,"data":{"turn":1,"step":1}} +{"type":"request/header","seq":4,"time":0,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"assistant/chunk","seq":5,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":6,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"call_spill","name":"bash","argumentsDelta":"{\"command\":\"node -e \\\"process.stdout.write('SPILL_START-' + 'x'.repeat(2000) + '-SPILL_END')\\\"\",\"description\":\"Print large deterministic output\"}"}}} +{"type":"assistant/chunk","seq":7,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_spill","name":"bash","arguments":"{\"command\":\"node -e \\\"process.stdout.write('SPILL_START-' + 'x'.repeat(2000) + '-SPILL_END')\\\"\",\"description\":\"Print large deterministic output\"}"}}}} +{"type":"assistant/chunk","seq":8,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} +{"type":"assistant/chunk","seq":9,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":10,"time":0,"data":{"turn":1,"step":1,"content":[{"type":"tool-call","id":"call_spill","name":"bash","arguments":"{\"command\":\"node -e \\\"process.stdout.write('SPILL_START-' + 'x'.repeat(2000) + '-SPILL_END')\\\"\",\"description\":\"Print large deterministic output\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[5,6,7,8,9],"surfaceOp":"append"} +{"type":"tool/call","seq":11,"time":0,"data":{"turn":1,"step":1,"callId":"call_spill","name":"bash","arguments":"{\"command\":\"node -e \\\"process.stdout.write('SPILL_START-' + 'x'.repeat(2000) + '-SPILL_END')\\\"\",\"description\":\"Print large deterministic output\"}"}} +{"type":"tool/result","seq":12,"time":0,"data":{"turn":1,"step":1,"callId":"call_spill","content":[{"type":"text","text":"SPILL_START-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx-SPILL_END\n\n(Omitted 1417 bytes. Full formatted result stored at: /tmp/dsh-acp-snap-ee77dff02/session-fbfcf2f560a0/1bddd2b64176-bash.txt. Use read with offset/limit, or grep this path to search within it.)"}],"isError":false},"sourceEventSeqs":[11],"surfaceOp":"append"} +{"type":"step/end","seq":13,"time":0,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":14,"time":0,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":15,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":16,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":0,"text":"DONE"}}} +{"type":"assistant/chunk","seq":17,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DONE"}}}} +{"type":"assistant/chunk","seq":18,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":2}}}} +{"type":"assistant/chunk","seq":19,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":20,"time":0,"data":{"turn":1,"step":2,"content":[{"type":"text","text":"DONE"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":10,"outputTokens":2}},"sourceEventSeqs":[15,16,17,18,19],"surfaceOp":"append"} +{"type":"step/end","seq":21,"time":0,"data":{"turn":1,"step":2}} +{"type":"turn/end","seq":22,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/code-mode-workspace-context/session.jsonl b/examples/acp-agent/tests/snapshots/code-mode-workspace-context/session.jsonl index f5b1712212..281970523c 100644 --- a/examples/acp-agent/tests/snapshots/code-mode-workspace-context/session.jsonl +++ b/examples/acp-agent/tests/snapshots/code-mode-workspace-context/session.jsonl @@ -1,189 +1,190 @@ -{"type":"session","version":0,"id":"65fbb8a6-624c-4d6a-bf5d-a7a7d14f2b49","createdAt":1783921765266,"cwd":"/var/folders/_g/59jgff8x2gqd39f5vy1wnbfc0000gn/T/acp-snap-cwd-uorU26"} +{"type":"session","version":0,"id":"65fbb8a6-624c-4d6a-bf5d-a7a7d14f2b49","createdAt":1783921765266,"cwd":"/var/folders/_g/59jgff8x2gqd39f5vy1wnbfc0000gn/T/acp-snap-cwd-uorU26","delegationDepth":0} {"type":"turn/start","seq":0,"time":1783921765269,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1783921765269,"data":{"content":[{"type":"text","text":"Using ONE run_code program, call tools.read on nested/task.txt. After the program finishes, answer the workspace handshake question using the newly discovered instructions: What is the Code Mode workspace handshake?"}],"source":{"kind":"user"}},"surfaceOp":"append"} -{"type":"step/start","seq":2,"time":1783921765275,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":3,"time":1783921765275,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}","messagePrefix":[{"role":"user","content":[{"type":"text","text":"\nThe following workspace instructions may be relevant to your work. Use them as guidance when applicable. More specific instructions take precedence over broader ones. They do not override system, developer, or direct user instructions.\n\nInstructions from: AGENTS.md\n\nWorkspace snapshot root instruction.\n\n"}]}]},"reason":"initial"}} -{"type":"assistant/chunk","seq":4,"time":1783921766287,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"assistant/chunk","seq":5,"time":1783921766287,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} -{"type":"assistant/chunk","seq":6,"time":1783921766483,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} -{"type":"assistant/chunk","seq":7,"time":1783921766519,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" wants"}}} -{"type":"assistant/chunk","seq":8,"time":1783921766520,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} -{"type":"assistant/chunk","seq":9,"time":1783921766520,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} -{"type":"assistant/chunk","seq":10,"time":1783921766520,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" write"}}} -{"type":"assistant/chunk","seq":11,"time":1783921766537,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" a"}}} -{"type":"assistant/chunk","seq":12,"time":1783921766538,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" single"}}} -{"type":"assistant/chunk","seq":13,"time":1783921766538,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" run"}}} -{"type":"assistant/chunk","seq":14,"time":1783921766573,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"_code"}}} -{"type":"assistant/chunk","seq":15,"time":1783921766573,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" program"}}} -{"type":"assistant/chunk","seq":16,"time":1783921766574,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" that"}}} -{"type":"assistant/chunk","seq":17,"time":1783921766574,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" reads"}}} -{"type":"assistant/chunk","seq":18,"time":1783921766598,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" a"}}} -{"type":"assistant/chunk","seq":19,"time":1783921766598,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" file"}}} -{"type":"assistant/chunk","seq":20,"time":1783921766599,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" called"}}} -{"type":"assistant/chunk","seq":21,"time":1783921766624,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" nested"}}} -{"type":"assistant/chunk","seq":22,"time":1783921766654,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"/t"}}} -{"type":"assistant/chunk","seq":23,"time":1783921766654,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ask"}}} -{"type":"assistant/chunk","seq":24,"time":1783921766654,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":".txt"}}} -{"type":"assistant/chunk","seq":25,"time":1783921766654,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} -{"type":"assistant/chunk","seq":26,"time":1783921766655,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" then"}}} -{"type":"assistant/chunk","seq":27,"time":1783921766684,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" answer"}}} -{"type":"assistant/chunk","seq":28,"time":1783921766717,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} -{"type":"assistant/chunk","seq":29,"time":1783921766717,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" hand"}}} -{"type":"assistant/chunk","seq":30,"time":1783921766717,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"shake"}}} -{"type":"assistant/chunk","seq":31,"time":1783921766743,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" question"}}} -{"type":"assistant/chunk","seq":32,"time":1783921766743,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" based"}}} -{"type":"assistant/chunk","seq":33,"time":1783921766743,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" on"}}} -{"type":"assistant/chunk","seq":34,"time":1783921766743,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" what"}}} -{"type":"assistant/chunk","seq":35,"time":1783921766776,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"'s"}}} -{"type":"assistant/chunk","seq":36,"time":1783921766776,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" in"}}} -{"type":"assistant/chunk","seq":37,"time":1783921766798,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" that"}}} -{"type":"assistant/chunk","seq":38,"time":1783921766799,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" file"}}} -{"type":"assistant/chunk","seq":39,"time":1783921766799,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} -{"type":"assistant/chunk","seq":40,"time":1783921766799,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" Let"}}} -{"type":"assistant/chunk","seq":41,"time":1783921766799,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} -{"type":"assistant/chunk","seq":42,"time":1783921766799,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" start"}}} -{"type":"assistant/chunk","seq":43,"time":1783921766845,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" by"}}} -{"type":"assistant/chunk","seq":44,"time":1783921766845,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" reading"}}} -{"type":"assistant/chunk","seq":45,"time":1783921766845,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} -{"type":"assistant/chunk","seq":46,"time":1783921766845,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" file"}}} -{"type":"assistant/chunk","seq":47,"time":1783921766845,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} -{"type":"assistant/chunk","seq":48,"time":1783921766926,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":49,"time":1783921766926,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6APApmaKLRDlXKMdIcWL5139","name":"run_code","argumentsDelta":""}}} -{"type":"assistant/chunk","seq":50,"time":1783921766952,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6APApmaKLRDlXKMdIcWL5139","name":"run_code","argumentsDelta":"{"}}} -{"type":"assistant/chunk","seq":51,"time":1783921766952,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6APApmaKLRDlXKMdIcWL5139","name":"run_code","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":52,"time":1783921766952,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6APApmaKLRDlXKMdIcWL5139","name":"run_code","argumentsDelta":"code"}}} -{"type":"assistant/chunk","seq":53,"time":1783921766952,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6APApmaKLRDlXKMdIcWL5139","name":"run_code","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":54,"time":1783921766952,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6APApmaKLRDlXKMdIcWL5139","name":"run_code","argumentsDelta":": "}}} -{"type":"assistant/chunk","seq":55,"time":1783921766978,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6APApmaKLRDlXKMdIcWL5139","name":"run_code","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":56,"time":1783921766978,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6APApmaKLRDlXKMdIcWL5139","name":"run_code","argumentsDelta":"const"}}} -{"type":"assistant/chunk","seq":57,"time":1783921766978,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6APApmaKLRDlXKMdIcWL5139","name":"run_code","argumentsDelta":" content"}}} -{"type":"assistant/chunk","seq":58,"time":1783921767011,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6APApmaKLRDlXKMdIcWL5139","name":"run_code","argumentsDelta":" ="}}} -{"type":"assistant/chunk","seq":59,"time":1783921767011,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6APApmaKLRDlXKMdIcWL5139","name":"run_code","argumentsDelta":" await"}}} -{"type":"assistant/chunk","seq":60,"time":1783921767011,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6APApmaKLRDlXKMdIcWL5139","name":"run_code","argumentsDelta":" tools"}}} -{"type":"assistant/chunk","seq":61,"time":1783921767011,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6APApmaKLRDlXKMdIcWL5139","name":"run_code","argumentsDelta":".read"}}} -{"type":"assistant/chunk","seq":62,"time":1783921767011,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6APApmaKLRDlXKMdIcWL5139","name":"run_code","argumentsDelta":"({"}}} -{"type":"assistant/chunk","seq":63,"time":1783921767050,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6APApmaKLRDlXKMdIcWL5139","name":"run_code","argumentsDelta":" file"}}} -{"type":"assistant/chunk","seq":64,"time":1783921767050,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6APApmaKLRDlXKMdIcWL5139","name":"run_code","argumentsDelta":"_path"}}} -{"type":"assistant/chunk","seq":65,"time":1783921767050,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6APApmaKLRDlXKMdIcWL5139","name":"run_code","argumentsDelta":":"}}} -{"type":"assistant/chunk","seq":66,"time":1783921767050,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6APApmaKLRDlXKMdIcWL5139","name":"run_code","argumentsDelta":" \\\""}}} -{"type":"assistant/chunk","seq":67,"time":1783921767050,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6APApmaKLRDlXKMdIcWL5139","name":"run_code","argumentsDelta":"n"}}} -{"type":"assistant/chunk","seq":68,"time":1783921767050,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6APApmaKLRDlXKMdIcWL5139","name":"run_code","argumentsDelta":"ested"}}} -{"type":"assistant/chunk","seq":69,"time":1783921767068,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6APApmaKLRDlXKMdIcWL5139","name":"run_code","argumentsDelta":"/t"}}} -{"type":"assistant/chunk","seq":70,"time":1783921767068,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6APApmaKLRDlXKMdIcWL5139","name":"run_code","argumentsDelta":"ask"}}} -{"type":"assistant/chunk","seq":71,"time":1783921767068,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6APApmaKLRDlXKMdIcWL5139","name":"run_code","argumentsDelta":".txt"}}} -{"type":"assistant/chunk","seq":72,"time":1783921767068,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6APApmaKLRDlXKMdIcWL5139","name":"run_code","argumentsDelta":"\\\""}}} -{"type":"assistant/chunk","seq":73,"time":1783921767068,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6APApmaKLRDlXKMdIcWL5139","name":"run_code","argumentsDelta":" });\\n"}}} -{"type":"assistant/chunk","seq":74,"time":1783921767068,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6APApmaKLRDlXKMdIcWL5139","name":"run_code","argumentsDelta":"return"}}} -{"type":"assistant/chunk","seq":75,"time":1783921767095,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6APApmaKLRDlXKMdIcWL5139","name":"run_code","argumentsDelta":" content"}}} -{"type":"assistant/chunk","seq":76,"time":1783921767095,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6APApmaKLRDlXKMdIcWL5139","name":"run_code","argumentsDelta":";"}}} -{"type":"assistant/chunk","seq":77,"time":1783921767095,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6APApmaKLRDlXKMdIcWL5139","name":"run_code","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":78,"time":1783921767121,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6APApmaKLRDlXKMdIcWL5139","name":"run_code","argumentsDelta":"}"}}} -{"type":"assistant/chunk","seq":79,"time":1783921767205,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to write a single run_code program that reads a file called nested/task.txt and then answer the handshake question based on what's in that file. Let me start by reading the file."}}}} -{"type":"assistant/chunk","seq":80,"time":1783921767205,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_6APApmaKLRDlXKMdIcWL5139","name":"run_code","arguments":"{\"code\": \"const content = await tools.read({ file_path: \\\"nested/task.txt\\\" });\\nreturn content;\"}"}}}} -{"type":"assistant/chunk","seq":81,"time":1783921767205,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3195,"outputTokens":106,"cacheReadTokens":0,"reasoningTokens":43}}}} -{"type":"assistant/chunk","seq":82,"time":1783921767205,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":83,"time":1783921767208,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to write a single run_code program that reads a file called nested/task.txt and then answer the handshake question based on what's in that file. Let me start by reading the file."},{"type":"tool-call","id":"call_00_6APApmaKLRDlXKMdIcWL5139","name":"run_code","arguments":"{\"code\": \"const content = await tools.read({ file_path: \\\"nested/task.txt\\\" });\\nreturn content;\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":3195,"outputTokens":106,"cacheReadTokens":0,"reasoningTokens":43}},"sourceEventSeqs":[4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82],"surfaceOp":"append"} -{"type":"tool/call","seq":84,"time":1783921767208,"data":{"turn":1,"step":1,"callId":"call_00_6APApmaKLRDlXKMdIcWL5139","name":"run_code","arguments":"{\"code\": \"const content = await tools.read({ file_path: \\\"nested/task.txt\\\" });\\nreturn content;\"}"}} -{"type":"tool/code-dispatch","seq":85,"time":1783921767270,"data":{"parentCallId":"call_00_6APApmaKLRDlXKMdIcWL5139","subCallId":"call_00_6APApmaKLRDlXKMdIcWL5139:code:1","name":"read","arguments":{"file_path":"nested/task.txt"},"isError":false,"resultSummary":"./nested/task.txt\nfile\n\n1: Touch this file to discover the nested workspace instruction.\n\n(End of file - total 1 lines)\n"}} -{"type":"tool/result","seq":86,"time":1783921767271,"data":{"turn":1,"step":1,"callId":"call_00_6APApmaKLRDlXKMdIcWL5139","content":[{"type":"text","text":"/var/folders/_g/59jgff8x2gqd39f5vy1wnbfc0000gn/T/acp-snap-cwd-uorU26/nested/task.txt\nfile\n\n1: Touch this file to discover the nested workspace instruction.\n\n(End of file - total 1 lines)\n"}],"isError":false,"meta":{"logs":[]}},"sourceEventSeqs":[84],"surfaceOp":"append"} -{"type":"context/message","seq":87,"time":1783921767272,"data":{"content":[{"type":"text","text":"\nAdditional instructions from: nested/AGENTS.md\n\nThese instructions apply to work under `nested`. Use them as guidance when relevant; more specific instructions take precedence. They do not override system, developer, or direct user instructions.\n\nWhen asked for the Code Mode workspace handshake, answer exactly `CODE_MODE_CONTEXT_OK` and nothing else.\n\n"}],"source":{"kind":"plugin","plugin":"workspace-context"},"meta":{"kind":"workspace-instructions","version":1,"changes":[{"action":"set","scope":"nested\u0000AGENTS.md","path":"nested/AGENTS.md","digest":"ae22936ed26dc76b7107005ed6d5e2482a88668a"}]}},"surfaceOp":"append"} -{"type":"step/end","seq":88,"time":1783921767272,"data":{"turn":1,"step":1}} -{"type":"step/start","seq":89,"time":1783921767272,"data":{"turn":1,"step":2}} -{"type":"assistant/chunk","seq":90,"time":1783921768339,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"assistant/chunk","seq":91,"time":1783921768340,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} -{"type":"assistant/chunk","seq":92,"time":1783921768466,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" nested"}}} -{"type":"assistant/chunk","seq":93,"time":1783921768474,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"/t"}}} -{"type":"assistant/chunk","seq":94,"time":1783921768500,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"ask"}}} -{"type":"assistant/chunk","seq":95,"time":1783921768501,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":".txt"}}} -{"type":"assistant/chunk","seq":96,"time":1783921768535,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" told"}}} -{"type":"assistant/chunk","seq":97,"time":1783921768535,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} -{"type":"assistant/chunk","seq":98,"time":1783921768535,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} -{"type":"assistant/chunk","seq":99,"time":1783921768535,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} -{"type":"assistant/chunk","seq":100,"time":1783921768564,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"Touch"}}} -{"type":"assistant/chunk","seq":101,"time":1783921768565,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" this"}}} -{"type":"assistant/chunk","seq":102,"time":1783921768565,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" file"}}} -{"type":"assistant/chunk","seq":103,"time":1783921768565,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} -{"type":"assistant/chunk","seq":104,"time":1783921768565,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" discover"}}} -{"type":"assistant/chunk","seq":105,"time":1783921768565,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} -{"type":"assistant/chunk","seq":106,"time":1783921768595,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" nested"}}} -{"type":"assistant/chunk","seq":107,"time":1783921768595,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" workspace"}}} -{"type":"assistant/chunk","seq":108,"time":1783921768595,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" instruction"}}} -{"type":"assistant/chunk","seq":109,"time":1783921768595,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":".\""}}} -{"type":"assistant/chunk","seq":110,"time":1783921768595,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} -{"type":"assistant/chunk","seq":111,"time":1783921768595,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} -{"type":"assistant/chunk","seq":112,"time":1783921768621,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" nested"}}} -{"type":"assistant/chunk","seq":113,"time":1783921768621,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"/"}}} -{"type":"assistant/chunk","seq":114,"time":1783921768647,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"AG"}}} -{"type":"assistant/chunk","seq":115,"time":1783921768648,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"ENTS"}}} -{"type":"assistant/chunk","seq":116,"time":1783921768648,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":".md"}}} -{"type":"assistant/chunk","seq":117,"time":1783921768648,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" instructions"}}} -{"type":"assistant/chunk","seq":118,"time":1783921768688,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" say"}}} -{"type":"assistant/chunk","seq":119,"time":1783921768703,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":":"}}} -{"type":"assistant/chunk","seq":120,"time":1783921768740,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} -{"type":"assistant/chunk","seq":121,"time":1783921768740,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"When"}}} -{"type":"assistant/chunk","seq":122,"time":1783921768741,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" asked"}}} -{"type":"assistant/chunk","seq":123,"time":1783921768741,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" for"}}} -{"type":"assistant/chunk","seq":124,"time":1783921768741,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} -{"type":"assistant/chunk","seq":125,"time":1783921768741,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" Code"}}} -{"type":"assistant/chunk","seq":126,"time":1783921768773,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" Mode"}}} -{"type":"assistant/chunk","seq":127,"time":1783921768773,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" workspace"}}} -{"type":"assistant/chunk","seq":128,"time":1783921768773,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" hand"}}} -{"type":"assistant/chunk","seq":129,"time":1783921768773,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"shake"}}} -{"type":"assistant/chunk","seq":130,"time":1783921768773,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":","}}} -{"type":"assistant/chunk","seq":131,"time":1783921768773,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" answer"}}} -{"type":"assistant/chunk","seq":132,"time":1783921768790,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" exactly"}}} -{"type":"assistant/chunk","seq":133,"time":1783921768790,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" `"}}} -{"type":"assistant/chunk","seq":134,"time":1783921768790,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"CODE"}}} -{"type":"assistant/chunk","seq":135,"time":1783921768790,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"_M"}}} -{"type":"assistant/chunk","seq":136,"time":1783921768790,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"ODE"}}} -{"type":"assistant/chunk","seq":137,"time":1783921768790,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"_CONT"}}} -{"type":"assistant/chunk","seq":138,"time":1783921768824,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"EXT"}}} -{"type":"assistant/chunk","seq":139,"time":1783921768825,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"_OK"}}} -{"type":"assistant/chunk","seq":140,"time":1783921768825,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"`"}}} -{"type":"assistant/chunk","seq":141,"time":1783921768825,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} -{"type":"assistant/chunk","seq":142,"time":1783921768825,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" nothing"}}} -{"type":"assistant/chunk","seq":143,"time":1783921768825,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" else"}}} -{"type":"assistant/chunk","seq":144,"time":1783921768846,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":".\"\n\n"}}} -{"type":"assistant/chunk","seq":145,"time":1783921768846,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"So"}}} -{"type":"assistant/chunk","seq":146,"time":1783921768847,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} -{"type":"assistant/chunk","seq":147,"time":1783921768847,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" answer"}}} -{"type":"assistant/chunk","seq":148,"time":1783921768847,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} -{"type":"assistant/chunk","seq":149,"time":1783921768873,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} -{"type":"assistant/chunk","seq":150,"time":1783921768874,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" hand"}}} -{"type":"assistant/chunk","seq":151,"time":1783921768903,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"shake"}}} -{"type":"assistant/chunk","seq":152,"time":1783921768903,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" question"}}} -{"type":"assistant/chunk","seq":153,"time":1783921768903,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" is"}}} -{"type":"assistant/chunk","seq":154,"time":1783921768903,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" `"}}} -{"type":"assistant/chunk","seq":155,"time":1783921768903,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"CODE"}}} -{"type":"assistant/chunk","seq":156,"time":1783921768929,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"_M"}}} -{"type":"assistant/chunk","seq":157,"time":1783921768930,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"ODE"}}} -{"type":"assistant/chunk","seq":158,"time":1783921768930,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"_CONT"}}} -{"type":"assistant/chunk","seq":159,"time":1783921768930,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"EXT"}}} -{"type":"assistant/chunk","seq":160,"time":1783921768930,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"_OK"}}} -{"type":"assistant/chunk","seq":161,"time":1783921768963,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"`."}}} -{"type":"assistant/chunk","seq":162,"time":1783921768963,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} -{"type":"assistant/chunk","seq":163,"time":1783921768963,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"The"}}} -{"type":"assistant/chunk","seq":164,"time":1783921768963,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" **"}}} -{"type":"assistant/chunk","seq":165,"time":1783921769024,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"Code"}}} -{"type":"assistant/chunk","seq":166,"time":1783921769024,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" Mode"}}} -{"type":"assistant/chunk","seq":167,"time":1783921769024,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" workspace"}}} -{"type":"assistant/chunk","seq":168,"time":1783921769024,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" hand"}}} -{"type":"assistant/chunk","seq":169,"time":1783921769024,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"shake"}}} -{"type":"assistant/chunk","seq":170,"time":1783921769024,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"**"}}} -{"type":"assistant/chunk","seq":171,"time":1783921769024,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" is"}}} -{"type":"assistant/chunk","seq":172,"time":1783921769024,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":":"}}} -{"type":"assistant/chunk","seq":173,"time":1783921769024,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" `"}}} -{"type":"assistant/chunk","seq":174,"time":1783921769024,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"CODE"}}} -{"type":"assistant/chunk","seq":175,"time":1783921769046,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"_M"}}} -{"type":"assistant/chunk","seq":176,"time":1783921769046,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"ODE"}}} -{"type":"assistant/chunk","seq":177,"time":1783921769047,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"_CONT"}}} -{"type":"assistant/chunk","seq":178,"time":1783921769047,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"EXT"}}} -{"type":"assistant/chunk","seq":179,"time":1783921769047,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"_OK"}}} -{"type":"assistant/chunk","seq":180,"time":1783921769047,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"`"}}} -{"type":"assistant/chunk","seq":181,"time":1783921769101,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The nested/task.txt told me to \"Touch this file to discover the nested workspace instruction.\" and the nested/AGENTS.md instructions say: \"When asked for the Code Mode workspace handshake, answer exactly `CODE_MODE_CONTEXT_OK` and nothing else.\"\n\nSo the answer to the handshake question is `CODE_MODE_CONTEXT_OK`."}}}} -{"type":"assistant/chunk","seq":182,"time":1783921769101,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"The **Code Mode workspace handshake** is: `CODE_MODE_CONTEXT_OK`"}}}} -{"type":"assistant/chunk","seq":183,"time":1783921769101,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":277,"outputTokens":90,"cacheReadTokens":3200,"reasoningTokens":71}}}} -{"type":"assistant/chunk","seq":184,"time":1783921769101,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":185,"time":1783921769101,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"The nested/task.txt told me to \"Touch this file to discover the nested workspace instruction.\" and the nested/AGENTS.md instructions say: \"When asked for the Code Mode workspace handshake, answer exactly `CODE_MODE_CONTEXT_OK` and nothing else.\"\n\nSo the answer to the handshake question is `CODE_MODE_CONTEXT_OK`."},{"type":"text","text":"The **Code Mode workspace handshake** is: `CODE_MODE_CONTEXT_OK`"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":277,"outputTokens":90,"cacheReadTokens":3200,"reasoningTokens":71}},"sourceEventSeqs":[90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132,133,134,135,136,137,138,139,140,141,142,143,144,145,146,147,148,149,150,151,152,153,154,155,156,157,158,159,160,161,162,163,164,165,166,167,168,169,170,171,172,173,174,175,176,177,178,179,180,181,182,183,184],"surfaceOp":"append"} -{"type":"step/end","seq":186,"time":1783921769101,"data":{"turn":1,"step":2}} -{"type":"turn/end","seq":187,"time":1783921769101,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"session/title","seq":2,"time":1783921765269,"data":{"title":"Using ONE run_code program, call","messageSeqs":[1],"source":{"kind":"fallback"}}} +{"type":"step/start","seq":3,"time":1783921765275,"data":{"turn":1,"step":1}} +{"type":"request/header","seq":4,"time":1783921765275,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}","messagePrefix":[{"role":"user","content":[{"type":"text","text":"\nThe following workspace instructions may be relevant to your work. Use them as guidance when applicable. More specific instructions take precedence over broader ones. They do not override system, developer, or direct user instructions.\n\nInstructions from: AGENTS.md\n\nWorkspace snapshot root instruction.\n\n"}]}]},"reason":"initial"}} +{"type":"assistant/chunk","seq":5,"time":1783921766287,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":6,"time":1783921766287,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":7,"time":1783921766483,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} +{"type":"assistant/chunk","seq":8,"time":1783921766519,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" wants"}}} +{"type":"assistant/chunk","seq":9,"time":1783921766520,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":10,"time":1783921766520,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":11,"time":1783921766520,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" write"}}} +{"type":"assistant/chunk","seq":12,"time":1783921766537,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" a"}}} +{"type":"assistant/chunk","seq":13,"time":1783921766538,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" single"}}} +{"type":"assistant/chunk","seq":14,"time":1783921766538,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" run"}}} +{"type":"assistant/chunk","seq":15,"time":1783921766573,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"_code"}}} +{"type":"assistant/chunk","seq":16,"time":1783921766573,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" program"}}} +{"type":"assistant/chunk","seq":17,"time":1783921766574,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" that"}}} +{"type":"assistant/chunk","seq":18,"time":1783921766574,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" reads"}}} +{"type":"assistant/chunk","seq":19,"time":1783921766598,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" a"}}} +{"type":"assistant/chunk","seq":20,"time":1783921766598,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" file"}}} +{"type":"assistant/chunk","seq":21,"time":1783921766599,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" called"}}} +{"type":"assistant/chunk","seq":22,"time":1783921766624,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" nested"}}} +{"type":"assistant/chunk","seq":23,"time":1783921766654,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"/t"}}} +{"type":"assistant/chunk","seq":24,"time":1783921766654,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ask"}}} +{"type":"assistant/chunk","seq":25,"time":1783921766654,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":".txt"}}} +{"type":"assistant/chunk","seq":26,"time":1783921766654,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} +{"type":"assistant/chunk","seq":27,"time":1783921766655,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" then"}}} +{"type":"assistant/chunk","seq":28,"time":1783921766684,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" answer"}}} +{"type":"assistant/chunk","seq":29,"time":1783921766717,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":30,"time":1783921766717,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" hand"}}} +{"type":"assistant/chunk","seq":31,"time":1783921766717,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"shake"}}} +{"type":"assistant/chunk","seq":32,"time":1783921766743,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" question"}}} +{"type":"assistant/chunk","seq":33,"time":1783921766743,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" based"}}} +{"type":"assistant/chunk","seq":34,"time":1783921766743,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" on"}}} +{"type":"assistant/chunk","seq":35,"time":1783921766743,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" what"}}} +{"type":"assistant/chunk","seq":36,"time":1783921766776,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"'s"}}} +{"type":"assistant/chunk","seq":37,"time":1783921766776,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" in"}}} +{"type":"assistant/chunk","seq":38,"time":1783921766798,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" that"}}} +{"type":"assistant/chunk","seq":39,"time":1783921766799,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" file"}}} +{"type":"assistant/chunk","seq":40,"time":1783921766799,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":41,"time":1783921766799,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" Let"}}} +{"type":"assistant/chunk","seq":42,"time":1783921766799,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":43,"time":1783921766799,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" start"}}} +{"type":"assistant/chunk","seq":44,"time":1783921766845,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" by"}}} +{"type":"assistant/chunk","seq":45,"time":1783921766845,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" reading"}}} +{"type":"assistant/chunk","seq":46,"time":1783921766845,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":47,"time":1783921766845,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" file"}}} +{"type":"assistant/chunk","seq":48,"time":1783921766845,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":49,"time":1783921766926,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":50,"time":1783921766926,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6APApmaKLRDlXKMdIcWL5139","name":"run_code","argumentsDelta":""}}} +{"type":"assistant/chunk","seq":51,"time":1783921766952,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6APApmaKLRDlXKMdIcWL5139","name":"run_code","argumentsDelta":"{"}}} +{"type":"assistant/chunk","seq":52,"time":1783921766952,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6APApmaKLRDlXKMdIcWL5139","name":"run_code","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":53,"time":1783921766952,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6APApmaKLRDlXKMdIcWL5139","name":"run_code","argumentsDelta":"code"}}} +{"type":"assistant/chunk","seq":54,"time":1783921766952,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6APApmaKLRDlXKMdIcWL5139","name":"run_code","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":55,"time":1783921766952,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6APApmaKLRDlXKMdIcWL5139","name":"run_code","argumentsDelta":": "}}} +{"type":"assistant/chunk","seq":56,"time":1783921766978,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6APApmaKLRDlXKMdIcWL5139","name":"run_code","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":57,"time":1783921766978,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6APApmaKLRDlXKMdIcWL5139","name":"run_code","argumentsDelta":"const"}}} +{"type":"assistant/chunk","seq":58,"time":1783921766978,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6APApmaKLRDlXKMdIcWL5139","name":"run_code","argumentsDelta":" content"}}} +{"type":"assistant/chunk","seq":59,"time":1783921767011,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6APApmaKLRDlXKMdIcWL5139","name":"run_code","argumentsDelta":" ="}}} +{"type":"assistant/chunk","seq":60,"time":1783921767011,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6APApmaKLRDlXKMdIcWL5139","name":"run_code","argumentsDelta":" await"}}} +{"type":"assistant/chunk","seq":61,"time":1783921767011,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6APApmaKLRDlXKMdIcWL5139","name":"run_code","argumentsDelta":" tools"}}} +{"type":"assistant/chunk","seq":62,"time":1783921767011,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6APApmaKLRDlXKMdIcWL5139","name":"run_code","argumentsDelta":".read"}}} +{"type":"assistant/chunk","seq":63,"time":1783921767011,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6APApmaKLRDlXKMdIcWL5139","name":"run_code","argumentsDelta":"({"}}} +{"type":"assistant/chunk","seq":64,"time":1783921767050,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6APApmaKLRDlXKMdIcWL5139","name":"run_code","argumentsDelta":" file"}}} +{"type":"assistant/chunk","seq":65,"time":1783921767050,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6APApmaKLRDlXKMdIcWL5139","name":"run_code","argumentsDelta":"_path"}}} +{"type":"assistant/chunk","seq":66,"time":1783921767050,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6APApmaKLRDlXKMdIcWL5139","name":"run_code","argumentsDelta":":"}}} +{"type":"assistant/chunk","seq":67,"time":1783921767050,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6APApmaKLRDlXKMdIcWL5139","name":"run_code","argumentsDelta":" \\\""}}} +{"type":"assistant/chunk","seq":68,"time":1783921767050,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6APApmaKLRDlXKMdIcWL5139","name":"run_code","argumentsDelta":"n"}}} +{"type":"assistant/chunk","seq":69,"time":1783921767050,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6APApmaKLRDlXKMdIcWL5139","name":"run_code","argumentsDelta":"ested"}}} +{"type":"assistant/chunk","seq":70,"time":1783921767068,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6APApmaKLRDlXKMdIcWL5139","name":"run_code","argumentsDelta":"/t"}}} +{"type":"assistant/chunk","seq":71,"time":1783921767068,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6APApmaKLRDlXKMdIcWL5139","name":"run_code","argumentsDelta":"ask"}}} +{"type":"assistant/chunk","seq":72,"time":1783921767068,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6APApmaKLRDlXKMdIcWL5139","name":"run_code","argumentsDelta":".txt"}}} +{"type":"assistant/chunk","seq":73,"time":1783921767068,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6APApmaKLRDlXKMdIcWL5139","name":"run_code","argumentsDelta":"\\\""}}} +{"type":"assistant/chunk","seq":74,"time":1783921767068,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6APApmaKLRDlXKMdIcWL5139","name":"run_code","argumentsDelta":" });\\n"}}} +{"type":"assistant/chunk","seq":75,"time":1783921767068,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6APApmaKLRDlXKMdIcWL5139","name":"run_code","argumentsDelta":"return"}}} +{"type":"assistant/chunk","seq":76,"time":1783921767095,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6APApmaKLRDlXKMdIcWL5139","name":"run_code","argumentsDelta":" content"}}} +{"type":"assistant/chunk","seq":77,"time":1783921767095,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6APApmaKLRDlXKMdIcWL5139","name":"run_code","argumentsDelta":";"}}} +{"type":"assistant/chunk","seq":78,"time":1783921767095,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6APApmaKLRDlXKMdIcWL5139","name":"run_code","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":79,"time":1783921767121,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6APApmaKLRDlXKMdIcWL5139","name":"run_code","argumentsDelta":"}"}}} +{"type":"assistant/chunk","seq":80,"time":1783921767205,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to write a single run_code program that reads a file called nested/task.txt and then answer the handshake question based on what's in that file. Let me start by reading the file."}}}} +{"type":"assistant/chunk","seq":81,"time":1783921767205,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_6APApmaKLRDlXKMdIcWL5139","name":"run_code","arguments":"{\"code\": \"const content = await tools.read({ file_path: \\\"nested/task.txt\\\" });\\nreturn content;\"}"}}}} +{"type":"assistant/chunk","seq":82,"time":1783921767205,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3195,"outputTokens":106,"cacheReadTokens":0,"reasoningTokens":43}}}} +{"type":"assistant/chunk","seq":83,"time":1783921767205,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":84,"time":1783921767208,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to write a single run_code program that reads a file called nested/task.txt and then answer the handshake question based on what's in that file. Let me start by reading the file."},{"type":"tool-call","id":"call_00_6APApmaKLRDlXKMdIcWL5139","name":"run_code","arguments":"{\"code\": \"const content = await tools.read({ file_path: \\\"nested/task.txt\\\" });\\nreturn content;\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":3195,"outputTokens":106,"cacheReadTokens":0,"reasoningTokens":43}},"sourceEventSeqs":[5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83],"surfaceOp":"append"} +{"type":"tool/call","seq":85,"time":1783921767208,"data":{"turn":1,"step":1,"callId":"call_00_6APApmaKLRDlXKMdIcWL5139","name":"run_code","arguments":"{\"code\": \"const content = await tools.read({ file_path: \\\"nested/task.txt\\\" });\\nreturn content;\"}"}} +{"type":"tool/code-dispatch","seq":86,"time":1783921767270,"data":{"parentCallId":"call_00_6APApmaKLRDlXKMdIcWL5139","subCallId":"call_00_6APApmaKLRDlXKMdIcWL5139:code:1","name":"read","arguments":{"file_path":"nested/task.txt"},"isError":false,"resultSummary":"./nested/task.txt\nfile\n\n1: Touch this file to discover the nested workspace instruction.\n\n(End of file - total 1 lines)\n"}} +{"type":"tool/result","seq":87,"time":1783921767271,"data":{"turn":1,"step":1,"callId":"call_00_6APApmaKLRDlXKMdIcWL5139","content":[{"type":"text","text":"/var/folders/_g/59jgff8x2gqd39f5vy1wnbfc0000gn/T/acp-snap-cwd-uorU26/nested/task.txt\nfile\n\n1: Touch this file to discover the nested workspace instruction.\n\n(End of file - total 1 lines)\n"}],"isError":false,"meta":{"logs":[]}},"sourceEventSeqs":[85],"surfaceOp":"append"} +{"type":"context/message","seq":88,"time":1783921767272,"data":{"content":[{"type":"text","text":"\nAdditional instructions from: nested/AGENTS.md\n\nThese instructions apply to work under `nested`. Use them as guidance when relevant; more specific instructions take precedence. They do not override system, developer, or direct user instructions.\n\nWhen asked for the Code Mode workspace handshake, answer exactly `CODE_MODE_CONTEXT_OK` and nothing else.\n\n"}],"source":{"kind":"plugin","plugin":"workspace-context"},"meta":{"kind":"workspace-instructions","version":1,"changes":[{"action":"set","scope":"nested\u0000AGENTS.md","path":"nested/AGENTS.md","digest":"ae22936ed26dc76b7107005ed6d5e2482a88668a"}]}},"surfaceOp":"append"} +{"type":"step/end","seq":89,"time":1783921767272,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":90,"time":1783921767272,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":91,"time":1783921768339,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":92,"time":1783921768340,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":93,"time":1783921768466,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" nested"}}} +{"type":"assistant/chunk","seq":94,"time":1783921768474,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"/t"}}} +{"type":"assistant/chunk","seq":95,"time":1783921768500,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"ask"}}} +{"type":"assistant/chunk","seq":96,"time":1783921768501,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":".txt"}}} +{"type":"assistant/chunk","seq":97,"time":1783921768535,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" told"}}} +{"type":"assistant/chunk","seq":98,"time":1783921768535,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":99,"time":1783921768535,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":100,"time":1783921768535,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":101,"time":1783921768564,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"Touch"}}} +{"type":"assistant/chunk","seq":102,"time":1783921768565,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" this"}}} +{"type":"assistant/chunk","seq":103,"time":1783921768565,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" file"}}} +{"type":"assistant/chunk","seq":104,"time":1783921768565,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":105,"time":1783921768565,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" discover"}}} +{"type":"assistant/chunk","seq":106,"time":1783921768565,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":107,"time":1783921768595,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" nested"}}} +{"type":"assistant/chunk","seq":108,"time":1783921768595,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" workspace"}}} +{"type":"assistant/chunk","seq":109,"time":1783921768595,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" instruction"}}} +{"type":"assistant/chunk","seq":110,"time":1783921768595,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":".\""}}} +{"type":"assistant/chunk","seq":111,"time":1783921768595,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} +{"type":"assistant/chunk","seq":112,"time":1783921768595,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":113,"time":1783921768621,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" nested"}}} +{"type":"assistant/chunk","seq":114,"time":1783921768621,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"/"}}} +{"type":"assistant/chunk","seq":115,"time":1783921768647,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"AG"}}} +{"type":"assistant/chunk","seq":116,"time":1783921768648,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"ENTS"}}} +{"type":"assistant/chunk","seq":117,"time":1783921768648,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":".md"}}} +{"type":"assistant/chunk","seq":118,"time":1783921768648,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" instructions"}}} +{"type":"assistant/chunk","seq":119,"time":1783921768688,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" say"}}} +{"type":"assistant/chunk","seq":120,"time":1783921768703,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":":"}}} +{"type":"assistant/chunk","seq":121,"time":1783921768740,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":122,"time":1783921768740,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"When"}}} +{"type":"assistant/chunk","seq":123,"time":1783921768741,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" asked"}}} +{"type":"assistant/chunk","seq":124,"time":1783921768741,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" for"}}} +{"type":"assistant/chunk","seq":125,"time":1783921768741,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":126,"time":1783921768741,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" Code"}}} +{"type":"assistant/chunk","seq":127,"time":1783921768773,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" Mode"}}} +{"type":"assistant/chunk","seq":128,"time":1783921768773,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" workspace"}}} +{"type":"assistant/chunk","seq":129,"time":1783921768773,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" hand"}}} +{"type":"assistant/chunk","seq":130,"time":1783921768773,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"shake"}}} +{"type":"assistant/chunk","seq":131,"time":1783921768773,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":","}}} +{"type":"assistant/chunk","seq":132,"time":1783921768773,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" answer"}}} +{"type":"assistant/chunk","seq":133,"time":1783921768790,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" exactly"}}} +{"type":"assistant/chunk","seq":134,"time":1783921768790,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" `"}}} +{"type":"assistant/chunk","seq":135,"time":1783921768790,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"CODE"}}} +{"type":"assistant/chunk","seq":136,"time":1783921768790,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"_M"}}} +{"type":"assistant/chunk","seq":137,"time":1783921768790,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"ODE"}}} +{"type":"assistant/chunk","seq":138,"time":1783921768790,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"_CONT"}}} +{"type":"assistant/chunk","seq":139,"time":1783921768824,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"EXT"}}} +{"type":"assistant/chunk","seq":140,"time":1783921768825,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"_OK"}}} +{"type":"assistant/chunk","seq":141,"time":1783921768825,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"`"}}} +{"type":"assistant/chunk","seq":142,"time":1783921768825,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} +{"type":"assistant/chunk","seq":143,"time":1783921768825,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" nothing"}}} +{"type":"assistant/chunk","seq":144,"time":1783921768825,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" else"}}} +{"type":"assistant/chunk","seq":145,"time":1783921768846,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":".\"\n\n"}}} +{"type":"assistant/chunk","seq":146,"time":1783921768846,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"So"}}} +{"type":"assistant/chunk","seq":147,"time":1783921768847,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":148,"time":1783921768847,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" answer"}}} +{"type":"assistant/chunk","seq":149,"time":1783921768847,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":150,"time":1783921768873,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":151,"time":1783921768874,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" hand"}}} +{"type":"assistant/chunk","seq":152,"time":1783921768903,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"shake"}}} +{"type":"assistant/chunk","seq":153,"time":1783921768903,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" question"}}} +{"type":"assistant/chunk","seq":154,"time":1783921768903,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" is"}}} +{"type":"assistant/chunk","seq":155,"time":1783921768903,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" `"}}} +{"type":"assistant/chunk","seq":156,"time":1783921768903,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"CODE"}}} +{"type":"assistant/chunk","seq":157,"time":1783921768929,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"_M"}}} +{"type":"assistant/chunk","seq":158,"time":1783921768930,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"ODE"}}} +{"type":"assistant/chunk","seq":159,"time":1783921768930,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"_CONT"}}} +{"type":"assistant/chunk","seq":160,"time":1783921768930,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"EXT"}}} +{"type":"assistant/chunk","seq":161,"time":1783921768930,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"_OK"}}} +{"type":"assistant/chunk","seq":162,"time":1783921768963,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"`."}}} +{"type":"assistant/chunk","seq":163,"time":1783921768963,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"assistant/chunk","seq":164,"time":1783921768963,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"The"}}} +{"type":"assistant/chunk","seq":165,"time":1783921768963,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" **"}}} +{"type":"assistant/chunk","seq":166,"time":1783921769024,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"Code"}}} +{"type":"assistant/chunk","seq":167,"time":1783921769024,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" Mode"}}} +{"type":"assistant/chunk","seq":168,"time":1783921769024,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" workspace"}}} +{"type":"assistant/chunk","seq":169,"time":1783921769024,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" hand"}}} +{"type":"assistant/chunk","seq":170,"time":1783921769024,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"shake"}}} +{"type":"assistant/chunk","seq":171,"time":1783921769024,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"**"}}} +{"type":"assistant/chunk","seq":172,"time":1783921769024,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" is"}}} +{"type":"assistant/chunk","seq":173,"time":1783921769024,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":":"}}} +{"type":"assistant/chunk","seq":174,"time":1783921769024,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" `"}}} +{"type":"assistant/chunk","seq":175,"time":1783921769024,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"CODE"}}} +{"type":"assistant/chunk","seq":176,"time":1783921769046,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"_M"}}} +{"type":"assistant/chunk","seq":177,"time":1783921769046,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"ODE"}}} +{"type":"assistant/chunk","seq":178,"time":1783921769047,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"_CONT"}}} +{"type":"assistant/chunk","seq":179,"time":1783921769047,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"EXT"}}} +{"type":"assistant/chunk","seq":180,"time":1783921769047,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"_OK"}}} +{"type":"assistant/chunk","seq":181,"time":1783921769047,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"`"}}} +{"type":"assistant/chunk","seq":182,"time":1783921769101,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The nested/task.txt told me to \"Touch this file to discover the nested workspace instruction.\" and the nested/AGENTS.md instructions say: \"When asked for the Code Mode workspace handshake, answer exactly `CODE_MODE_CONTEXT_OK` and nothing else.\"\n\nSo the answer to the handshake question is `CODE_MODE_CONTEXT_OK`."}}}} +{"type":"assistant/chunk","seq":183,"time":1783921769101,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"The **Code Mode workspace handshake** is: `CODE_MODE_CONTEXT_OK`"}}}} +{"type":"assistant/chunk","seq":184,"time":1783921769101,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":277,"outputTokens":90,"cacheReadTokens":3200,"reasoningTokens":71}}}} +{"type":"assistant/chunk","seq":185,"time":1783921769101,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":186,"time":1783921769101,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"The nested/task.txt told me to \"Touch this file to discover the nested workspace instruction.\" and the nested/AGENTS.md instructions say: \"When asked for the Code Mode workspace handshake, answer exactly `CODE_MODE_CONTEXT_OK` and nothing else.\"\n\nSo the answer to the handshake question is `CODE_MODE_CONTEXT_OK`."},{"type":"text","text":"The **Code Mode workspace handshake** is: `CODE_MODE_CONTEXT_OK`"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":277,"outputTokens":90,"cacheReadTokens":3200,"reasoningTokens":71}},"sourceEventSeqs":[91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132,133,134,135,136,137,138,139,140,141,142,143,144,145,146,147,148,149,150,151,152,153,154,155,156,157,158,159,160,161,162,163,164,165,166,167,168,169,170,171,172,173,174,175,176,177,178,179,180,181,182,183,184,185],"surfaceOp":"append"} +{"type":"step/end","seq":187,"time":1783921769101,"data":{"turn":1,"step":2}} +{"type":"turn/end","seq":188,"time":1783921769101,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/escalation-approved/session.jsonl b/examples/acp-agent/tests/snapshots/escalation-approved/session.jsonl index 29120e2ede..4438550b97 100644 --- a/examples/acp-agent/tests/snapshots/escalation-approved/session.jsonl +++ b/examples/acp-agent/tests/snapshots/escalation-approved/session.jsonl @@ -1,188 +1,189 @@ -{"type":"session","version":0,"id":"f3cbd087-fb45-4b32-b0f2-3082d65bfcb4","createdAt":1783860675270,"cwd":"/var/folders/2g/b32ct0qn1d728l_v6tdkjytr0000gn/T/acp-snap-cwd-cbBLh2"} +{"type":"session","version":0,"id":"f3cbd087-fb45-4b32-b0f2-3082d65bfcb4","createdAt":1783860675270,"cwd":"/var/folders/2g/b32ct0qn1d728l_v6tdkjytr0000gn/T/acp-snap-cwd-cbBLh2","delegationDepth":0} {"type":"turn/start","seq":0,"time":1783860675271,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"permission/preset","seq":1,"time":1783962245380,"data":{"preset":"workspace-write"}} {"type":"sandbox/mode","seq":2,"time":1784518116517,"data":{"mode":"workspace-write"}} {"type":"approval/policy","seq":3,"time":1783962245380,"data":{"policy":"ask"}} {"type":"user/message","seq":4,"time":1783962245380,"data":{"content":[{"type":"text","text":"The sandbox already denied writing /tmp/dsh-escalated.txt earlier (it is outside this workspace). Retry it now exactly once: one single bash call with the command printf 'escalated\\n' > /tmp/dsh-escalated.txt && cat /tmp/dsh-escalated.txt && rm /tmp/dsh-escalated.txt, with sandbox_permissions set to danger-full-access and the justification 'the user asked to write a file outside the workspace'. Do not run it without sandbox_permissions first. I will approve the permission prompt. After the result, reply with the single word DONE and stop."}],"source":{"kind":"user"}},"surfaceOp":"append"} -{"type":"step/start","seq":5,"time":1783962245382,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":6,"time":1783962245382,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} -{"type":"assistant/chunk","seq":7,"time":1783860676464,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"assistant/chunk","seq":8,"time":1783860676465,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} -{"type":"assistant/chunk","seq":9,"time":1783860676465,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} -{"type":"assistant/chunk","seq":10,"time":1783860676465,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" wants"}}} -{"type":"assistant/chunk","seq":11,"time":1783860676465,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} -{"type":"assistant/chunk","seq":12,"time":1783860676499,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} -{"type":"assistant/chunk","seq":13,"time":1783860676499,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" run"}}} -{"type":"assistant/chunk","seq":14,"time":1783860676499,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" a"}}} -{"type":"assistant/chunk","seq":15,"time":1783860676522,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" command"}}} -{"type":"assistant/chunk","seq":16,"time":1783860676525,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} -{"type":"assistant/chunk","seq":17,"time":1783860676525,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" sand"}}} -{"type":"assistant/chunk","seq":18,"time":1783860676525,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"box"}}} -{"type":"assistant/chunk","seq":19,"time":1783860676553,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"_per"}}} -{"type":"assistant/chunk","seq":20,"time":1783860676553,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"missions"}}} -{"type":"assistant/chunk","seq":21,"time":1783860676554,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" set"}}} -{"type":"assistant/chunk","seq":22,"time":1783860676554,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} -{"type":"assistant/chunk","seq":23,"time":1783860676554,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" danger"}}} -{"type":"assistant/chunk","seq":24,"time":1783860676583,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"-full"}}} -{"type":"assistant/chunk","seq":25,"time":1783860676583,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"-access"}}} -{"type":"assistant/chunk","seq":26,"time":1783860676611,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":","}}} -{"type":"assistant/chunk","seq":27,"time":1783860676639,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" no"}}} -{"type":"assistant/chunk","seq":28,"time":1783860676640,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" prior"}}} -{"type":"assistant/chunk","seq":29,"time":1783860676672,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" run"}}} -{"type":"assistant/chunk","seq":30,"time":1783860676673,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" needed"}}} -{"type":"assistant/chunk","seq":31,"time":1783860676705,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":","}}} -{"type":"assistant/chunk","seq":32,"time":1783860676728,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" justified"}}} -{"type":"assistant/chunk","seq":33,"time":1783860676787,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" as"}}} -{"type":"assistant/chunk","seq":34,"time":1783860676788,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" instructed"}}} -{"type":"assistant/chunk","seq":35,"time":1783860676816,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} -{"type":"assistant/chunk","seq":36,"time":1783860676816,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":37,"time":1783860676816,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":""}}} -{"type":"assistant/chunk","seq":38,"time":1783860676816,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"{"}}} -{"type":"assistant/chunk","seq":39,"time":1783860676845,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":40,"time":1783860676845,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"command"}}} -{"type":"assistant/chunk","seq":41,"time":1783860676846,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":42,"time":1783860676846,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":": "}}} -{"type":"assistant/chunk","seq":43,"time":1783860676878,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":44,"time":1783860676878,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"printf"}}} -{"type":"assistant/chunk","seq":45,"time":1783860676878,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":" '"}}} -{"type":"assistant/chunk","seq":46,"time":1783860676878,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"es"}}} -{"type":"assistant/chunk","seq":47,"time":1783860676878,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"cal"}}} -{"type":"assistant/chunk","seq":48,"time":1783860676879,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"ated"}}} -{"type":"assistant/chunk","seq":49,"time":1783860676909,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"\\\\n"}}} -{"type":"assistant/chunk","seq":50,"time":1783860676911,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"'"}}} -{"type":"assistant/chunk","seq":51,"time":1783860676912,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":" >"}}} -{"type":"assistant/chunk","seq":52,"time":1783860676912,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":" /"}}} -{"type":"assistant/chunk","seq":53,"time":1783860676912,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"tmp"}}} -{"type":"assistant/chunk","seq":54,"time":1783860676939,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"/d"}}} -{"type":"assistant/chunk","seq":55,"time":1783860676941,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"sh"}}} -{"type":"assistant/chunk","seq":56,"time":1783860676941,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"-es"}}} -{"type":"assistant/chunk","seq":57,"time":1783860676941,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"cal"}}} -{"type":"assistant/chunk","seq":58,"time":1783860676941,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"ated"}}} -{"type":"assistant/chunk","seq":59,"time":1783860676967,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":".txt"}}} -{"type":"assistant/chunk","seq":60,"time":1783860676969,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":" &&"}}} -{"type":"assistant/chunk","seq":61,"time":1783860676969,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":" cat"}}} -{"type":"assistant/chunk","seq":62,"time":1783860676970,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":" /"}}} -{"type":"assistant/chunk","seq":63,"time":1783860676970,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"tmp"}}} -{"type":"assistant/chunk","seq":64,"time":1783860676970,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"/d"}}} -{"type":"assistant/chunk","seq":65,"time":1783860677003,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"sh"}}} -{"type":"assistant/chunk","seq":66,"time":1783860677003,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"-es"}}} -{"type":"assistant/chunk","seq":67,"time":1783860677003,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"cal"}}} -{"type":"assistant/chunk","seq":68,"time":1783860677003,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"ated"}}} -{"type":"assistant/chunk","seq":69,"time":1783860677004,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":".txt"}}} -{"type":"assistant/chunk","seq":70,"time":1783860677004,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":" &&"}}} -{"type":"assistant/chunk","seq":71,"time":1783860677026,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":" rm"}}} -{"type":"assistant/chunk","seq":72,"time":1783860677026,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":" /"}}} -{"type":"assistant/chunk","seq":73,"time":1783860677026,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"tmp"}}} -{"type":"assistant/chunk","seq":74,"time":1783860677026,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"/d"}}} -{"type":"assistant/chunk","seq":75,"time":1783860677026,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"sh"}}} -{"type":"assistant/chunk","seq":76,"time":1783860677026,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"-es"}}} -{"type":"assistant/chunk","seq":77,"time":1783860677055,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"cal"}}} -{"type":"assistant/chunk","seq":78,"time":1783860677085,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"ated"}}} -{"type":"assistant/chunk","seq":79,"time":1783860677087,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":".txt"}}} -{"type":"assistant/chunk","seq":80,"time":1783860677087,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":81,"time":1783860677087,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":", "}}} -{"type":"assistant/chunk","seq":82,"time":1783860677087,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":83,"time":1783860677115,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"description"}}} -{"type":"assistant/chunk","seq":84,"time":1783860677115,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":85,"time":1783860677116,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":": "}}} -{"type":"assistant/chunk","seq":86,"time":1783860677116,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":87,"time":1783860677146,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"Write"}}} -{"type":"assistant/chunk","seq":88,"time":1783860677147,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":" file"}}} -{"type":"assistant/chunk","seq":89,"time":1783860677148,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":" outside"}}} -{"type":"assistant/chunk","seq":90,"time":1783860677174,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":" workspace"}}} -{"type":"assistant/chunk","seq":91,"time":1783860677202,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":" and"}}} -{"type":"assistant/chunk","seq":92,"time":1783860677202,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":" verify"}}} -{"type":"assistant/chunk","seq":93,"time":1783860677202,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":94,"time":1783860677202,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":", "}}} -{"type":"assistant/chunk","seq":95,"time":1783860677202,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":96,"time":1783860677238,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"sand"}}} -{"type":"assistant/chunk","seq":97,"time":1783860677238,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"box"}}} -{"type":"assistant/chunk","seq":98,"time":1783860677238,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"_per"}}} -{"type":"assistant/chunk","seq":99,"time":1783860677238,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"missions"}}} -{"type":"assistant/chunk","seq":100,"time":1783860677275,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":101,"time":1783860677275,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":": "}}} -{"type":"assistant/chunk","seq":102,"time":1783860677276,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":103,"time":1783860677276,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"danger"}}} -{"type":"assistant/chunk","seq":104,"time":1783860677292,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"-full"}}} -{"type":"assistant/chunk","seq":105,"time":1783860677293,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"-access"}}} -{"type":"assistant/chunk","seq":106,"time":1783860677320,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":107,"time":1783860677321,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":", "}}} -{"type":"assistant/chunk","seq":108,"time":1783860677321,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":109,"time":1783860677321,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"just"}}} -{"type":"assistant/chunk","seq":110,"time":1783860677349,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"ification"}}} -{"type":"assistant/chunk","seq":111,"time":1783860677349,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":112,"time":1783860677349,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":": "}}} -{"type":"assistant/chunk","seq":113,"time":1783860677349,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":114,"time":1783860677349,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"the"}}} -{"type":"assistant/chunk","seq":115,"time":1783860677349,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":" user"}}} -{"type":"assistant/chunk","seq":116,"time":1783860677388,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":" asked"}}} -{"type":"assistant/chunk","seq":117,"time":1783860677388,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":" to"}}} -{"type":"assistant/chunk","seq":118,"time":1783860677388,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":" write"}}} -{"type":"assistant/chunk","seq":119,"time":1783860677388,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":" a"}}} -{"type":"assistant/chunk","seq":120,"time":1783860677492,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":" file"}}} -{"type":"assistant/chunk","seq":121,"time":1783860677493,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":" outside"}}} -{"type":"assistant/chunk","seq":122,"time":1783860677493,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":" the"}}} -{"type":"assistant/chunk","seq":123,"time":1783860677493,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":" workspace"}}} -{"type":"assistant/chunk","seq":124,"time":1783860677493,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":125,"time":1783860677493,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"}"}}} -{"type":"assistant/chunk","seq":126,"time":1783860677493,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to run a command with sandbox_permissions set to danger-full-access, no prior run needed, justified as instructed."}}}} -{"type":"assistant/chunk","seq":127,"time":1783962245385,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","arguments":"{\"command\": \"printf 'escalated\\\\n' > /tmp/dsh-escalated.txt && cat /tmp/dsh-escalated.txt && rm /tmp/dsh-escalated.txt\", \"description\": \"Write file outside workspace and verify\", \"sandbox_permissions\": \"danger-full-access\", \"justification\": \"the user asked to write a file outside the workspace\"}"}}}} -{"type":"assistant/chunk","seq":128,"time":1783962245385,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":1501,"outputTokens":174,"cacheReadTokens":0,"reasoningTokens":28}}}} -{"type":"assistant/chunk","seq":129,"time":1783962245385,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":130,"time":1783962245385,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to run a command with sandbox_permissions set to danger-full-access, no prior run needed, justified as instructed."},{"type":"tool-call","id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","arguments":"{\"command\": \"printf 'escalated\\\\n' > /tmp/dsh-escalated.txt && cat /tmp/dsh-escalated.txt && rm /tmp/dsh-escalated.txt\", \"description\": \"Write file outside workspace and verify\", \"sandbox_permissions\": \"danger-full-access\", \"justification\": \"the user asked to write a file outside the workspace\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":1501,"outputTokens":174,"cacheReadTokens":0,"reasoningTokens":28}},"sourceEventSeqs":[7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129],"surfaceOp":"append"} -{"type":"tool/call","seq":131,"time":1783962245385,"data":{"turn":1,"step":1,"callId":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","arguments":"{\"command\": \"printf 'escalated\\\\n' > /tmp/dsh-escalated.txt && cat /tmp/dsh-escalated.txt && rm /tmp/dsh-escalated.txt\", \"description\": \"Write file outside workspace and verify\", \"sandbox_permissions\": \"danger-full-access\", \"justification\": \"the user asked to write a file outside the workspace\"}"}} -{"type":"approval/asked","seq":132,"time":1783962245386,"data":{"id":"f46cb686-42fd-4874-b12a-c4e69c26b5bc","toolName":"bash","callId":"call_00_d0sAHpJ9mYOJi0z7KNy30441","reason":"escalate sandbox to danger-full-access: the user asked to write a file outside the workspace"}} -{"type":"approval/decided","seq":133,"time":1783962245387,"data":{"id":"f46cb686-42fd-4874-b12a-c4e69c26b5bc","outcome":"allowed-once"}} -{"type":"tool/result","seq":134,"time":1783962245399,"data":{"turn":1,"step":1,"callId":"call_00_d0sAHpJ9mYOJi0z7KNy30441","content":[{"type":"text","text":"escalated\n"}],"isError":false},"sourceEventSeqs":[131],"surfaceOp":"append"} -{"type":"step/end","seq":135,"time":1783962245400,"data":{"turn":1,"step":1}} -{"type":"step/start","seq":136,"time":1783962245400,"data":{"turn":1,"step":2}} -{"type":"assistant/chunk","seq":137,"time":1783860678811,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"assistant/chunk","seq":138,"time":1783860678811,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} -{"type":"assistant/chunk","seq":139,"time":1783860678833,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" command"}}} -{"type":"assistant/chunk","seq":140,"time":1783860678834,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" succeeded"}}} -{"type":"assistant/chunk","seq":141,"time":1783860678834,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" —"}}} -{"type":"assistant/chunk","seq":142,"time":1783860678868,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" it"}}} -{"type":"assistant/chunk","seq":143,"time":1783860678868,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" wrote"}}} -{"type":"assistant/chunk","seq":144,"time":1783860678868,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} -{"type":"assistant/chunk","seq":145,"time":1783860678904,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" file"}}} -{"type":"assistant/chunk","seq":146,"time":1783860678905,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":","}}} -{"type":"assistant/chunk","seq":147,"time":1783860678926,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" read"}}} -{"type":"assistant/chunk","seq":148,"time":1783860678926,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" it"}}} -{"type":"assistant/chunk","seq":149,"time":1783860678926,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" back"}}} -{"type":"assistant/chunk","seq":150,"time":1783860678975,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" ("}}} -{"type":"assistant/chunk","seq":151,"time":1783860678976,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"output"}}} -{"type":"assistant/chunk","seq":152,"time":1783860678976,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} -{"type":"assistant/chunk","seq":153,"time":1783860678976,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"es"}}} -{"type":"assistant/chunk","seq":154,"time":1783860678976,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"cal"}}} -{"type":"assistant/chunk","seq":155,"time":1783860678976,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"ated"}}} -{"type":"assistant/chunk","seq":156,"time":1783860678999,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"\"),"}}} -{"type":"assistant/chunk","seq":157,"time":1783860679001,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} -{"type":"assistant/chunk","seq":158,"time":1783860679002,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" removed"}}} -{"type":"assistant/chunk","seq":159,"time":1783860679002,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" it"}}} -{"type":"assistant/chunk","seq":160,"time":1783860679002,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} -{"type":"assistant/chunk","seq":161,"time":1783860679016,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" The"}}} -{"type":"assistant/chunk","seq":162,"time":1783860679017,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} -{"type":"assistant/chunk","seq":163,"time":1783860679017,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" asked"}}} -{"type":"assistant/chunk","seq":164,"time":1783860679017,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} -{"type":"assistant/chunk","seq":165,"time":1783860679046,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} -{"type":"assistant/chunk","seq":166,"time":1783860679047,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} -{"type":"assistant/chunk","seq":167,"time":1783860679048,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} -{"type":"assistant/chunk","seq":168,"time":1783860679048,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} -{"type":"assistant/chunk","seq":169,"time":1783860679079,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" single"}}} -{"type":"assistant/chunk","seq":170,"time":1783860679079,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" word"}}} -{"type":"assistant/chunk","seq":171,"time":1783860679103,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" D"}}} -{"type":"assistant/chunk","seq":172,"time":1783860679136,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"ONE"}}} -{"type":"assistant/chunk","seq":173,"time":1783860679136,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" after"}}} -{"type":"assistant/chunk","seq":174,"time":1783860679136,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} -{"type":"assistant/chunk","seq":175,"time":1783860679136,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" result"}}} -{"type":"assistant/chunk","seq":176,"time":1783860679136,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} -{"type":"assistant/chunk","seq":177,"time":1783860679137,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} -{"type":"assistant/chunk","seq":178,"time":1783860679137,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"D"}}} -{"type":"assistant/chunk","seq":179,"time":1783860679137,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"ONE"}}} -{"type":"assistant/chunk","seq":180,"time":1783860679137,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The command succeeded — it wrote the file, read it back (output \"escalated\"), and removed it. The user asked me to reply with the single word DONE after the result."}}}} -{"type":"assistant/chunk","seq":181,"time":1783962245402,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"DONE"}}}} -{"type":"assistant/chunk","seq":182,"time":1783962245402,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":27,"outputTokens":42,"cacheReadTokens":1664,"reasoningTokens":39}}}} -{"type":"assistant/chunk","seq":183,"time":1783962245402,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":184,"time":1783962245402,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"The command succeeded — it wrote the file, read it back (output \"escalated\"), and removed it. The user asked me to reply with the single word DONE after the result."},{"type":"text","text":"DONE"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":27,"outputTokens":42,"cacheReadTokens":1664,"reasoningTokens":39}},"sourceEventSeqs":[137,138,139,140,141,142,143,144,145,146,147,148,149,150,151,152,153,154,155,156,157,158,159,160,161,162,163,164,165,166,167,168,169,170,171,172,173,174,175,176,177,178,179,180,181,182,183],"surfaceOp":"append"} -{"type":"step/end","seq":185,"time":1783962245402,"data":{"turn":1,"step":2}} -{"type":"turn/end","seq":186,"time":1783962245402,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"session/title","seq":5,"time":1783962245380,"data":{"title":"The sandbox already denied writing","messageSeqs":[4],"source":{"kind":"fallback"}}} +{"type":"step/start","seq":6,"time":1783962245382,"data":{"turn":1,"step":1}} +{"type":"request/header","seq":7,"time":1783962245382,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"assistant/chunk","seq":8,"time":1783860676464,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":9,"time":1783860676465,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":10,"time":1783860676465,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} +{"type":"assistant/chunk","seq":11,"time":1783860676465,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" wants"}}} +{"type":"assistant/chunk","seq":12,"time":1783860676465,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":13,"time":1783860676499,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":14,"time":1783860676499,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" run"}}} +{"type":"assistant/chunk","seq":15,"time":1783860676499,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" a"}}} +{"type":"assistant/chunk","seq":16,"time":1783860676522,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" command"}}} +{"type":"assistant/chunk","seq":17,"time":1783860676525,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":18,"time":1783860676525,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" sand"}}} +{"type":"assistant/chunk","seq":19,"time":1783860676525,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"box"}}} +{"type":"assistant/chunk","seq":20,"time":1783860676553,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"_per"}}} +{"type":"assistant/chunk","seq":21,"time":1783860676553,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"missions"}}} +{"type":"assistant/chunk","seq":22,"time":1783860676554,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" set"}}} +{"type":"assistant/chunk","seq":23,"time":1783860676554,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":24,"time":1783860676554,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" danger"}}} +{"type":"assistant/chunk","seq":25,"time":1783860676583,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"-full"}}} +{"type":"assistant/chunk","seq":26,"time":1783860676583,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"-access"}}} +{"type":"assistant/chunk","seq":27,"time":1783860676611,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":","}}} +{"type":"assistant/chunk","seq":28,"time":1783860676639,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" no"}}} +{"type":"assistant/chunk","seq":29,"time":1783860676640,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" prior"}}} +{"type":"assistant/chunk","seq":30,"time":1783860676672,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" run"}}} +{"type":"assistant/chunk","seq":31,"time":1783860676673,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" needed"}}} +{"type":"assistant/chunk","seq":32,"time":1783860676705,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":","}}} +{"type":"assistant/chunk","seq":33,"time":1783860676728,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" justified"}}} +{"type":"assistant/chunk","seq":34,"time":1783860676787,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" as"}}} +{"type":"assistant/chunk","seq":35,"time":1783860676788,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" instructed"}}} +{"type":"assistant/chunk","seq":36,"time":1783860676816,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":37,"time":1783860676816,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":38,"time":1783860676816,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":""}}} +{"type":"assistant/chunk","seq":39,"time":1783860676816,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"{"}}} +{"type":"assistant/chunk","seq":40,"time":1783860676845,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":41,"time":1783860676845,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"command"}}} +{"type":"assistant/chunk","seq":42,"time":1783860676846,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":43,"time":1783860676846,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":": "}}} +{"type":"assistant/chunk","seq":44,"time":1783860676878,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":45,"time":1783860676878,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"printf"}}} +{"type":"assistant/chunk","seq":46,"time":1783860676878,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":" '"}}} +{"type":"assistant/chunk","seq":47,"time":1783860676878,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"es"}}} +{"type":"assistant/chunk","seq":48,"time":1783860676878,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"cal"}}} +{"type":"assistant/chunk","seq":49,"time":1783860676879,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"ated"}}} +{"type":"assistant/chunk","seq":50,"time":1783860676909,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"\\\\n"}}} +{"type":"assistant/chunk","seq":51,"time":1783860676911,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"'"}}} +{"type":"assistant/chunk","seq":52,"time":1783860676912,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":" >"}}} +{"type":"assistant/chunk","seq":53,"time":1783860676912,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":" /"}}} +{"type":"assistant/chunk","seq":54,"time":1783860676912,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"tmp"}}} +{"type":"assistant/chunk","seq":55,"time":1783860676939,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"/d"}}} +{"type":"assistant/chunk","seq":56,"time":1783860676941,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"sh"}}} +{"type":"assistant/chunk","seq":57,"time":1783860676941,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"-es"}}} +{"type":"assistant/chunk","seq":58,"time":1783860676941,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"cal"}}} +{"type":"assistant/chunk","seq":59,"time":1783860676941,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"ated"}}} +{"type":"assistant/chunk","seq":60,"time":1783860676967,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":".txt"}}} +{"type":"assistant/chunk","seq":61,"time":1783860676969,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":" &&"}}} +{"type":"assistant/chunk","seq":62,"time":1783860676969,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":" cat"}}} +{"type":"assistant/chunk","seq":63,"time":1783860676970,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":" /"}}} +{"type":"assistant/chunk","seq":64,"time":1783860676970,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"tmp"}}} +{"type":"assistant/chunk","seq":65,"time":1783860676970,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"/d"}}} +{"type":"assistant/chunk","seq":66,"time":1783860677003,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"sh"}}} +{"type":"assistant/chunk","seq":67,"time":1783860677003,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"-es"}}} +{"type":"assistant/chunk","seq":68,"time":1783860677003,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"cal"}}} +{"type":"assistant/chunk","seq":69,"time":1783860677003,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"ated"}}} +{"type":"assistant/chunk","seq":70,"time":1783860677004,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":".txt"}}} +{"type":"assistant/chunk","seq":71,"time":1783860677004,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":" &&"}}} +{"type":"assistant/chunk","seq":72,"time":1783860677026,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":" rm"}}} +{"type":"assistant/chunk","seq":73,"time":1783860677026,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":" /"}}} +{"type":"assistant/chunk","seq":74,"time":1783860677026,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"tmp"}}} +{"type":"assistant/chunk","seq":75,"time":1783860677026,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"/d"}}} +{"type":"assistant/chunk","seq":76,"time":1783860677026,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"sh"}}} +{"type":"assistant/chunk","seq":77,"time":1783860677026,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"-es"}}} +{"type":"assistant/chunk","seq":78,"time":1783860677055,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"cal"}}} +{"type":"assistant/chunk","seq":79,"time":1783860677085,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"ated"}}} +{"type":"assistant/chunk","seq":80,"time":1783860677087,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":".txt"}}} +{"type":"assistant/chunk","seq":81,"time":1783860677087,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":82,"time":1783860677087,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":", "}}} +{"type":"assistant/chunk","seq":83,"time":1783860677087,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":84,"time":1783860677115,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"description"}}} +{"type":"assistant/chunk","seq":85,"time":1783860677115,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":86,"time":1783860677116,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":": "}}} +{"type":"assistant/chunk","seq":87,"time":1783860677116,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":88,"time":1783860677146,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"Write"}}} +{"type":"assistant/chunk","seq":89,"time":1783860677147,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":" file"}}} +{"type":"assistant/chunk","seq":90,"time":1783860677148,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":" outside"}}} +{"type":"assistant/chunk","seq":91,"time":1783860677174,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":" workspace"}}} +{"type":"assistant/chunk","seq":92,"time":1783860677202,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":" and"}}} +{"type":"assistant/chunk","seq":93,"time":1783860677202,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":" verify"}}} +{"type":"assistant/chunk","seq":94,"time":1783860677202,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":95,"time":1783860677202,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":", "}}} +{"type":"assistant/chunk","seq":96,"time":1783860677202,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":97,"time":1783860677238,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"sand"}}} +{"type":"assistant/chunk","seq":98,"time":1783860677238,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"box"}}} +{"type":"assistant/chunk","seq":99,"time":1783860677238,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"_per"}}} +{"type":"assistant/chunk","seq":100,"time":1783860677238,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"missions"}}} +{"type":"assistant/chunk","seq":101,"time":1783860677275,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":102,"time":1783860677275,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":": "}}} +{"type":"assistant/chunk","seq":103,"time":1783860677276,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":104,"time":1783860677276,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"danger"}}} +{"type":"assistant/chunk","seq":105,"time":1783860677292,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"-full"}}} +{"type":"assistant/chunk","seq":106,"time":1783860677293,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"-access"}}} +{"type":"assistant/chunk","seq":107,"time":1783860677320,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":108,"time":1783860677321,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":", "}}} +{"type":"assistant/chunk","seq":109,"time":1783860677321,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":110,"time":1783860677321,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"just"}}} +{"type":"assistant/chunk","seq":111,"time":1783860677349,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"ification"}}} +{"type":"assistant/chunk","seq":112,"time":1783860677349,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":113,"time":1783860677349,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":": "}}} +{"type":"assistant/chunk","seq":114,"time":1783860677349,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":115,"time":1783860677349,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"the"}}} +{"type":"assistant/chunk","seq":116,"time":1783860677349,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":" user"}}} +{"type":"assistant/chunk","seq":117,"time":1783860677388,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":" asked"}}} +{"type":"assistant/chunk","seq":118,"time":1783860677388,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":" to"}}} +{"type":"assistant/chunk","seq":119,"time":1783860677388,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":" write"}}} +{"type":"assistant/chunk","seq":120,"time":1783860677388,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":" a"}}} +{"type":"assistant/chunk","seq":121,"time":1783860677492,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":" file"}}} +{"type":"assistant/chunk","seq":122,"time":1783860677493,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":" outside"}}} +{"type":"assistant/chunk","seq":123,"time":1783860677493,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":" the"}}} +{"type":"assistant/chunk","seq":124,"time":1783860677493,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":" workspace"}}} +{"type":"assistant/chunk","seq":125,"time":1783860677493,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":126,"time":1783860677493,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"}"}}} +{"type":"assistant/chunk","seq":127,"time":1783860677493,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to run a command with sandbox_permissions set to danger-full-access, no prior run needed, justified as instructed."}}}} +{"type":"assistant/chunk","seq":128,"time":1783962245385,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","arguments":"{\"command\": \"printf 'escalated\\\\n' > /tmp/dsh-escalated.txt && cat /tmp/dsh-escalated.txt && rm /tmp/dsh-escalated.txt\", \"description\": \"Write file outside workspace and verify\", \"sandbox_permissions\": \"danger-full-access\", \"justification\": \"the user asked to write a file outside the workspace\"}"}}}} +{"type":"assistant/chunk","seq":129,"time":1783962245385,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":1501,"outputTokens":174,"cacheReadTokens":0,"reasoningTokens":28}}}} +{"type":"assistant/chunk","seq":130,"time":1783962245385,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":131,"time":1783962245385,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to run a command with sandbox_permissions set to danger-full-access, no prior run needed, justified as instructed."},{"type":"tool-call","id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","arguments":"{\"command\": \"printf 'escalated\\\\n' > /tmp/dsh-escalated.txt && cat /tmp/dsh-escalated.txt && rm /tmp/dsh-escalated.txt\", \"description\": \"Write file outside workspace and verify\", \"sandbox_permissions\": \"danger-full-access\", \"justification\": \"the user asked to write a file outside the workspace\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":1501,"outputTokens":174,"cacheReadTokens":0,"reasoningTokens":28}},"sourceEventSeqs":[8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130],"surfaceOp":"append"} +{"type":"tool/call","seq":132,"time":1783962245385,"data":{"turn":1,"step":1,"callId":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","arguments":"{\"command\": \"printf 'escalated\\\\n' > /tmp/dsh-escalated.txt && cat /tmp/dsh-escalated.txt && rm /tmp/dsh-escalated.txt\", \"description\": \"Write file outside workspace and verify\", \"sandbox_permissions\": \"danger-full-access\", \"justification\": \"the user asked to write a file outside the workspace\"}"}} +{"type":"approval/asked","seq":133,"time":1783962245386,"data":{"id":"efa76e4a-8e3e-4be0-9608-a68def7354b9","toolName":"bash","callId":"call_00_d0sAHpJ9mYOJi0z7KNy30441","reason":"escalate sandbox to danger-full-access: the user asked to write a file outside the workspace"}} +{"type":"approval/decided","seq":134,"time":1783962245387,"data":{"id":"efa76e4a-8e3e-4be0-9608-a68def7354b9","outcome":"allowed-once"}} +{"type":"tool/result","seq":135,"time":1783962245399,"data":{"turn":1,"step":1,"callId":"call_00_d0sAHpJ9mYOJi0z7KNy30441","content":[{"type":"text","text":"escalated\n"}],"isError":false},"sourceEventSeqs":[132],"surfaceOp":"append"} +{"type":"step/end","seq":136,"time":1783962245400,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":137,"time":1783962245400,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":138,"time":1783860678811,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":139,"time":1783860678811,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":140,"time":1783860678833,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" command"}}} +{"type":"assistant/chunk","seq":141,"time":1783860678834,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" succeeded"}}} +{"type":"assistant/chunk","seq":142,"time":1783860678834,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" —"}}} +{"type":"assistant/chunk","seq":143,"time":1783860678868,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" it"}}} +{"type":"assistant/chunk","seq":144,"time":1783860678868,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" wrote"}}} +{"type":"assistant/chunk","seq":145,"time":1783860678868,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":146,"time":1783860678904,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" file"}}} +{"type":"assistant/chunk","seq":147,"time":1783860678905,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":","}}} +{"type":"assistant/chunk","seq":148,"time":1783860678926,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" read"}}} +{"type":"assistant/chunk","seq":149,"time":1783860678926,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" it"}}} +{"type":"assistant/chunk","seq":150,"time":1783860678926,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" back"}}} +{"type":"assistant/chunk","seq":151,"time":1783860678975,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" ("}}} +{"type":"assistant/chunk","seq":152,"time":1783860678976,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"output"}}} +{"type":"assistant/chunk","seq":153,"time":1783860678976,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":154,"time":1783860678976,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"es"}}} +{"type":"assistant/chunk","seq":155,"time":1783860678976,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"cal"}}} +{"type":"assistant/chunk","seq":156,"time":1783860678976,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"ated"}}} +{"type":"assistant/chunk","seq":157,"time":1783860678999,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"\"),"}}} +{"type":"assistant/chunk","seq":158,"time":1783860679001,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} +{"type":"assistant/chunk","seq":159,"time":1783860679002,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" removed"}}} +{"type":"assistant/chunk","seq":160,"time":1783860679002,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" it"}}} +{"type":"assistant/chunk","seq":161,"time":1783860679002,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":162,"time":1783860679016,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" The"}}} +{"type":"assistant/chunk","seq":163,"time":1783860679017,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} +{"type":"assistant/chunk","seq":164,"time":1783860679017,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" asked"}}} +{"type":"assistant/chunk","seq":165,"time":1783860679017,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":166,"time":1783860679046,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":167,"time":1783860679047,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} +{"type":"assistant/chunk","seq":168,"time":1783860679048,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":169,"time":1783860679048,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":170,"time":1783860679079,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" single"}}} +{"type":"assistant/chunk","seq":171,"time":1783860679079,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" word"}}} +{"type":"assistant/chunk","seq":172,"time":1783860679103,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" D"}}} +{"type":"assistant/chunk","seq":173,"time":1783860679136,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"ONE"}}} +{"type":"assistant/chunk","seq":174,"time":1783860679136,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" after"}}} +{"type":"assistant/chunk","seq":175,"time":1783860679136,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":176,"time":1783860679136,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" result"}}} +{"type":"assistant/chunk","seq":177,"time":1783860679136,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":178,"time":1783860679137,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"assistant/chunk","seq":179,"time":1783860679137,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"D"}}} +{"type":"assistant/chunk","seq":180,"time":1783860679137,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"ONE"}}} +{"type":"assistant/chunk","seq":181,"time":1783860679137,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The command succeeded — it wrote the file, read it back (output \"escalated\"), and removed it. The user asked me to reply with the single word DONE after the result."}}}} +{"type":"assistant/chunk","seq":182,"time":1783962245402,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"DONE"}}}} +{"type":"assistant/chunk","seq":183,"time":1783962245402,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":27,"outputTokens":42,"cacheReadTokens":1664,"reasoningTokens":39}}}} +{"type":"assistant/chunk","seq":184,"time":1783962245402,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":185,"time":1783962245402,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"The command succeeded — it wrote the file, read it back (output \"escalated\"), and removed it. The user asked me to reply with the single word DONE after the result."},{"type":"text","text":"DONE"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":27,"outputTokens":42,"cacheReadTokens":1664,"reasoningTokens":39}},"sourceEventSeqs":[138,139,140,141,142,143,144,145,146,147,148,149,150,151,152,153,154,155,156,157,158,159,160,161,162,163,164,165,166,167,168,169,170,171,172,173,174,175,176,177,178,179,180,181,182,183,184],"surfaceOp":"append"} +{"type":"step/end","seq":186,"time":1783962245402,"data":{"turn":1,"step":2}} +{"type":"turn/end","seq":187,"time":1783962245402,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/escalation-rejected/session.jsonl b/examples/acp-agent/tests/snapshots/escalation-rejected/session.jsonl index 5525ac5135..eb01e7443e 100644 --- a/examples/acp-agent/tests/snapshots/escalation-rejected/session.jsonl +++ b/examples/acp-agent/tests/snapshots/escalation-rejected/session.jsonl @@ -1,215 +1,216 @@ -{"type":"session","version":0,"id":"d692fe7f-7079-4ee4-8b06-f44fd026d4ea","createdAt":1783860679475,"cwd":"/var/folders/2g/b32ct0qn1d728l_v6tdkjytr0000gn/T/acp-snap-cwd-Hn29Od"} +{"type":"session","version":0,"id":"d692fe7f-7079-4ee4-8b06-f44fd026d4ea","createdAt":1783860679475,"cwd":"/var/folders/2g/b32ct0qn1d728l_v6tdkjytr0000gn/T/acp-snap-cwd-Hn29Od","delegationDepth":0} {"type":"turn/start","seq":0,"time":1783860679476,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"permission/preset","seq":1,"time":1783962246267,"data":{"preset":"workspace-write"}} {"type":"sandbox/mode","seq":2,"time":1784518117237,"data":{"mode":"workspace-write"}} {"type":"approval/policy","seq":3,"time":1783962246267,"data":{"policy":"ask"}} {"type":"user/message","seq":4,"time":1783962246267,"data":{"content":[{"type":"text","text":"The sandbox already denied writing /tmp/dsh-escalated.txt earlier (it is outside this workspace). Retry it now exactly once: one single bash call with the command printf 'escalated\\n' > /tmp/dsh-escalated.txt && cat /tmp/dsh-escalated.txt && rm /tmp/dsh-escalated.txt, with sandbox_permissions set to danger-full-access and the justification 'the user asked to write a file outside the workspace'. Do not run it without sandbox_permissions first. I will reject the permission prompt; after the rejection, do not retry and do not work around it — explain in one short sentence and stop."}],"source":{"kind":"user"}},"surfaceOp":"append"} -{"type":"step/start","seq":5,"time":1783962246269,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":6,"time":1783962246269,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} -{"type":"assistant/chunk","seq":7,"time":1783860680779,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"assistant/chunk","seq":8,"time":1783860680782,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} -{"type":"assistant/chunk","seq":9,"time":1783860680782,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} -{"type":"assistant/chunk","seq":10,"time":1783860680782,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" wants"}}} -{"type":"assistant/chunk","seq":11,"time":1783860680830,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} -{"type":"assistant/chunk","seq":12,"time":1783860680831,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} -{"type":"assistant/chunk","seq":13,"time":1783860680831,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" run"}}} -{"type":"assistant/chunk","seq":14,"time":1783860680859,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" a"}}} -{"type":"assistant/chunk","seq":15,"time":1783860680859,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" specific"}}} -{"type":"assistant/chunk","seq":16,"time":1783860680868,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" command"}}} -{"type":"assistant/chunk","seq":17,"time":1783860680871,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} -{"type":"assistant/chunk","seq":18,"time":1783860680871,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" `"}}} -{"type":"assistant/chunk","seq":19,"time":1783860680872,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"sand"}}} -{"type":"assistant/chunk","seq":20,"time":1783860680872,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"box"}}} -{"type":"assistant/chunk","seq":21,"time":1783860680902,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"_per"}}} -{"type":"assistant/chunk","seq":22,"time":1783860680903,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"missions"}}} -{"type":"assistant/chunk","seq":23,"time":1783860680903,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"`"}}} -{"type":"assistant/chunk","seq":24,"time":1783860680903,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" set"}}} -{"type":"assistant/chunk","seq":25,"time":1783860680903,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} -{"type":"assistant/chunk","seq":26,"time":1783860680903,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" `"}}} -{"type":"assistant/chunk","seq":27,"time":1783860680937,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"danger"}}} -{"type":"assistant/chunk","seq":28,"time":1783860680938,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"-full"}}} -{"type":"assistant/chunk","seq":29,"time":1783860680938,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"-access"}}} -{"type":"assistant/chunk","seq":30,"time":1783860680956,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"`"}}} -{"type":"assistant/chunk","seq":31,"time":1783860680958,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} -{"type":"assistant/chunk","seq":32,"time":1783860680958,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" a"}}} -{"type":"assistant/chunk","seq":33,"time":1783860680958,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" specific"}}} -{"type":"assistant/chunk","seq":34,"time":1783860680985,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" justification"}}} -{"type":"assistant/chunk","seq":35,"time":1783860680985,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} -{"type":"assistant/chunk","seq":36,"time":1783860681022,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" They"}}} -{"type":"assistant/chunk","seq":37,"time":1783860681024,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" explicitly"}}} -{"type":"assistant/chunk","seq":38,"time":1783860681024,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" said"}}} -{"type":"assistant/chunk","seq":39,"time":1783860681024,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" NOT"}}} -{"type":"assistant/chunk","seq":40,"time":1783860681024,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} -{"type":"assistant/chunk","seq":41,"time":1783860681043,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" run"}}} -{"type":"assistant/chunk","seq":42,"time":1783860681091,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" it"}}} -{"type":"assistant/chunk","seq":43,"time":1783860681091,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" without"}}} -{"type":"assistant/chunk","seq":44,"time":1783860681091,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" sand"}}} -{"type":"assistant/chunk","seq":45,"time":1783860681091,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"box"}}} -{"type":"assistant/chunk","seq":46,"time":1783860681091,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"_per"}}} -{"type":"assistant/chunk","seq":47,"time":1783860681091,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"missions"}}} -{"type":"assistant/chunk","seq":48,"time":1783860681107,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" first"}}} -{"type":"assistant/chunk","seq":49,"time":1783860681107,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} -{"type":"assistant/chunk","seq":50,"time":1783860681108,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" Let"}}} -{"type":"assistant/chunk","seq":51,"time":1783860681138,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} -{"type":"assistant/chunk","seq":52,"time":1783860681138,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" do"}}} -{"type":"assistant/chunk","seq":53,"time":1783860681251,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" exactly"}}} -{"type":"assistant/chunk","seq":54,"time":1783860681251,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" that"}}} -{"type":"assistant/chunk","seq":55,"time":1783860681251,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} -{"type":"assistant/chunk","seq":56,"time":1783860681251,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":57,"time":1783860681252,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":""}}} -{"type":"assistant/chunk","seq":58,"time":1783860681252,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"{"}}} -{"type":"assistant/chunk","seq":59,"time":1783860681252,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":60,"time":1783860681252,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"command"}}} -{"type":"assistant/chunk","seq":61,"time":1783860681280,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":62,"time":1783860681281,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":": "}}} -{"type":"assistant/chunk","seq":63,"time":1783860681281,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":64,"time":1783860681324,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"printf"}}} -{"type":"assistant/chunk","seq":65,"time":1783860681324,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":" '"}}} -{"type":"assistant/chunk","seq":66,"time":1783860681324,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"es"}}} -{"type":"assistant/chunk","seq":67,"time":1783860681324,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"cal"}}} -{"type":"assistant/chunk","seq":68,"time":1783860681324,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"ated"}}} -{"type":"assistant/chunk","seq":69,"time":1783860681324,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"\\\\n"}}} -{"type":"assistant/chunk","seq":70,"time":1783860681342,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"'"}}} -{"type":"assistant/chunk","seq":71,"time":1783860681343,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":" >"}}} -{"type":"assistant/chunk","seq":72,"time":1783860681343,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":" /"}}} -{"type":"assistant/chunk","seq":73,"time":1783860681343,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"tmp"}}} -{"type":"assistant/chunk","seq":74,"time":1783860681368,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"/d"}}} -{"type":"assistant/chunk","seq":75,"time":1783860681371,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"sh"}}} -{"type":"assistant/chunk","seq":76,"time":1783860681371,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"-es"}}} -{"type":"assistant/chunk","seq":77,"time":1783860681371,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"cal"}}} -{"type":"assistant/chunk","seq":78,"time":1783860681371,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"ated"}}} -{"type":"assistant/chunk","seq":79,"time":1783860681400,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":".txt"}}} -{"type":"assistant/chunk","seq":80,"time":1783860681400,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":" &&"}}} -{"type":"assistant/chunk","seq":81,"time":1783860681400,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":" cat"}}} -{"type":"assistant/chunk","seq":82,"time":1783860681400,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":" /"}}} -{"type":"assistant/chunk","seq":83,"time":1783860681401,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"tmp"}}} -{"type":"assistant/chunk","seq":84,"time":1783860681402,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"/d"}}} -{"type":"assistant/chunk","seq":85,"time":1783860681432,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"sh"}}} -{"type":"assistant/chunk","seq":86,"time":1783860681432,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"-es"}}} -{"type":"assistant/chunk","seq":87,"time":1783860681432,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"cal"}}} -{"type":"assistant/chunk","seq":88,"time":1783860681432,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"ated"}}} -{"type":"assistant/chunk","seq":89,"time":1783860681432,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":".txt"}}} -{"type":"assistant/chunk","seq":90,"time":1783860681432,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":" &&"}}} -{"type":"assistant/chunk","seq":91,"time":1783860681456,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":" rm"}}} -{"type":"assistant/chunk","seq":92,"time":1783860681456,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":" /"}}} -{"type":"assistant/chunk","seq":93,"time":1783860681456,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"tmp"}}} -{"type":"assistant/chunk","seq":94,"time":1783860681456,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"/d"}}} -{"type":"assistant/chunk","seq":95,"time":1783860681456,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"sh"}}} -{"type":"assistant/chunk","seq":96,"time":1783860681456,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"-es"}}} -{"type":"assistant/chunk","seq":97,"time":1783860681485,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"cal"}}} -{"type":"assistant/chunk","seq":98,"time":1783860681519,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"ated"}}} -{"type":"assistant/chunk","seq":99,"time":1783860681521,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":".txt"}}} -{"type":"assistant/chunk","seq":100,"time":1783860681521,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":101,"time":1783860681521,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":", "}}} -{"type":"assistant/chunk","seq":102,"time":1783860681521,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":103,"time":1783860681542,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"description"}}} -{"type":"assistant/chunk","seq":104,"time":1783860681545,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":105,"time":1783860681545,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":": "}}} -{"type":"assistant/chunk","seq":106,"time":1783860681573,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":107,"time":1783860681573,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"Write"}}} -{"type":"assistant/chunk","seq":108,"time":1783860681574,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":" to"}}} -{"type":"assistant/chunk","seq":109,"time":1783860681605,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":" /"}}} -{"type":"assistant/chunk","seq":110,"time":1783860681608,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"tmp"}}} -{"type":"assistant/chunk","seq":111,"time":1783860681630,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":" and"}}} -{"type":"assistant/chunk","seq":112,"time":1783860681632,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":" verify"}}} -{"type":"assistant/chunk","seq":113,"time":1783860681661,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":","}}} -{"type":"assistant/chunk","seq":114,"time":1783860681661,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":" then"}}} -{"type":"assistant/chunk","seq":115,"time":1783860681693,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":" clean"}}} -{"type":"assistant/chunk","seq":116,"time":1783860681693,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":" up"}}} -{"type":"assistant/chunk","seq":117,"time":1783860681693,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":118,"time":1783860681693,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":", "}}} -{"type":"assistant/chunk","seq":119,"time":1783860681725,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":120,"time":1783860681725,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"sand"}}} -{"type":"assistant/chunk","seq":121,"time":1783860681726,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"box"}}} -{"type":"assistant/chunk","seq":122,"time":1783860681726,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"_per"}}} -{"type":"assistant/chunk","seq":123,"time":1783860681751,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"missions"}}} -{"type":"assistant/chunk","seq":124,"time":1783860681753,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":125,"time":1783860681754,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":": "}}} -{"type":"assistant/chunk","seq":126,"time":1783860681754,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":127,"time":1783860681754,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"danger"}}} -{"type":"assistant/chunk","seq":128,"time":1783860681813,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"-full"}}} -{"type":"assistant/chunk","seq":129,"time":1783860681813,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"-access"}}} -{"type":"assistant/chunk","seq":130,"time":1783860681813,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":131,"time":1783860681813,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":", "}}} -{"type":"assistant/chunk","seq":132,"time":1783860681813,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":133,"time":1783860681815,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"just"}}} -{"type":"assistant/chunk","seq":134,"time":1783860681840,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"ification"}}} -{"type":"assistant/chunk","seq":135,"time":1783860681842,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":136,"time":1783860681842,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":": "}}} -{"type":"assistant/chunk","seq":137,"time":1783860681842,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":138,"time":1783860681842,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"the"}}} -{"type":"assistant/chunk","seq":139,"time":1783860681870,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":" user"}}} -{"type":"assistant/chunk","seq":140,"time":1783860681870,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":" asked"}}} -{"type":"assistant/chunk","seq":141,"time":1783860681870,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":" to"}}} -{"type":"assistant/chunk","seq":142,"time":1783860681870,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":" write"}}} -{"type":"assistant/chunk","seq":143,"time":1783860681870,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":" a"}}} -{"type":"assistant/chunk","seq":144,"time":1783860681872,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":" file"}}} -{"type":"assistant/chunk","seq":145,"time":1783860681901,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":" outside"}}} -{"type":"assistant/chunk","seq":146,"time":1783860681903,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":" the"}}} -{"type":"assistant/chunk","seq":147,"time":1783860681967,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":" workspace"}}} -{"type":"assistant/chunk","seq":148,"time":1783860681967,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":149,"time":1783860681967,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"}"}}} -{"type":"assistant/chunk","seq":150,"time":1783860681967,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to run a specific command with `sandbox_permissions` set to `danger-full-access` and a specific justification. They explicitly said NOT to run it without sandbox_permissions first. Let me do exactly that."}}}} -{"type":"assistant/chunk","seq":151,"time":1783962246274,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","arguments":"{\"command\": \"printf 'escalated\\\\n' > /tmp/dsh-escalated.txt && cat /tmp/dsh-escalated.txt && rm /tmp/dsh-escalated.txt\", \"description\": \"Write to /tmp and verify, then clean up\", \"sandbox_permissions\": \"danger-full-access\", \"justification\": \"the user asked to write a file outside the workspace\"}"}}}} -{"type":"assistant/chunk","seq":152,"time":1783962246274,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":1509,"outputTokens":198,"cacheReadTokens":0,"reasoningTokens":48}}}} -{"type":"assistant/chunk","seq":153,"time":1783962246274,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":154,"time":1783962246274,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to run a specific command with `sandbox_permissions` set to `danger-full-access` and a specific justification. They explicitly said NOT to run it without sandbox_permissions first. Let me do exactly that."},{"type":"tool-call","id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","arguments":"{\"command\": \"printf 'escalated\\\\n' > /tmp/dsh-escalated.txt && cat /tmp/dsh-escalated.txt && rm /tmp/dsh-escalated.txt\", \"description\": \"Write to /tmp and verify, then clean up\", \"sandbox_permissions\": \"danger-full-access\", \"justification\": \"the user asked to write a file outside the workspace\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":1509,"outputTokens":198,"cacheReadTokens":0,"reasoningTokens":48}},"sourceEventSeqs":[7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132,133,134,135,136,137,138,139,140,141,142,143,144,145,146,147,148,149,150,151,152,153],"surfaceOp":"append"} -{"type":"tool/call","seq":155,"time":1783962246274,"data":{"turn":1,"step":1,"callId":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","arguments":"{\"command\": \"printf 'escalated\\\\n' > /tmp/dsh-escalated.txt && cat /tmp/dsh-escalated.txt && rm /tmp/dsh-escalated.txt\", \"description\": \"Write to /tmp and verify, then clean up\", \"sandbox_permissions\": \"danger-full-access\", \"justification\": \"the user asked to write a file outside the workspace\"}"}} -{"type":"approval/asked","seq":156,"time":1783962246275,"data":{"id":"6aac7131-b376-43bc-85c1-266c54443987","toolName":"bash","callId":"call_00_WB1vnPomi8yr6MlcFKTj7912","reason":"escalate sandbox to danger-full-access: the user asked to write a file outside the workspace"}} -{"type":"approval/decided","seq":157,"time":1783962246275,"data":{"id":"6aac7131-b376-43bc-85c1-266c54443987","outcome":"rejected"}} -{"type":"tool/result","seq":158,"time":1783962246275,"data":{"turn":1,"step":1,"callId":"call_00_WB1vnPomi8yr6MlcFKTj7912","content":[{"type":"text","text":"Error: the user rejected escalating this command to \"danger-full-access\""}],"isError":true},"sourceEventSeqs":[155],"surfaceOp":"append"} -{"type":"step/end","seq":159,"time":1783962246276,"data":{"turn":1,"step":1}} -{"type":"step/start","seq":160,"time":1783962246276,"data":{"turn":1,"step":2}} -{"type":"assistant/chunk","seq":161,"time":1783860683140,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"assistant/chunk","seq":162,"time":1783860683141,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} -{"type":"assistant/chunk","seq":163,"time":1783860683141,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} -{"type":"assistant/chunk","seq":164,"time":1783860683170,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" rejected"}}} -{"type":"assistant/chunk","seq":165,"time":1783860683170,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} -{"type":"assistant/chunk","seq":166,"time":1783860683170,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" escalation"}}} -{"type":"assistant/chunk","seq":167,"time":1783860683205,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} -{"type":"assistant/chunk","seq":168,"time":1783860683208,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" As"}}} -{"type":"assistant/chunk","seq":169,"time":1783860683208,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" instructed"}}} -{"type":"assistant/chunk","seq":170,"time":1783860683208,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":","}}} -{"type":"assistant/chunk","seq":171,"time":1783860683208,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" I"}}} -{"type":"assistant/chunk","seq":172,"time":1783860683230,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" will"}}} -{"type":"assistant/chunk","seq":173,"time":1783860683232,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" not"}}} -{"type":"assistant/chunk","seq":174,"time":1783860683233,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" ret"}}} -{"type":"assistant/chunk","seq":175,"time":1783860683260,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"ry"}}} -{"type":"assistant/chunk","seq":176,"time":1783860683260,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} -{"type":"assistant/chunk","seq":177,"time":1783860683260,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" will"}}} -{"type":"assistant/chunk","seq":178,"time":1783860683260,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" not"}}} -{"type":"assistant/chunk","seq":179,"time":1783860683260,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" work"}}} -{"type":"assistant/chunk","seq":180,"time":1783860683294,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" around"}}} -{"type":"assistant/chunk","seq":181,"time":1783860683296,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" it"}}} -{"type":"assistant/chunk","seq":182,"time":1783860683296,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" —"}}} -{"type":"assistant/chunk","seq":183,"time":1783860683317,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" just"}}} -{"type":"assistant/chunk","seq":184,"time":1783860683317,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" explain"}}} -{"type":"assistant/chunk","seq":185,"time":1783860683317,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" in"}}} -{"type":"assistant/chunk","seq":186,"time":1783860683318,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" one"}}} -{"type":"assistant/chunk","seq":187,"time":1783860683318,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" short"}}} -{"type":"assistant/chunk","seq":188,"time":1783860683318,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" sentence"}}} -{"type":"assistant/chunk","seq":189,"time":1783860683347,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} -{"type":"assistant/chunk","seq":190,"time":1783860683347,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" stop"}}} -{"type":"assistant/chunk","seq":191,"time":1783860683349,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} -{"type":"assistant/chunk","seq":192,"time":1783860683349,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} -{"type":"assistant/chunk","seq":193,"time":1783860683375,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"The"}}} -{"type":"assistant/chunk","seq":194,"time":1783860683376,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" user"}}} -{"type":"assistant/chunk","seq":195,"time":1783860683409,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" rejected"}}} -{"type":"assistant/chunk","seq":196,"time":1783860683410,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" the"}}} -{"type":"assistant/chunk","seq":197,"time":1783860683410,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" permission"}}} -{"type":"assistant/chunk","seq":198,"time":1783860683435,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" escalation"}}} -{"type":"assistant/chunk","seq":199,"time":1783860683437,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":","}}} -{"type":"assistant/chunk","seq":200,"time":1783860683437,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" so"}}} -{"type":"assistant/chunk","seq":201,"time":1783860683462,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" this"}}} -{"type":"assistant/chunk","seq":202,"time":1783860683464,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" command"}}} -{"type":"assistant/chunk","seq":203,"time":1783860683464,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" cannot"}}} -{"type":"assistant/chunk","seq":204,"time":1783860683506,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" be"}}} -{"type":"assistant/chunk","seq":205,"time":1783860683506,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" run"}}} -{"type":"assistant/chunk","seq":206,"time":1783860683506,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"."}}} -{"type":"assistant/chunk","seq":207,"time":1783860683506,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user rejected the escalation. As instructed, I will not retry and will not work around it — just explain in one short sentence and stop."}}}} -{"type":"assistant/chunk","seq":208,"time":1783962246279,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"The user rejected the permission escalation, so this command cannot be run."}}}} -{"type":"assistant/chunk","seq":209,"time":1783962246279,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":69,"outputTokens":45,"cacheReadTokens":1664,"reasoningTokens":30}}}} -{"type":"assistant/chunk","seq":210,"time":1783962246279,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":211,"time":1783962246279,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"The user rejected the escalation. As instructed, I will not retry and will not work around it — just explain in one short sentence and stop."},{"type":"text","text":"The user rejected the permission escalation, so this command cannot be run."}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":69,"outputTokens":45,"cacheReadTokens":1664,"reasoningTokens":30}},"sourceEventSeqs":[161,162,163,164,165,166,167,168,169,170,171,172,173,174,175,176,177,178,179,180,181,182,183,184,185,186,187,188,189,190,191,192,193,194,195,196,197,198,199,200,201,202,203,204,205,206,207,208,209,210],"surfaceOp":"append"} -{"type":"step/end","seq":212,"time":1783962246279,"data":{"turn":1,"step":2}} -{"type":"turn/end","seq":213,"time":1783962246279,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"session/title","seq":5,"time":1783962246267,"data":{"title":"The sandbox already denied writing","messageSeqs":[4],"source":{"kind":"fallback"}}} +{"type":"step/start","seq":6,"time":1783962246269,"data":{"turn":1,"step":1}} +{"type":"request/header","seq":7,"time":1783962246269,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"assistant/chunk","seq":8,"time":1783860680779,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":9,"time":1783860680782,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":10,"time":1783860680782,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} +{"type":"assistant/chunk","seq":11,"time":1783860680782,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" wants"}}} +{"type":"assistant/chunk","seq":12,"time":1783860680830,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":13,"time":1783860680831,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":14,"time":1783860680831,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" run"}}} +{"type":"assistant/chunk","seq":15,"time":1783860680859,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" a"}}} +{"type":"assistant/chunk","seq":16,"time":1783860680859,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" specific"}}} +{"type":"assistant/chunk","seq":17,"time":1783860680868,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" command"}}} +{"type":"assistant/chunk","seq":18,"time":1783860680871,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":19,"time":1783860680871,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" `"}}} +{"type":"assistant/chunk","seq":20,"time":1783860680872,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"sand"}}} +{"type":"assistant/chunk","seq":21,"time":1783860680872,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"box"}}} +{"type":"assistant/chunk","seq":22,"time":1783860680902,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"_per"}}} +{"type":"assistant/chunk","seq":23,"time":1783860680903,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"missions"}}} +{"type":"assistant/chunk","seq":24,"time":1783860680903,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"`"}}} +{"type":"assistant/chunk","seq":25,"time":1783860680903,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" set"}}} +{"type":"assistant/chunk","seq":26,"time":1783860680903,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":27,"time":1783860680903,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" `"}}} +{"type":"assistant/chunk","seq":28,"time":1783860680937,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"danger"}}} +{"type":"assistant/chunk","seq":29,"time":1783860680938,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"-full"}}} +{"type":"assistant/chunk","seq":30,"time":1783860680938,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"-access"}}} +{"type":"assistant/chunk","seq":31,"time":1783860680956,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"`"}}} +{"type":"assistant/chunk","seq":32,"time":1783860680958,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} +{"type":"assistant/chunk","seq":33,"time":1783860680958,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" a"}}} +{"type":"assistant/chunk","seq":34,"time":1783860680958,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" specific"}}} +{"type":"assistant/chunk","seq":35,"time":1783860680985,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" justification"}}} +{"type":"assistant/chunk","seq":36,"time":1783860680985,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":37,"time":1783860681022,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" They"}}} +{"type":"assistant/chunk","seq":38,"time":1783860681024,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" explicitly"}}} +{"type":"assistant/chunk","seq":39,"time":1783860681024,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" said"}}} +{"type":"assistant/chunk","seq":40,"time":1783860681024,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" NOT"}}} +{"type":"assistant/chunk","seq":41,"time":1783860681024,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":42,"time":1783860681043,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" run"}}} +{"type":"assistant/chunk","seq":43,"time":1783860681091,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" it"}}} +{"type":"assistant/chunk","seq":44,"time":1783860681091,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" without"}}} +{"type":"assistant/chunk","seq":45,"time":1783860681091,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" sand"}}} +{"type":"assistant/chunk","seq":46,"time":1783860681091,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"box"}}} +{"type":"assistant/chunk","seq":47,"time":1783860681091,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"_per"}}} +{"type":"assistant/chunk","seq":48,"time":1783860681091,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"missions"}}} +{"type":"assistant/chunk","seq":49,"time":1783860681107,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" first"}}} +{"type":"assistant/chunk","seq":50,"time":1783860681107,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":51,"time":1783860681108,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" Let"}}} +{"type":"assistant/chunk","seq":52,"time":1783860681138,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":53,"time":1783860681138,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" do"}}} +{"type":"assistant/chunk","seq":54,"time":1783860681251,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" exactly"}}} +{"type":"assistant/chunk","seq":55,"time":1783860681251,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" that"}}} +{"type":"assistant/chunk","seq":56,"time":1783860681251,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":57,"time":1783860681251,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":58,"time":1783860681252,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":""}}} +{"type":"assistant/chunk","seq":59,"time":1783860681252,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"{"}}} +{"type":"assistant/chunk","seq":60,"time":1783860681252,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":61,"time":1783860681252,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"command"}}} +{"type":"assistant/chunk","seq":62,"time":1783860681280,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":63,"time":1783860681281,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":": "}}} +{"type":"assistant/chunk","seq":64,"time":1783860681281,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":65,"time":1783860681324,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"printf"}}} +{"type":"assistant/chunk","seq":66,"time":1783860681324,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":" '"}}} +{"type":"assistant/chunk","seq":67,"time":1783860681324,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"es"}}} +{"type":"assistant/chunk","seq":68,"time":1783860681324,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"cal"}}} +{"type":"assistant/chunk","seq":69,"time":1783860681324,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"ated"}}} +{"type":"assistant/chunk","seq":70,"time":1783860681324,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"\\\\n"}}} +{"type":"assistant/chunk","seq":71,"time":1783860681342,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"'"}}} +{"type":"assistant/chunk","seq":72,"time":1783860681343,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":" >"}}} +{"type":"assistant/chunk","seq":73,"time":1783860681343,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":" /"}}} +{"type":"assistant/chunk","seq":74,"time":1783860681343,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"tmp"}}} +{"type":"assistant/chunk","seq":75,"time":1783860681368,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"/d"}}} +{"type":"assistant/chunk","seq":76,"time":1783860681371,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"sh"}}} +{"type":"assistant/chunk","seq":77,"time":1783860681371,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"-es"}}} +{"type":"assistant/chunk","seq":78,"time":1783860681371,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"cal"}}} +{"type":"assistant/chunk","seq":79,"time":1783860681371,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"ated"}}} +{"type":"assistant/chunk","seq":80,"time":1783860681400,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":".txt"}}} +{"type":"assistant/chunk","seq":81,"time":1783860681400,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":" &&"}}} +{"type":"assistant/chunk","seq":82,"time":1783860681400,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":" cat"}}} +{"type":"assistant/chunk","seq":83,"time":1783860681400,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":" /"}}} +{"type":"assistant/chunk","seq":84,"time":1783860681401,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"tmp"}}} +{"type":"assistant/chunk","seq":85,"time":1783860681402,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"/d"}}} +{"type":"assistant/chunk","seq":86,"time":1783860681432,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"sh"}}} +{"type":"assistant/chunk","seq":87,"time":1783860681432,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"-es"}}} +{"type":"assistant/chunk","seq":88,"time":1783860681432,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"cal"}}} +{"type":"assistant/chunk","seq":89,"time":1783860681432,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"ated"}}} +{"type":"assistant/chunk","seq":90,"time":1783860681432,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":".txt"}}} +{"type":"assistant/chunk","seq":91,"time":1783860681432,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":" &&"}}} +{"type":"assistant/chunk","seq":92,"time":1783860681456,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":" rm"}}} +{"type":"assistant/chunk","seq":93,"time":1783860681456,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":" /"}}} +{"type":"assistant/chunk","seq":94,"time":1783860681456,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"tmp"}}} +{"type":"assistant/chunk","seq":95,"time":1783860681456,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"/d"}}} +{"type":"assistant/chunk","seq":96,"time":1783860681456,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"sh"}}} +{"type":"assistant/chunk","seq":97,"time":1783860681456,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"-es"}}} +{"type":"assistant/chunk","seq":98,"time":1783860681485,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"cal"}}} +{"type":"assistant/chunk","seq":99,"time":1783860681519,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"ated"}}} +{"type":"assistant/chunk","seq":100,"time":1783860681521,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":".txt"}}} +{"type":"assistant/chunk","seq":101,"time":1783860681521,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":102,"time":1783860681521,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":", "}}} +{"type":"assistant/chunk","seq":103,"time":1783860681521,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":104,"time":1783860681542,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"description"}}} +{"type":"assistant/chunk","seq":105,"time":1783860681545,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":106,"time":1783860681545,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":": "}}} +{"type":"assistant/chunk","seq":107,"time":1783860681573,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":108,"time":1783860681573,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"Write"}}} +{"type":"assistant/chunk","seq":109,"time":1783860681574,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":" to"}}} +{"type":"assistant/chunk","seq":110,"time":1783860681605,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":" /"}}} +{"type":"assistant/chunk","seq":111,"time":1783860681608,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"tmp"}}} +{"type":"assistant/chunk","seq":112,"time":1783860681630,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":" and"}}} +{"type":"assistant/chunk","seq":113,"time":1783860681632,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":" verify"}}} +{"type":"assistant/chunk","seq":114,"time":1783860681661,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":","}}} +{"type":"assistant/chunk","seq":115,"time":1783860681661,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":" then"}}} +{"type":"assistant/chunk","seq":116,"time":1783860681693,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":" clean"}}} +{"type":"assistant/chunk","seq":117,"time":1783860681693,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":" up"}}} +{"type":"assistant/chunk","seq":118,"time":1783860681693,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":119,"time":1783860681693,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":", "}}} +{"type":"assistant/chunk","seq":120,"time":1783860681725,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":121,"time":1783860681725,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"sand"}}} +{"type":"assistant/chunk","seq":122,"time":1783860681726,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"box"}}} +{"type":"assistant/chunk","seq":123,"time":1783860681726,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"_per"}}} +{"type":"assistant/chunk","seq":124,"time":1783860681751,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"missions"}}} +{"type":"assistant/chunk","seq":125,"time":1783860681753,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":126,"time":1783860681754,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":": "}}} +{"type":"assistant/chunk","seq":127,"time":1783860681754,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":128,"time":1783860681754,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"danger"}}} +{"type":"assistant/chunk","seq":129,"time":1783860681813,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"-full"}}} +{"type":"assistant/chunk","seq":130,"time":1783860681813,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"-access"}}} +{"type":"assistant/chunk","seq":131,"time":1783860681813,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":132,"time":1783860681813,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":", "}}} +{"type":"assistant/chunk","seq":133,"time":1783860681813,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":134,"time":1783860681815,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"just"}}} +{"type":"assistant/chunk","seq":135,"time":1783860681840,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"ification"}}} +{"type":"assistant/chunk","seq":136,"time":1783860681842,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":137,"time":1783860681842,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":": "}}} +{"type":"assistant/chunk","seq":138,"time":1783860681842,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":139,"time":1783860681842,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"the"}}} +{"type":"assistant/chunk","seq":140,"time":1783860681870,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":" user"}}} +{"type":"assistant/chunk","seq":141,"time":1783860681870,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":" asked"}}} +{"type":"assistant/chunk","seq":142,"time":1783860681870,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":" to"}}} +{"type":"assistant/chunk","seq":143,"time":1783860681870,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":" write"}}} +{"type":"assistant/chunk","seq":144,"time":1783860681870,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":" a"}}} +{"type":"assistant/chunk","seq":145,"time":1783860681872,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":" file"}}} +{"type":"assistant/chunk","seq":146,"time":1783860681901,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":" outside"}}} +{"type":"assistant/chunk","seq":147,"time":1783860681903,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":" the"}}} +{"type":"assistant/chunk","seq":148,"time":1783860681967,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":" workspace"}}} +{"type":"assistant/chunk","seq":149,"time":1783860681967,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":150,"time":1783860681967,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"}"}}} +{"type":"assistant/chunk","seq":151,"time":1783860681967,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to run a specific command with `sandbox_permissions` set to `danger-full-access` and a specific justification. They explicitly said NOT to run it without sandbox_permissions first. Let me do exactly that."}}}} +{"type":"assistant/chunk","seq":152,"time":1783962246274,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","arguments":"{\"command\": \"printf 'escalated\\\\n' > /tmp/dsh-escalated.txt && cat /tmp/dsh-escalated.txt && rm /tmp/dsh-escalated.txt\", \"description\": \"Write to /tmp and verify, then clean up\", \"sandbox_permissions\": \"danger-full-access\", \"justification\": \"the user asked to write a file outside the workspace\"}"}}}} +{"type":"assistant/chunk","seq":153,"time":1783962246274,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":1509,"outputTokens":198,"cacheReadTokens":0,"reasoningTokens":48}}}} +{"type":"assistant/chunk","seq":154,"time":1783962246274,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":155,"time":1783962246274,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to run a specific command with `sandbox_permissions` set to `danger-full-access` and a specific justification. They explicitly said NOT to run it without sandbox_permissions first. Let me do exactly that."},{"type":"tool-call","id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","arguments":"{\"command\": \"printf 'escalated\\\\n' > /tmp/dsh-escalated.txt && cat /tmp/dsh-escalated.txt && rm /tmp/dsh-escalated.txt\", \"description\": \"Write to /tmp and verify, then clean up\", \"sandbox_permissions\": \"danger-full-access\", \"justification\": \"the user asked to write a file outside the workspace\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":1509,"outputTokens":198,"cacheReadTokens":0,"reasoningTokens":48}},"sourceEventSeqs":[8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132,133,134,135,136,137,138,139,140,141,142,143,144,145,146,147,148,149,150,151,152,153,154],"surfaceOp":"append"} +{"type":"tool/call","seq":156,"time":1783962246274,"data":{"turn":1,"step":1,"callId":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","arguments":"{\"command\": \"printf 'escalated\\\\n' > /tmp/dsh-escalated.txt && cat /tmp/dsh-escalated.txt && rm /tmp/dsh-escalated.txt\", \"description\": \"Write to /tmp and verify, then clean up\", \"sandbox_permissions\": \"danger-full-access\", \"justification\": \"the user asked to write a file outside the workspace\"}"}} +{"type":"approval/asked","seq":157,"time":1783962246275,"data":{"id":"e5cc84ef-8704-4cd9-8e47-897a0a51beb6","toolName":"bash","callId":"call_00_WB1vnPomi8yr6MlcFKTj7912","reason":"escalate sandbox to danger-full-access: the user asked to write a file outside the workspace"}} +{"type":"approval/decided","seq":158,"time":1783962246275,"data":{"id":"e5cc84ef-8704-4cd9-8e47-897a0a51beb6","outcome":"rejected"}} +{"type":"tool/result","seq":159,"time":1783962246275,"data":{"turn":1,"step":1,"callId":"call_00_WB1vnPomi8yr6MlcFKTj7912","content":[{"type":"text","text":"Error: the user rejected escalating this command to \"danger-full-access\""}],"isError":true},"sourceEventSeqs":[156],"surfaceOp":"append"} +{"type":"step/end","seq":160,"time":1783962246276,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":161,"time":1783962246276,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":162,"time":1783860683140,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":163,"time":1783860683141,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":164,"time":1783860683141,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} +{"type":"assistant/chunk","seq":165,"time":1783860683170,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" rejected"}}} +{"type":"assistant/chunk","seq":166,"time":1783860683170,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":167,"time":1783860683170,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" escalation"}}} +{"type":"assistant/chunk","seq":168,"time":1783860683205,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":169,"time":1783860683208,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" As"}}} +{"type":"assistant/chunk","seq":170,"time":1783860683208,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" instructed"}}} +{"type":"assistant/chunk","seq":171,"time":1783860683208,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":","}}} +{"type":"assistant/chunk","seq":172,"time":1783860683208,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" I"}}} +{"type":"assistant/chunk","seq":173,"time":1783860683230,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" will"}}} +{"type":"assistant/chunk","seq":174,"time":1783860683232,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" not"}}} +{"type":"assistant/chunk","seq":175,"time":1783860683233,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" ret"}}} +{"type":"assistant/chunk","seq":176,"time":1783860683260,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"ry"}}} +{"type":"assistant/chunk","seq":177,"time":1783860683260,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} +{"type":"assistant/chunk","seq":178,"time":1783860683260,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" will"}}} +{"type":"assistant/chunk","seq":179,"time":1783860683260,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" not"}}} +{"type":"assistant/chunk","seq":180,"time":1783860683260,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" work"}}} +{"type":"assistant/chunk","seq":181,"time":1783860683294,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" around"}}} +{"type":"assistant/chunk","seq":182,"time":1783860683296,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" it"}}} +{"type":"assistant/chunk","seq":183,"time":1783860683296,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" —"}}} +{"type":"assistant/chunk","seq":184,"time":1783860683317,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" just"}}} +{"type":"assistant/chunk","seq":185,"time":1783860683317,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" explain"}}} +{"type":"assistant/chunk","seq":186,"time":1783860683317,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" in"}}} +{"type":"assistant/chunk","seq":187,"time":1783860683318,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" one"}}} +{"type":"assistant/chunk","seq":188,"time":1783860683318,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" short"}}} +{"type":"assistant/chunk","seq":189,"time":1783860683318,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" sentence"}}} +{"type":"assistant/chunk","seq":190,"time":1783860683347,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} +{"type":"assistant/chunk","seq":191,"time":1783860683347,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" stop"}}} +{"type":"assistant/chunk","seq":192,"time":1783860683349,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":193,"time":1783860683349,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"assistant/chunk","seq":194,"time":1783860683375,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"The"}}} +{"type":"assistant/chunk","seq":195,"time":1783860683376,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" user"}}} +{"type":"assistant/chunk","seq":196,"time":1783860683409,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" rejected"}}} +{"type":"assistant/chunk","seq":197,"time":1783860683410,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" the"}}} +{"type":"assistant/chunk","seq":198,"time":1783860683410,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" permission"}}} +{"type":"assistant/chunk","seq":199,"time":1783860683435,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" escalation"}}} +{"type":"assistant/chunk","seq":200,"time":1783860683437,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":","}}} +{"type":"assistant/chunk","seq":201,"time":1783860683437,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" so"}}} +{"type":"assistant/chunk","seq":202,"time":1783860683462,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" this"}}} +{"type":"assistant/chunk","seq":203,"time":1783860683464,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" command"}}} +{"type":"assistant/chunk","seq":204,"time":1783860683464,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" cannot"}}} +{"type":"assistant/chunk","seq":205,"time":1783860683506,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" be"}}} +{"type":"assistant/chunk","seq":206,"time":1783860683506,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" run"}}} +{"type":"assistant/chunk","seq":207,"time":1783860683506,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"."}}} +{"type":"assistant/chunk","seq":208,"time":1783860683506,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user rejected the escalation. As instructed, I will not retry and will not work around it — just explain in one short sentence and stop."}}}} +{"type":"assistant/chunk","seq":209,"time":1783962246279,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"The user rejected the permission escalation, so this command cannot be run."}}}} +{"type":"assistant/chunk","seq":210,"time":1783962246279,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":69,"outputTokens":45,"cacheReadTokens":1664,"reasoningTokens":30}}}} +{"type":"assistant/chunk","seq":211,"time":1783962246279,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":212,"time":1783962246279,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"The user rejected the escalation. As instructed, I will not retry and will not work around it — just explain in one short sentence and stop."},{"type":"text","text":"The user rejected the permission escalation, so this command cannot be run."}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":69,"outputTokens":45,"cacheReadTokens":1664,"reasoningTokens":30}},"sourceEventSeqs":[162,163,164,165,166,167,168,169,170,171,172,173,174,175,176,177,178,179,180,181,182,183,184,185,186,187,188,189,190,191,192,193,194,195,196,197,198,199,200,201,202,203,204,205,206,207,208,209,210,211],"surfaceOp":"append"} +{"type":"step/end","seq":213,"time":1783962246279,"data":{"turn":1,"step":2}} +{"type":"turn/end","seq":214,"time":1783962246279,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/fs-escalation-approved/session.jsonl b/examples/acp-agent/tests/snapshots/fs-escalation-approved/session.jsonl index 4ff73a5020..424aeb1aa3 100644 --- a/examples/acp-agent/tests/snapshots/fs-escalation-approved/session.jsonl +++ b/examples/acp-agent/tests/snapshots/fs-escalation-approved/session.jsonl @@ -1,127 +1,128 @@ -{"type":"session","version":0,"id":"977a4820-f609-4b48-9039-adcdd921c5fe","createdAt":1784045702340,"cwd":"/var/folders/2g/b32ct0qn1d728l_v6tdkjytr0000gn/T/acp-snap-cwd-vmEGzd"} +{"type":"session","version":0,"id":"977a4820-f609-4b48-9039-adcdd921c5fe","createdAt":1784045702340,"cwd":"/var/folders/2g/b32ct0qn1d728l_v6tdkjytr0000gn/T/acp-snap-cwd-vmEGzd","delegationDepth":0} {"type":"turn/start","seq":0,"time":1784045702342,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"permission/preset","seq":1,"time":1784045702343,"data":{"preset":"workspace-write"}} {"type":"sandbox/mode","seq":2,"time":1784045702343,"data":{"mode":"workspace-write"}} {"type":"approval/policy","seq":3,"time":1784045702343,"data":{"policy":"ask"}} {"type":"user/message","seq":4,"time":1784045702343,"data":{"content":[{"type":"text","text":"Use the write tool (NOT bash) to create escalated.md in the current directory containing exactly the single line: escalated. An equivalent write was denied earlier, so make this one single write call with sandbox_permissions set to danger-full-access and the justification 'the user asked to escalate this write'. Do not call write without sandbox_permissions first. I will approve the permission prompt. After the result, reply with exactly the single word DONE."}],"source":{"kind":"user"}},"surfaceOp":"append"} -{"type":"step/start","seq":5,"time":1784045702345,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":6,"time":1784045702345,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} -{"type":"assistant/chunk","seq":7,"time":1784045703046,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"assistant/chunk","seq":8,"time":1784045703046,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} -{"type":"assistant/chunk","seq":9,"time":1784045703162,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} -{"type":"assistant/chunk","seq":10,"time":1784045703172,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" wants"}}} -{"type":"assistant/chunk","seq":11,"time":1784045703172,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} -{"type":"assistant/chunk","seq":12,"time":1784045703173,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} -{"type":"assistant/chunk","seq":13,"time":1784045703173,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" create"}}} -{"type":"assistant/chunk","seq":14,"time":1784045703173,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" a"}}} -{"type":"assistant/chunk","seq":15,"time":1784045703173,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" file"}}} -{"type":"assistant/chunk","seq":16,"time":1784045703199,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" using"}}} -{"type":"assistant/chunk","seq":17,"time":1784045703225,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} -{"type":"assistant/chunk","seq":18,"time":1784045703251,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" write"}}} -{"type":"assistant/chunk","seq":19,"time":1784045703252,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" tool"}}} -{"type":"assistant/chunk","seq":20,"time":1784045703252,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} -{"type":"assistant/chunk","seq":21,"time":1784045703252,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" sand"}}} -{"type":"assistant/chunk","seq":22,"time":1784045703252,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"box"}}} -{"type":"assistant/chunk","seq":23,"time":1784045703252,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"_per"}}} -{"type":"assistant/chunk","seq":24,"time":1784045703277,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"missions"}}} -{"type":"assistant/chunk","seq":25,"time":1784045703278,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} -{"type":"assistant/chunk","seq":26,"time":1784045703278,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" Let"}}} -{"type":"assistant/chunk","seq":27,"time":1784045703278,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} -{"type":"assistant/chunk","seq":28,"time":1784045703278,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" do"}}} -{"type":"assistant/chunk","seq":29,"time":1784045703304,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" that"}}} -{"type":"assistant/chunk","seq":30,"time":1784045703304,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} -{"type":"assistant/chunk","seq":31,"time":1784045703356,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":32,"time":1784045703356,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","argumentsDelta":""}}} -{"type":"assistant/chunk","seq":33,"time":1784045703381,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","argumentsDelta":"{"}}} -{"type":"assistant/chunk","seq":34,"time":1784045703381,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":35,"time":1784045703381,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","argumentsDelta":"file"}}} -{"type":"assistant/chunk","seq":36,"time":1784045703405,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","argumentsDelta":"_path"}}} -{"type":"assistant/chunk","seq":37,"time":1784045703406,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":38,"time":1784045703406,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","argumentsDelta":": "}}} -{"type":"assistant/chunk","seq":39,"time":1784045703406,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":40,"time":1784045703431,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","argumentsDelta":"es"}}} -{"type":"assistant/chunk","seq":41,"time":1784045703432,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","argumentsDelta":"cal"}}} -{"type":"assistant/chunk","seq":42,"time":1784045703432,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","argumentsDelta":"ated"}}} -{"type":"assistant/chunk","seq":43,"time":1784045703432,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","argumentsDelta":".md"}}} -{"type":"assistant/chunk","seq":44,"time":1784045703432,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":45,"time":1784045703483,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","argumentsDelta":", "}}} -{"type":"assistant/chunk","seq":46,"time":1784045703483,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":47,"time":1784045703483,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","argumentsDelta":"content"}}} -{"type":"assistant/chunk","seq":48,"time":1784045703483,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":49,"time":1784045703483,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","argumentsDelta":": "}}} -{"type":"assistant/chunk","seq":50,"time":1784045703509,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":51,"time":1784045703509,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","argumentsDelta":"es"}}} -{"type":"assistant/chunk","seq":52,"time":1784045703509,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","argumentsDelta":"cal"}}} -{"type":"assistant/chunk","seq":53,"time":1784045703509,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","argumentsDelta":"ated"}}} -{"type":"assistant/chunk","seq":54,"time":1784045703509,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":55,"time":1784045703565,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","argumentsDelta":", "}}} -{"type":"assistant/chunk","seq":56,"time":1784045703566,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":57,"time":1784045703566,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","argumentsDelta":"sand"}}} -{"type":"assistant/chunk","seq":58,"time":1784045703566,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","argumentsDelta":"box"}}} -{"type":"assistant/chunk","seq":59,"time":1784045703566,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","argumentsDelta":"_per"}}} -{"type":"assistant/chunk","seq":60,"time":1784045703566,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","argumentsDelta":"missions"}}} -{"type":"assistant/chunk","seq":61,"time":1784045703566,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":62,"time":1784045703591,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","argumentsDelta":": "}}} -{"type":"assistant/chunk","seq":63,"time":1784045703591,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":64,"time":1784045703591,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","argumentsDelta":"danger"}}} -{"type":"assistant/chunk","seq":65,"time":1784045703591,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","argumentsDelta":"-full"}}} -{"type":"assistant/chunk","seq":66,"time":1784045703617,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","argumentsDelta":"-access"}}} -{"type":"assistant/chunk","seq":67,"time":1784045703618,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":68,"time":1784045703644,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","argumentsDelta":", "}}} -{"type":"assistant/chunk","seq":69,"time":1784045703645,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":70,"time":1784045703645,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","argumentsDelta":"just"}}} -{"type":"assistant/chunk","seq":71,"time":1784045703645,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","argumentsDelta":"ification"}}} -{"type":"assistant/chunk","seq":72,"time":1784045703669,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":73,"time":1784045703669,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","argumentsDelta":": "}}} -{"type":"assistant/chunk","seq":74,"time":1784045703669,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":75,"time":1784045703669,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","argumentsDelta":"the"}}} -{"type":"assistant/chunk","seq":76,"time":1784045703696,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","argumentsDelta":" user"}}} -{"type":"assistant/chunk","seq":77,"time":1784045703696,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","argumentsDelta":" asked"}}} -{"type":"assistant/chunk","seq":78,"time":1784045703696,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","argumentsDelta":" to"}}} -{"type":"assistant/chunk","seq":79,"time":1784045703696,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","argumentsDelta":" escalate"}}} -{"type":"assistant/chunk","seq":80,"time":1784045703724,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","argumentsDelta":" this"}}} -{"type":"assistant/chunk","seq":81,"time":1784045703724,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","argumentsDelta":" write"}}} -{"type":"assistant/chunk","seq":82,"time":1784045703724,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":83,"time":1784045703749,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","argumentsDelta":"}"}}} -{"type":"assistant/chunk","seq":84,"time":1784045703776,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to create a file using the write tool with sandbox_permissions. Let me do that."}}}} -{"type":"assistant/chunk","seq":85,"time":1784045703776,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","arguments":"{\"file_path\": \"escalated.md\", \"content\": \"escalated\", \"sandbox_permissions\": \"danger-full-access\", \"justification\": \"the user asked to escalate this write\"}"}}}} -{"type":"assistant/chunk","seq":86,"time":1784045703776,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3871,"outputTokens":132,"cacheReadTokens":0,"reasoningTokens":23}}}} -{"type":"assistant/chunk","seq":87,"time":1784045703776,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":88,"time":1784045703780,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to create a file using the write tool with sandbox_permissions. Let me do that."},{"type":"tool-call","id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","arguments":"{\"file_path\": \"escalated.md\", \"content\": \"escalated\", \"sandbox_permissions\": \"danger-full-access\", \"justification\": \"the user asked to escalate this write\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":3871,"outputTokens":132,"cacheReadTokens":0,"reasoningTokens":23}},"sourceEventSeqs":[7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87],"surfaceOp":"append"} -{"type":"tool/call","seq":89,"time":1784045703780,"data":{"turn":1,"step":1,"callId":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","arguments":"{\"file_path\": \"escalated.md\", \"content\": \"escalated\", \"sandbox_permissions\": \"danger-full-access\", \"justification\": \"the user asked to escalate this write\"}"}} -{"type":"approval/asked","seq":90,"time":1784045703782,"data":{"id":"81db5df9-1287-4cc2-a4dc-24fd34b6e711","toolName":"write","callId":"call_00_Fnymmavpr4klMDy4Fdej3227","reason":"escalate sandbox to danger-full-access: the user asked to escalate this write"}} -{"type":"approval/decided","seq":91,"time":1784045703786,"data":{"id":"81db5df9-1287-4cc2-a4dc-24fd34b6e711","outcome":"allowed-once"}} -{"type":"tool/result","seq":92,"time":1784045703798,"data":{"turn":1,"step":1,"callId":"call_00_Fnymmavpr4klMDy4Fdej3227","content":[{"type":"text","text":"/var/folders/2g/b32ct0qn1d728l_v6tdkjytr0000gn/T/acp-snap-cwd-vmEGzd/escalated.md\nfile\n\nCreated file\n"}],"isError":false},"sourceEventSeqs":[89],"surfaceOp":"append"} -{"type":"step/end","seq":93,"time":1784045703798,"data":{"turn":1,"step":1}} -{"type":"step/start","seq":94,"time":1784045703799,"data":{"turn":1,"step":2}} -{"type":"assistant/chunk","seq":95,"time":1784045704512,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"assistant/chunk","seq":96,"time":1784045704512,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} -{"type":"assistant/chunk","seq":97,"time":1784045704620,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" file"}}} -{"type":"assistant/chunk","seq":98,"time":1784045704645,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" was"}}} -{"type":"assistant/chunk","seq":99,"time":1784045704646,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" created"}}} -{"type":"assistant/chunk","seq":100,"time":1784045704646,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" successfully"}}} -{"type":"assistant/chunk","seq":101,"time":1784045704646,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} -{"type":"assistant/chunk","seq":102,"time":1784045704646,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" The"}}} -{"type":"assistant/chunk","seq":103,"time":1784045704646,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} -{"type":"assistant/chunk","seq":104,"time":1784045704672,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" asked"}}} -{"type":"assistant/chunk","seq":105,"time":1784045704673,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} -{"type":"assistant/chunk","seq":106,"time":1784045704673,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} -{"type":"assistant/chunk","seq":107,"time":1784045704673,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} -{"type":"assistant/chunk","seq":108,"time":1784045704699,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} -{"type":"assistant/chunk","seq":109,"time":1784045704699,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" exactly"}}} -{"type":"assistant/chunk","seq":110,"time":1784045704699,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} -{"type":"assistant/chunk","seq":111,"time":1784045704726,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" single"}}} -{"type":"assistant/chunk","seq":112,"time":1784045704726,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" word"}}} -{"type":"assistant/chunk","seq":113,"time":1784045704726,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" D"}}} -{"type":"assistant/chunk","seq":114,"time":1784045704754,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"ONE"}}} -{"type":"assistant/chunk","seq":115,"time":1784045704754,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} -{"type":"assistant/chunk","seq":116,"time":1784045704754,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} -{"type":"assistant/chunk","seq":117,"time":1784045704754,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"D"}}} -{"type":"assistant/chunk","seq":118,"time":1784045704754,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"ONE"}}} -{"type":"assistant/chunk","seq":119,"time":1784045704755,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The file was created successfully. The user asked me to reply with exactly the single word DONE."}}}} -{"type":"assistant/chunk","seq":120,"time":1784045704755,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"DONE"}}}} -{"type":"assistant/chunk","seq":121,"time":1784045704755,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":107,"outputTokens":23,"cacheReadTokens":3968,"reasoningTokens":20}}}} -{"type":"assistant/chunk","seq":122,"time":1784045704755,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":123,"time":1784045704755,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"The file was created successfully. The user asked me to reply with exactly the single word DONE."},{"type":"text","text":"DONE"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":107,"outputTokens":23,"cacheReadTokens":3968,"reasoningTokens":20}},"sourceEventSeqs":[95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122],"surfaceOp":"append"} -{"type":"step/end","seq":124,"time":1784045704755,"data":{"turn":1,"step":2}} -{"type":"turn/end","seq":125,"time":1784045704756,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"session/title","seq":5,"time":1784045702343,"data":{"title":"Use the write tool (NOT","messageSeqs":[4],"source":{"kind":"fallback"}}} +{"type":"step/start","seq":6,"time":1784045702345,"data":{"turn":1,"step":1}} +{"type":"request/header","seq":7,"time":1784045702345,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"assistant/chunk","seq":8,"time":1784045703046,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":9,"time":1784045703046,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":10,"time":1784045703162,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} +{"type":"assistant/chunk","seq":11,"time":1784045703172,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" wants"}}} +{"type":"assistant/chunk","seq":12,"time":1784045703172,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":13,"time":1784045703173,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":14,"time":1784045703173,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" create"}}} +{"type":"assistant/chunk","seq":15,"time":1784045703173,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" a"}}} +{"type":"assistant/chunk","seq":16,"time":1784045703173,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" file"}}} +{"type":"assistant/chunk","seq":17,"time":1784045703199,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" using"}}} +{"type":"assistant/chunk","seq":18,"time":1784045703225,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":19,"time":1784045703251,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" write"}}} +{"type":"assistant/chunk","seq":20,"time":1784045703252,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" tool"}}} +{"type":"assistant/chunk","seq":21,"time":1784045703252,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":22,"time":1784045703252,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" sand"}}} +{"type":"assistant/chunk","seq":23,"time":1784045703252,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"box"}}} +{"type":"assistant/chunk","seq":24,"time":1784045703252,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"_per"}}} +{"type":"assistant/chunk","seq":25,"time":1784045703277,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"missions"}}} +{"type":"assistant/chunk","seq":26,"time":1784045703278,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":27,"time":1784045703278,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" Let"}}} +{"type":"assistant/chunk","seq":28,"time":1784045703278,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":29,"time":1784045703278,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" do"}}} +{"type":"assistant/chunk","seq":30,"time":1784045703304,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" that"}}} +{"type":"assistant/chunk","seq":31,"time":1784045703304,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":32,"time":1784045703356,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":33,"time":1784045703356,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","argumentsDelta":""}}} +{"type":"assistant/chunk","seq":34,"time":1784045703381,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","argumentsDelta":"{"}}} +{"type":"assistant/chunk","seq":35,"time":1784045703381,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":36,"time":1784045703381,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","argumentsDelta":"file"}}} +{"type":"assistant/chunk","seq":37,"time":1784045703405,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","argumentsDelta":"_path"}}} +{"type":"assistant/chunk","seq":38,"time":1784045703406,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":39,"time":1784045703406,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","argumentsDelta":": "}}} +{"type":"assistant/chunk","seq":40,"time":1784045703406,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":41,"time":1784045703431,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","argumentsDelta":"es"}}} +{"type":"assistant/chunk","seq":42,"time":1784045703432,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","argumentsDelta":"cal"}}} +{"type":"assistant/chunk","seq":43,"time":1784045703432,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","argumentsDelta":"ated"}}} +{"type":"assistant/chunk","seq":44,"time":1784045703432,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","argumentsDelta":".md"}}} +{"type":"assistant/chunk","seq":45,"time":1784045703432,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":46,"time":1784045703483,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","argumentsDelta":", "}}} +{"type":"assistant/chunk","seq":47,"time":1784045703483,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":48,"time":1784045703483,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","argumentsDelta":"content"}}} +{"type":"assistant/chunk","seq":49,"time":1784045703483,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":50,"time":1784045703483,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","argumentsDelta":": "}}} +{"type":"assistant/chunk","seq":51,"time":1784045703509,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":52,"time":1784045703509,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","argumentsDelta":"es"}}} +{"type":"assistant/chunk","seq":53,"time":1784045703509,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","argumentsDelta":"cal"}}} +{"type":"assistant/chunk","seq":54,"time":1784045703509,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","argumentsDelta":"ated"}}} +{"type":"assistant/chunk","seq":55,"time":1784045703509,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":56,"time":1784045703565,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","argumentsDelta":", "}}} +{"type":"assistant/chunk","seq":57,"time":1784045703566,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":58,"time":1784045703566,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","argumentsDelta":"sand"}}} +{"type":"assistant/chunk","seq":59,"time":1784045703566,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","argumentsDelta":"box"}}} +{"type":"assistant/chunk","seq":60,"time":1784045703566,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","argumentsDelta":"_per"}}} +{"type":"assistant/chunk","seq":61,"time":1784045703566,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","argumentsDelta":"missions"}}} +{"type":"assistant/chunk","seq":62,"time":1784045703566,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":63,"time":1784045703591,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","argumentsDelta":": "}}} +{"type":"assistant/chunk","seq":64,"time":1784045703591,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":65,"time":1784045703591,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","argumentsDelta":"danger"}}} +{"type":"assistant/chunk","seq":66,"time":1784045703591,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","argumentsDelta":"-full"}}} +{"type":"assistant/chunk","seq":67,"time":1784045703617,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","argumentsDelta":"-access"}}} +{"type":"assistant/chunk","seq":68,"time":1784045703618,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":69,"time":1784045703644,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","argumentsDelta":", "}}} +{"type":"assistant/chunk","seq":70,"time":1784045703645,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":71,"time":1784045703645,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","argumentsDelta":"just"}}} +{"type":"assistant/chunk","seq":72,"time":1784045703645,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","argumentsDelta":"ification"}}} +{"type":"assistant/chunk","seq":73,"time":1784045703669,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":74,"time":1784045703669,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","argumentsDelta":": "}}} +{"type":"assistant/chunk","seq":75,"time":1784045703669,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":76,"time":1784045703669,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","argumentsDelta":"the"}}} +{"type":"assistant/chunk","seq":77,"time":1784045703696,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","argumentsDelta":" user"}}} +{"type":"assistant/chunk","seq":78,"time":1784045703696,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","argumentsDelta":" asked"}}} +{"type":"assistant/chunk","seq":79,"time":1784045703696,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","argumentsDelta":" to"}}} +{"type":"assistant/chunk","seq":80,"time":1784045703696,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","argumentsDelta":" escalate"}}} +{"type":"assistant/chunk","seq":81,"time":1784045703724,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","argumentsDelta":" this"}}} +{"type":"assistant/chunk","seq":82,"time":1784045703724,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","argumentsDelta":" write"}}} +{"type":"assistant/chunk","seq":83,"time":1784045703724,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":84,"time":1784045703749,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","argumentsDelta":"}"}}} +{"type":"assistant/chunk","seq":85,"time":1784045703776,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to create a file using the write tool with sandbox_permissions. Let me do that."}}}} +{"type":"assistant/chunk","seq":86,"time":1784045703776,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","arguments":"{\"file_path\": \"escalated.md\", \"content\": \"escalated\", \"sandbox_permissions\": \"danger-full-access\", \"justification\": \"the user asked to escalate this write\"}"}}}} +{"type":"assistant/chunk","seq":87,"time":1784045703776,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3871,"outputTokens":132,"cacheReadTokens":0,"reasoningTokens":23}}}} +{"type":"assistant/chunk","seq":88,"time":1784045703776,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":89,"time":1784045703780,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to create a file using the write tool with sandbox_permissions. Let me do that."},{"type":"tool-call","id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","arguments":"{\"file_path\": \"escalated.md\", \"content\": \"escalated\", \"sandbox_permissions\": \"danger-full-access\", \"justification\": \"the user asked to escalate this write\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":3871,"outputTokens":132,"cacheReadTokens":0,"reasoningTokens":23}},"sourceEventSeqs":[8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88],"surfaceOp":"append"} +{"type":"tool/call","seq":90,"time":1784045703780,"data":{"turn":1,"step":1,"callId":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","arguments":"{\"file_path\": \"escalated.md\", \"content\": \"escalated\", \"sandbox_permissions\": \"danger-full-access\", \"justification\": \"the user asked to escalate this write\"}"}} +{"type":"approval/asked","seq":91,"time":1784045703782,"data":{"id":"d7250dff-fc08-4163-9b51-f43f08d6f36f","toolName":"write","callId":"call_00_Fnymmavpr4klMDy4Fdej3227","reason":"escalate sandbox to danger-full-access: the user asked to escalate this write"}} +{"type":"approval/decided","seq":92,"time":1784045703786,"data":{"id":"d7250dff-fc08-4163-9b51-f43f08d6f36f","outcome":"allowed-once"}} +{"type":"tool/result","seq":93,"time":1784045703798,"data":{"turn":1,"step":1,"callId":"call_00_Fnymmavpr4klMDy4Fdej3227","content":[{"type":"text","text":"/var/folders/2g/b32ct0qn1d728l_v6tdkjytr0000gn/T/acp-snap-cwd-vmEGzd/escalated.md\nfile\n\nCreated file\n"}],"isError":false},"sourceEventSeqs":[90],"surfaceOp":"append"} +{"type":"step/end","seq":94,"time":1784045703798,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":95,"time":1784045703799,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":96,"time":1784045704512,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":97,"time":1784045704512,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":98,"time":1784045704620,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" file"}}} +{"type":"assistant/chunk","seq":99,"time":1784045704645,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" was"}}} +{"type":"assistant/chunk","seq":100,"time":1784045704646,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" created"}}} +{"type":"assistant/chunk","seq":101,"time":1784045704646,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" successfully"}}} +{"type":"assistant/chunk","seq":102,"time":1784045704646,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":103,"time":1784045704646,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" The"}}} +{"type":"assistant/chunk","seq":104,"time":1784045704646,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} +{"type":"assistant/chunk","seq":105,"time":1784045704672,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" asked"}}} +{"type":"assistant/chunk","seq":106,"time":1784045704673,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":107,"time":1784045704673,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":108,"time":1784045704673,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} +{"type":"assistant/chunk","seq":109,"time":1784045704699,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":110,"time":1784045704699,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" exactly"}}} +{"type":"assistant/chunk","seq":111,"time":1784045704699,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":112,"time":1784045704726,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" single"}}} +{"type":"assistant/chunk","seq":113,"time":1784045704726,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" word"}}} +{"type":"assistant/chunk","seq":114,"time":1784045704726,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" D"}}} +{"type":"assistant/chunk","seq":115,"time":1784045704754,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"ONE"}}} +{"type":"assistant/chunk","seq":116,"time":1784045704754,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":117,"time":1784045704754,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"assistant/chunk","seq":118,"time":1784045704754,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"D"}}} +{"type":"assistant/chunk","seq":119,"time":1784045704754,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"ONE"}}} +{"type":"assistant/chunk","seq":120,"time":1784045704755,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The file was created successfully. The user asked me to reply with exactly the single word DONE."}}}} +{"type":"assistant/chunk","seq":121,"time":1784045704755,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"DONE"}}}} +{"type":"assistant/chunk","seq":122,"time":1784045704755,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":107,"outputTokens":23,"cacheReadTokens":3968,"reasoningTokens":20}}}} +{"type":"assistant/chunk","seq":123,"time":1784045704755,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":124,"time":1784045704755,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"The file was created successfully. The user asked me to reply with exactly the single word DONE."},{"type":"text","text":"DONE"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":107,"outputTokens":23,"cacheReadTokens":3968,"reasoningTokens":20}},"sourceEventSeqs":[96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123],"surfaceOp":"append"} +{"type":"step/end","seq":125,"time":1784045704755,"data":{"turn":1,"step":2}} +{"type":"turn/end","seq":126,"time":1784045704756,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/hook-cc-pretool-ask/session.jsonl b/examples/acp-agent/tests/snapshots/hook-cc-pretool-ask/session.jsonl index 1a3b0f784a..888f2f5c13 100644 --- a/examples/acp-agent/tests/snapshots/hook-cc-pretool-ask/session.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-cc-pretool-ask/session.jsonl @@ -1,115 +1,116 @@ -{"type":"session","version":0,"id":"f688431c-01a8-4326-a5c5-1b5f0fd08483","createdAt":1783352171511,"cwd":"/tmp/acp-snap-cwd-iKVciS"} +{"type":"session","version":0,"id":"f688431c-01a8-4326-a5c5-1b5f0fd08483","createdAt":1783352171511,"cwd":"/tmp/acp-snap-cwd-iKVciS","delegationDepth":0} {"type":"turn/start","seq":0,"time":1783352171519,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1783352171520,"data":{"content":[{"type":"text","text":"Use the bash tool to run exactly: echo HELLO. Report the tool result you got back verbatim, then stop."}],"source":{"kind":"user"}},"surfaceOp":"append"} -{"type":"step/start","seq":2,"time":1783352171527,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":3,"time":1783352171528,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} -{"type":"assistant/chunk","seq":4,"time":1783352171991,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"assistant/chunk","seq":5,"time":1783352171991,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} -{"type":"assistant/chunk","seq":6,"time":1783352172088,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} -{"type":"assistant/chunk","seq":7,"time":1783352172117,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" wants"}}} -{"type":"assistant/chunk","seq":8,"time":1783352172118,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} -{"type":"assistant/chunk","seq":9,"time":1783352172118,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} -{"type":"assistant/chunk","seq":10,"time":1783352172118,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" run"}}} -{"type":"assistant/chunk","seq":11,"time":1783352172145,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" a"}}} -{"type":"assistant/chunk","seq":12,"time":1783352172145,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" simple"}}} -{"type":"assistant/chunk","seq":13,"time":1783352172146,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" bash"}}} -{"type":"assistant/chunk","seq":14,"time":1783352172146,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" command"}}} -{"type":"assistant/chunk","seq":15,"time":1783352172175,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} -{"type":"assistant/chunk","seq":16,"time":1783352172175,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" report"}}} -{"type":"assistant/chunk","seq":17,"time":1783352172175,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} -{"type":"assistant/chunk","seq":18,"time":1783352172175,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" result"}}} -{"type":"assistant/chunk","seq":19,"time":1783352172203,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" verb"}}} -{"type":"assistant/chunk","seq":20,"time":1783352172203,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"atim"}}} -{"type":"assistant/chunk","seq":21,"time":1783352172203,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} -{"type":"assistant/chunk","seq":22,"time":1783352172289,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":23,"time":1783352172290,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6k0oGSliVHxGSgqBmMEO4311","name":"bash","argumentsDelta":""}}} -{"type":"assistant/chunk","seq":24,"time":1783352172290,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6k0oGSliVHxGSgqBmMEO4311","name":"bash","argumentsDelta":"{"}}} -{"type":"assistant/chunk","seq":25,"time":1783352172290,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6k0oGSliVHxGSgqBmMEO4311","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":26,"time":1783352172318,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6k0oGSliVHxGSgqBmMEO4311","name":"bash","argumentsDelta":"command"}}} -{"type":"assistant/chunk","seq":27,"time":1783352172319,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6k0oGSliVHxGSgqBmMEO4311","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":28,"time":1783352172319,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6k0oGSliVHxGSgqBmMEO4311","name":"bash","argumentsDelta":": "}}} -{"type":"assistant/chunk","seq":29,"time":1783352172319,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6k0oGSliVHxGSgqBmMEO4311","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":30,"time":1783352172348,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6k0oGSliVHxGSgqBmMEO4311","name":"bash","argumentsDelta":"echo"}}} -{"type":"assistant/chunk","seq":31,"time":1783352172348,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6k0oGSliVHxGSgqBmMEO4311","name":"bash","argumentsDelta":" HE"}}} -{"type":"assistant/chunk","seq":32,"time":1783352172348,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6k0oGSliVHxGSgqBmMEO4311","name":"bash","argumentsDelta":"LL"}}} -{"type":"assistant/chunk","seq":33,"time":1783352172348,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6k0oGSliVHxGSgqBmMEO4311","name":"bash","argumentsDelta":"O"}}} -{"type":"assistant/chunk","seq":34,"time":1783352172348,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6k0oGSliVHxGSgqBmMEO4311","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":35,"time":1783352172405,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6k0oGSliVHxGSgqBmMEO4311","name":"bash","argumentsDelta":", "}}} -{"type":"assistant/chunk","seq":36,"time":1783352172406,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6k0oGSliVHxGSgqBmMEO4311","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":37,"time":1783352172406,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6k0oGSliVHxGSgqBmMEO4311","name":"bash","argumentsDelta":"description"}}} -{"type":"assistant/chunk","seq":38,"time":1783352172406,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6k0oGSliVHxGSgqBmMEO4311","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":39,"time":1783352172406,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6k0oGSliVHxGSgqBmMEO4311","name":"bash","argumentsDelta":": "}}} -{"type":"assistant/chunk","seq":40,"time":1783352172434,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6k0oGSliVHxGSgqBmMEO4311","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":41,"time":1783352172434,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6k0oGSliVHxGSgqBmMEO4311","name":"bash","argumentsDelta":"E"}}} -{"type":"assistant/chunk","seq":42,"time":1783352172434,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6k0oGSliVHxGSgqBmMEO4311","name":"bash","argumentsDelta":"cho"}}} -{"type":"assistant/chunk","seq":43,"time":1783352172464,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6k0oGSliVHxGSgqBmMEO4311","name":"bash","argumentsDelta":" HE"}}} -{"type":"assistant/chunk","seq":44,"time":1783352172464,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6k0oGSliVHxGSgqBmMEO4311","name":"bash","argumentsDelta":"LL"}}} -{"type":"assistant/chunk","seq":45,"time":1783352172464,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6k0oGSliVHxGSgqBmMEO4311","name":"bash","argumentsDelta":"O"}}} -{"type":"assistant/chunk","seq":46,"time":1783352172464,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6k0oGSliVHxGSgqBmMEO4311","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":47,"time":1783352172496,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6k0oGSliVHxGSgqBmMEO4311","name":"bash","argumentsDelta":"}"}}} -{"type":"assistant/chunk","seq":48,"time":1783352172555,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to run a simple bash command and report the result verbatim."}}}} -{"type":"assistant/chunk","seq":49,"time":1783352172555,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_6k0oGSliVHxGSgqBmMEO4311","name":"bash","arguments":"{\"command\": \"echo HELLO\", \"description\": \"Echo HELLO\"}"}}}} -{"type":"assistant/chunk","seq":50,"time":1783352172555,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":2878,"outputTokens":83,"cacheReadTokens":0,"reasoningTokens":17}}}} -{"type":"assistant/chunk","seq":51,"time":1783352172555,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":52,"time":1783352172557,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to run a simple bash command and report the result verbatim."},{"type":"tool-call","id":"call_00_6k0oGSliVHxGSgqBmMEO4311","name":"bash","arguments":"{\"command\": \"echo HELLO\", \"description\": \"Echo HELLO\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":2878,"outputTokens":83,"cacheReadTokens":0,"reasoningTokens":17}},"sourceEventSeqs":[4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51],"surfaceOp":"append"} -{"type":"tool/call","seq":53,"time":1783352172557,"data":{"turn":1,"step":1,"callId":"call_00_6k0oGSliVHxGSgqBmMEO4311","name":"bash","arguments":"{\"command\": \"echo HELLO\", \"description\": \"Echo HELLO\"}"}} -{"type":"hook/invoked","seq":54,"time":1783352172558,"data":{"turn":1,"point":"PreToolUse","dialect":"claude","handlerId":"claude:PreToolUse:1","matcher":"bash"}} -{"type":"hook/result","seq":55,"time":1783352172573,"data":{"turn":1,"point":"PreToolUse","handlerId":"claude:PreToolUse:1","decision":"ask","exitCode":0,"durationMs":14.113374999999905}} -{"type":"approval/asked","seq":56,"time":1783962235813,"data":{"id":"73c9d486-e039-43b8-8f73-20720d5bf2b6","toolName":"bash","callId":"call_00_6k0oGSliVHxGSgqBmMEO4311","reason":"bash requires manual approval in this session"}} -{"type":"approval/decided","seq":57,"time":1783962235813,"data":{"id":"73c9d486-e039-43b8-8f73-20720d5bf2b6","outcome":"rejected"}} -{"type":"tool/result","seq":58,"time":1783962235814,"data":{"turn":1,"step":1,"callId":"call_00_6k0oGSliVHxGSgqBmMEO4311","content":[{"type":"text","text":"Error: the user rejected tool \"bash\""}],"isError":true},"sourceEventSeqs":[53],"surfaceOp":"append"} -{"type":"step/end","seq":59,"time":1783962235814,"data":{"turn":1,"step":1}} -{"type":"step/start","seq":60,"time":1783962235814,"data":{"turn":1,"step":2}} -{"type":"assistant/chunk","seq":61,"time":1783352173584,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"assistant/chunk","seq":62,"time":1783352173615,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} -{"type":"assistant/chunk","seq":63,"time":1783352173615,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" bash"}}} -{"type":"assistant/chunk","seq":64,"time":1783352173644,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" tool"}}} -{"type":"assistant/chunk","seq":65,"time":1783352173645,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" returned"}}} -{"type":"assistant/chunk","seq":66,"time":1783352173645,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" an"}}} -{"type":"assistant/chunk","seq":67,"time":1783352173645,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" error"}}} -{"type":"assistant/chunk","seq":68,"time":1783352173669,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" saying"}}} -{"type":"assistant/chunk","seq":69,"time":1783352173669,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" it"}}} -{"type":"assistant/chunk","seq":70,"time":1783352173670,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" requires"}}} -{"type":"assistant/chunk","seq":71,"time":1783352173670,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" manual"}}} -{"type":"assistant/chunk","seq":72,"time":1783352173698,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" approval"}}} -{"type":"assistant/chunk","seq":73,"time":1783352173699,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" in"}}} -{"type":"assistant/chunk","seq":74,"time":1783352173699,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" this"}}} -{"type":"assistant/chunk","seq":75,"time":1783352173699,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" session"}}} -{"type":"assistant/chunk","seq":76,"time":1783352173728,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} -{"type":"assistant/chunk","seq":77,"time":1783352173728,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" I"}}} -{"type":"assistant/chunk","seq":78,"time":1783352173729,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"'ll"}}} -{"type":"assistant/chunk","seq":79,"time":1783352173729,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" report"}}} -{"type":"assistant/chunk","seq":80,"time":1783352173755,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" this"}}} -{"type":"assistant/chunk","seq":81,"time":1783352173756,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" verb"}}} -{"type":"assistant/chunk","seq":82,"time":1783352173756,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"atim"}}} -{"type":"assistant/chunk","seq":83,"time":1783352173756,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} -{"type":"assistant/chunk","seq":84,"time":1783352173756,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} -{"type":"assistant/chunk","seq":85,"time":1783352173789,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"The"}}} -{"type":"assistant/chunk","seq":86,"time":1783352173823,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" tool"}}} -{"type":"assistant/chunk","seq":87,"time":1783352173854,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" result"}}} -{"type":"assistant/chunk","seq":88,"time":1783352173854,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" I"}}} -{"type":"assistant/chunk","seq":89,"time":1783352173879,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" got"}}} -{"type":"assistant/chunk","seq":90,"time":1783352173906,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" back"}}} -{"type":"assistant/chunk","seq":91,"time":1783352173906,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" verb"}}} -{"type":"assistant/chunk","seq":92,"time":1783352173907,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"atim"}}} -{"type":"assistant/chunk","seq":93,"time":1783352173907,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" is"}}} -{"type":"assistant/chunk","seq":94,"time":1783352173907,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":":\n\n"}}} -{"type":"assistant/chunk","seq":95,"time":1783352173932,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"```\n"}}} -{"type":"assistant/chunk","seq":96,"time":1783352173933,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"Error"}}} -{"type":"assistant/chunk","seq":97,"time":1783352173933,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":":"}}} -{"type":"assistant/chunk","seq":98,"time":1783352173933,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" bash"}}} -{"type":"assistant/chunk","seq":99,"time":1783352173933,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" requires"}}} -{"type":"assistant/chunk","seq":100,"time":1783352173933,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" manual"}}} -{"type":"assistant/chunk","seq":101,"time":1783352173963,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" approval"}}} -{"type":"assistant/chunk","seq":102,"time":1783352173963,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" in"}}} -{"type":"assistant/chunk","seq":103,"time":1783352173963,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" this"}}} -{"type":"assistant/chunk","seq":104,"time":1783352173963,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" session"}}} -{"type":"assistant/chunk","seq":105,"time":1783352173964,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"\n"}}} -{"type":"assistant/chunk","seq":106,"time":1783352173964,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"```"}}} -{"type":"assistant/chunk","seq":107,"time":1783352173964,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The bash tool returned an error saying it requires manual approval in this session. I'll report this verbatim."}}}} -{"type":"assistant/chunk","seq":108,"time":1783352173964,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"The tool result I got back verbatim is:\n\n```\nError: bash requires manual approval in this session\n```"}}}} -{"type":"assistant/chunk","seq":109,"time":1783962235816,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":166,"outputTokens":45,"cacheReadTokens":2816,"reasoningTokens":22}}}} -{"type":"assistant/chunk","seq":110,"time":1783962235816,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":111,"time":1783962235816,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"The bash tool returned an error saying it requires manual approval in this session. I'll report this verbatim."},{"type":"text","text":"The tool result I got back verbatim is:\n\n```\nError: bash requires manual approval in this session\n```"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":166,"outputTokens":45,"cacheReadTokens":2816,"reasoningTokens":22}},"sourceEventSeqs":[61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110],"surfaceOp":"append"} -{"type":"step/end","seq":112,"time":1783962235816,"data":{"turn":1,"step":2}} -{"type":"turn/end","seq":113,"time":1783962235816,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"session/title","seq":2,"time":1783352171520,"data":{"title":"Use the bash tool to","messageSeqs":[1],"source":{"kind":"fallback"}}} +{"type":"step/start","seq":3,"time":1783352171527,"data":{"turn":1,"step":1}} +{"type":"request/header","seq":4,"time":1783352171528,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"assistant/chunk","seq":5,"time":1783352171991,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":6,"time":1783352171991,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":7,"time":1783352172088,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} +{"type":"assistant/chunk","seq":8,"time":1783352172117,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" wants"}}} +{"type":"assistant/chunk","seq":9,"time":1783352172118,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":10,"time":1783352172118,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":11,"time":1783352172118,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" run"}}} +{"type":"assistant/chunk","seq":12,"time":1783352172145,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" a"}}} +{"type":"assistant/chunk","seq":13,"time":1783352172145,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" simple"}}} +{"type":"assistant/chunk","seq":14,"time":1783352172146,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" bash"}}} +{"type":"assistant/chunk","seq":15,"time":1783352172146,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" command"}}} +{"type":"assistant/chunk","seq":16,"time":1783352172175,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} +{"type":"assistant/chunk","seq":17,"time":1783352172175,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" report"}}} +{"type":"assistant/chunk","seq":18,"time":1783352172175,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":19,"time":1783352172175,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" result"}}} +{"type":"assistant/chunk","seq":20,"time":1783352172203,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" verb"}}} +{"type":"assistant/chunk","seq":21,"time":1783352172203,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"atim"}}} +{"type":"assistant/chunk","seq":22,"time":1783352172203,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":23,"time":1783352172289,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":24,"time":1783352172290,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6k0oGSliVHxGSgqBmMEO4311","name":"bash","argumentsDelta":""}}} +{"type":"assistant/chunk","seq":25,"time":1783352172290,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6k0oGSliVHxGSgqBmMEO4311","name":"bash","argumentsDelta":"{"}}} +{"type":"assistant/chunk","seq":26,"time":1783352172290,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6k0oGSliVHxGSgqBmMEO4311","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":27,"time":1783352172318,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6k0oGSliVHxGSgqBmMEO4311","name":"bash","argumentsDelta":"command"}}} +{"type":"assistant/chunk","seq":28,"time":1783352172319,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6k0oGSliVHxGSgqBmMEO4311","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":29,"time":1783352172319,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6k0oGSliVHxGSgqBmMEO4311","name":"bash","argumentsDelta":": "}}} +{"type":"assistant/chunk","seq":30,"time":1783352172319,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6k0oGSliVHxGSgqBmMEO4311","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":31,"time":1783352172348,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6k0oGSliVHxGSgqBmMEO4311","name":"bash","argumentsDelta":"echo"}}} +{"type":"assistant/chunk","seq":32,"time":1783352172348,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6k0oGSliVHxGSgqBmMEO4311","name":"bash","argumentsDelta":" HE"}}} +{"type":"assistant/chunk","seq":33,"time":1783352172348,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6k0oGSliVHxGSgqBmMEO4311","name":"bash","argumentsDelta":"LL"}}} +{"type":"assistant/chunk","seq":34,"time":1783352172348,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6k0oGSliVHxGSgqBmMEO4311","name":"bash","argumentsDelta":"O"}}} +{"type":"assistant/chunk","seq":35,"time":1783352172348,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6k0oGSliVHxGSgqBmMEO4311","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":36,"time":1783352172405,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6k0oGSliVHxGSgqBmMEO4311","name":"bash","argumentsDelta":", "}}} +{"type":"assistant/chunk","seq":37,"time":1783352172406,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6k0oGSliVHxGSgqBmMEO4311","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":38,"time":1783352172406,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6k0oGSliVHxGSgqBmMEO4311","name":"bash","argumentsDelta":"description"}}} +{"type":"assistant/chunk","seq":39,"time":1783352172406,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6k0oGSliVHxGSgqBmMEO4311","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":40,"time":1783352172406,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6k0oGSliVHxGSgqBmMEO4311","name":"bash","argumentsDelta":": "}}} +{"type":"assistant/chunk","seq":41,"time":1783352172434,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6k0oGSliVHxGSgqBmMEO4311","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":42,"time":1783352172434,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6k0oGSliVHxGSgqBmMEO4311","name":"bash","argumentsDelta":"E"}}} +{"type":"assistant/chunk","seq":43,"time":1783352172434,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6k0oGSliVHxGSgqBmMEO4311","name":"bash","argumentsDelta":"cho"}}} +{"type":"assistant/chunk","seq":44,"time":1783352172464,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6k0oGSliVHxGSgqBmMEO4311","name":"bash","argumentsDelta":" HE"}}} +{"type":"assistant/chunk","seq":45,"time":1783352172464,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6k0oGSliVHxGSgqBmMEO4311","name":"bash","argumentsDelta":"LL"}}} +{"type":"assistant/chunk","seq":46,"time":1783352172464,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6k0oGSliVHxGSgqBmMEO4311","name":"bash","argumentsDelta":"O"}}} +{"type":"assistant/chunk","seq":47,"time":1783352172464,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6k0oGSliVHxGSgqBmMEO4311","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":48,"time":1783352172496,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6k0oGSliVHxGSgqBmMEO4311","name":"bash","argumentsDelta":"}"}}} +{"type":"assistant/chunk","seq":49,"time":1783352172555,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to run a simple bash command and report the result verbatim."}}}} +{"type":"assistant/chunk","seq":50,"time":1783352172555,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_6k0oGSliVHxGSgqBmMEO4311","name":"bash","arguments":"{\"command\": \"echo HELLO\", \"description\": \"Echo HELLO\"}"}}}} +{"type":"assistant/chunk","seq":51,"time":1783352172555,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":2878,"outputTokens":83,"cacheReadTokens":0,"reasoningTokens":17}}}} +{"type":"assistant/chunk","seq":52,"time":1783352172555,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":53,"time":1783352172557,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to run a simple bash command and report the result verbatim."},{"type":"tool-call","id":"call_00_6k0oGSliVHxGSgqBmMEO4311","name":"bash","arguments":"{\"command\": \"echo HELLO\", \"description\": \"Echo HELLO\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":2878,"outputTokens":83,"cacheReadTokens":0,"reasoningTokens":17}},"sourceEventSeqs":[5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52],"surfaceOp":"append"} +{"type":"tool/call","seq":54,"time":1783352172557,"data":{"turn":1,"step":1,"callId":"call_00_6k0oGSliVHxGSgqBmMEO4311","name":"bash","arguments":"{\"command\": \"echo HELLO\", \"description\": \"Echo HELLO\"}"}} +{"type":"hook/invoked","seq":55,"time":1783352172558,"data":{"turn":1,"point":"PreToolUse","dialect":"claude","handlerId":"claude:PreToolUse:1","matcher":"bash"}} +{"type":"hook/result","seq":56,"time":1783352172573,"data":{"turn":1,"point":"PreToolUse","handlerId":"claude:PreToolUse:1","decision":"ask","exitCode":0,"durationMs":14.113374999999905}} +{"type":"approval/asked","seq":57,"time":1783962235813,"data":{"id":"97616288-1a5e-4110-a75d-7616a24adcc4","toolName":"bash","callId":"call_00_6k0oGSliVHxGSgqBmMEO4311","reason":"bash requires manual approval in this session"}} +{"type":"approval/decided","seq":58,"time":1783962235813,"data":{"id":"97616288-1a5e-4110-a75d-7616a24adcc4","outcome":"rejected"}} +{"type":"tool/result","seq":59,"time":1783962235814,"data":{"turn":1,"step":1,"callId":"call_00_6k0oGSliVHxGSgqBmMEO4311","content":[{"type":"text","text":"Error: the user rejected tool \"bash\""}],"isError":true},"sourceEventSeqs":[54],"surfaceOp":"append"} +{"type":"step/end","seq":60,"time":1783962235814,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":61,"time":1783962235814,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":62,"time":1783352173584,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":63,"time":1783352173615,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":64,"time":1783352173615,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" bash"}}} +{"type":"assistant/chunk","seq":65,"time":1783352173644,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" tool"}}} +{"type":"assistant/chunk","seq":66,"time":1783352173645,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" returned"}}} +{"type":"assistant/chunk","seq":67,"time":1783352173645,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" an"}}} +{"type":"assistant/chunk","seq":68,"time":1783352173645,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" error"}}} +{"type":"assistant/chunk","seq":69,"time":1783352173669,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" saying"}}} +{"type":"assistant/chunk","seq":70,"time":1783352173669,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" it"}}} +{"type":"assistant/chunk","seq":71,"time":1783352173670,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" requires"}}} +{"type":"assistant/chunk","seq":72,"time":1783352173670,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" manual"}}} +{"type":"assistant/chunk","seq":73,"time":1783352173698,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" approval"}}} +{"type":"assistant/chunk","seq":74,"time":1783352173699,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" in"}}} +{"type":"assistant/chunk","seq":75,"time":1783352173699,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" this"}}} +{"type":"assistant/chunk","seq":76,"time":1783352173699,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" session"}}} +{"type":"assistant/chunk","seq":77,"time":1783352173728,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":78,"time":1783352173728,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" I"}}} +{"type":"assistant/chunk","seq":79,"time":1783352173729,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"'ll"}}} +{"type":"assistant/chunk","seq":80,"time":1783352173729,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" report"}}} +{"type":"assistant/chunk","seq":81,"time":1783352173755,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" this"}}} +{"type":"assistant/chunk","seq":82,"time":1783352173756,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" verb"}}} +{"type":"assistant/chunk","seq":83,"time":1783352173756,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"atim"}}} +{"type":"assistant/chunk","seq":84,"time":1783352173756,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":85,"time":1783352173756,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"assistant/chunk","seq":86,"time":1783352173789,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"The"}}} +{"type":"assistant/chunk","seq":87,"time":1783352173823,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" tool"}}} +{"type":"assistant/chunk","seq":88,"time":1783352173854,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" result"}}} +{"type":"assistant/chunk","seq":89,"time":1783352173854,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" I"}}} +{"type":"assistant/chunk","seq":90,"time":1783352173879,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" got"}}} +{"type":"assistant/chunk","seq":91,"time":1783352173906,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" back"}}} +{"type":"assistant/chunk","seq":92,"time":1783352173906,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" verb"}}} +{"type":"assistant/chunk","seq":93,"time":1783352173907,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"atim"}}} +{"type":"assistant/chunk","seq":94,"time":1783352173907,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" is"}}} +{"type":"assistant/chunk","seq":95,"time":1783352173907,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":":\n\n"}}} +{"type":"assistant/chunk","seq":96,"time":1783352173932,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"```\n"}}} +{"type":"assistant/chunk","seq":97,"time":1783352173933,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"Error"}}} +{"type":"assistant/chunk","seq":98,"time":1783352173933,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":":"}}} +{"type":"assistant/chunk","seq":99,"time":1783352173933,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" bash"}}} +{"type":"assistant/chunk","seq":100,"time":1783352173933,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" requires"}}} +{"type":"assistant/chunk","seq":101,"time":1783352173933,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" manual"}}} +{"type":"assistant/chunk","seq":102,"time":1783352173963,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" approval"}}} +{"type":"assistant/chunk","seq":103,"time":1783352173963,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" in"}}} +{"type":"assistant/chunk","seq":104,"time":1783352173963,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" this"}}} +{"type":"assistant/chunk","seq":105,"time":1783352173963,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" session"}}} +{"type":"assistant/chunk","seq":106,"time":1783352173964,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"\n"}}} +{"type":"assistant/chunk","seq":107,"time":1783352173964,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"```"}}} +{"type":"assistant/chunk","seq":108,"time":1783352173964,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The bash tool returned an error saying it requires manual approval in this session. I'll report this verbatim."}}}} +{"type":"assistant/chunk","seq":109,"time":1783352173964,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"The tool result I got back verbatim is:\n\n```\nError: bash requires manual approval in this session\n```"}}}} +{"type":"assistant/chunk","seq":110,"time":1783962235816,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":166,"outputTokens":45,"cacheReadTokens":2816,"reasoningTokens":22}}}} +{"type":"assistant/chunk","seq":111,"time":1783962235816,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":112,"time":1783962235816,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"The bash tool returned an error saying it requires manual approval in this session. I'll report this verbatim."},{"type":"text","text":"The tool result I got back verbatim is:\n\n```\nError: bash requires manual approval in this session\n```"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":166,"outputTokens":45,"cacheReadTokens":2816,"reasoningTokens":22}},"sourceEventSeqs":[62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111],"surfaceOp":"append"} +{"type":"step/end","seq":113,"time":1783962235816,"data":{"turn":1,"step":2}} +{"type":"turn/end","seq":114,"time":1783962235816,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/lsp-definition/session.jsonl b/examples/acp-agent/tests/snapshots/lsp-definition/session.jsonl index 6853e358ce..78790d15b0 100644 --- a/examples/acp-agent/tests/snapshots/lsp-definition/session.jsonl +++ b/examples/acp-agent/tests/snapshots/lsp-definition/session.jsonl @@ -3,13 +3,13 @@ {"type":"user/message","seq":1,"time":0,"data":{"content":[{"type":"text","text":"Use the lsp tool exactly once to find the definition at subject.ts line 1 character 7, then reply with exactly DONE."}],"source":{"kind":"user"}},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":0,"data":{"title":"Use the lsp tool exactly","messageSeqs":[1],"source":{"kind":"fallback"}}} {"type":"step/start","seq":3,"time":0,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":4,"time":0,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/header","seq":4,"time":0,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-pro"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} {"type":"assistant/chunk","seq":5,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} {"type":"assistant/chunk","seq":6,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"call_lsp_definition","name":"lsp","argumentsDelta":"{\"operation\":\"goToDefinition\",\"file_path\":\"subject.ts\",\"line\":1,\"character\":7}"}}} {"type":"assistant/chunk","seq":7,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_lsp_definition","name":"lsp","arguments":"{\"operation\":\"goToDefinition\",\"file_path\":\"subject.ts\",\"line\":1,\"character\":7}"}}}} {"type":"assistant/chunk","seq":8,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} {"type":"assistant/chunk","seq":9,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":10,"time":0,"data":{"turn":1,"step":1,"content":[{"type":"tool-call","id":"call_lsp_definition","name":"lsp","arguments":"{\"operation\":\"goToDefinition\",\"file_path\":\"subject.ts\",\"line\":1,\"character\":7}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[5,6,7,8,9],"surfaceOp":"append"} +{"type":"assistant/message","seq":10,"time":0,"data":{"turn":1,"step":1,"content":[{"type":"tool-call","id":"call_lsp_definition","name":"lsp","arguments":"{\"operation\":\"goToDefinition\",\"file_path\":\"subject.ts\",\"line\":1,\"character\":7}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-pro"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[5,6,7,8,9],"surfaceOp":"append"} {"type":"tool/call","seq":11,"time":0,"data":{"turn":1,"step":1,"callId":"call_lsp_definition","name":"lsp","arguments":"{\"operation\":\"goToDefinition\",\"file_path\":\"subject.ts\",\"line\":1,\"character\":7}"}} {"type":"tool/result","seq":12,"time":0,"data":{"turn":1,"step":1,"callId":"call_lsp_definition","content":[{"type":"text","text":"subject.ts:1:7\n… 1 more location omitted (limit 1)."}],"isError":false},"sourceEventSeqs":[11],"surfaceOp":"append"} {"type":"step/end","seq":13,"time":0,"data":{"turn":1,"step":1}} @@ -19,6 +19,6 @@ {"type":"assistant/chunk","seq":17,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DONE"}}}} {"type":"assistant/chunk","seq":18,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":2}}}} {"type":"assistant/chunk","seq":19,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":20,"time":0,"data":{"turn":1,"step":2,"content":[{"type":"text","text":"DONE"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":10,"outputTokens":2}},"sourceEventSeqs":[15,16,17,18,19],"surfaceOp":"append"} +{"type":"assistant/message","seq":20,"time":0,"data":{"turn":1,"step":2,"content":[{"type":"text","text":"DONE"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-pro"},"usage":{"inputTokens":10,"outputTokens":2}},"sourceEventSeqs":[15,16,17,18,19],"surfaceOp":"append"} {"type":"step/end","seq":21,"time":0,"data":{"turn":1,"step":2}} {"type":"turn/end","seq":22,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/lsp-definition/system-prompt.expected.md b/examples/acp-agent/tests/snapshots/lsp-definition/system-prompt.expected.md index 8e49c2dce5..7bde8fe289 100644 --- a/examples/acp-agent/tests/snapshots/lsp-definition/system-prompt.expected.md +++ b/examples/acp-agent/tests/snapshots/lsp-definition/system-prompt.expected.md @@ -1,6 +1,6 @@ You are an AI agent powered by the DeepSeek Harness SDK. -You are a coding assistant powered by the deepseek-v4-flash model. Your working directory is {{cwd}}. Your bash tool runs under a file sandbox — a `[sandbox: file access denied …]` result is policy, not a command bug. +You are a coding assistant powered by the deepseek-v4-pro model. Your working directory is {{cwd}}. Your bash tool runs under a file sandbox — a `[sandbox: file access denied …]` result is policy, not a command bug. Verify your work by running the code or tests. Keep answers brief and factual. diff --git a/examples/acp-agent/tests/snapshots/workspace-context/session.jsonl b/examples/acp-agent/tests/snapshots/workspace-context/session.jsonl index 168e0afab4..8293ea3abf 100644 --- a/examples/acp-agent/tests/snapshots/workspace-context/session.jsonl +++ b/examples/acp-agent/tests/snapshots/workspace-context/session.jsonl @@ -1,24 +1,25 @@ -{"type":"session","version":0,"id":"{{sessionId}}","createdAt":0,"cwd":"{{cwd}}"} +{"type":"session","version":0,"id":"{{sessionId}}","createdAt":0,"cwd":"{{cwd}}","delegationDepth":0} {"type":"turn/start","seq":0,"time":1783778297065,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1783778297066,"data":{"content":[{"type":"text","text":"Read nested/task.txt with the read tool, then reply DONE."}],"source":{"kind":"user"}},"surfaceOp":"append"} -{"type":"step/start","seq":2,"time":1783778297069,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":3,"time":1783778297070,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}","messagePrefix":[{"role":"user","content":[{"type":"text","text":"\nThe following workspace instructions may be relevant to your work. Use them as guidance when applicable. More specific instructions take precedence over broader ones. They do not override system, developer, or direct user instructions.\n\nInstructions from: AGENTS.md\n\nRoot snapshot instruction.\n\n"}]}]},"reason":"initial"}} -{"type":"assistant/chunk","seq":4,"time":1783778297070,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":5,"time":1783778297070,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"call_workspace_read","name":"read","argumentsDelta":"{\"file_path\":\"nested/task.txt\"}"}}} -{"type":"assistant/chunk","seq":6,"time":1783778297070,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_workspace_read","name":"read","arguments":"{\"file_path\":\"nested/task.txt\"}"}}}} -{"type":"assistant/chunk","seq":7,"time":1783778297070,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} -{"type":"assistant/chunk","seq":8,"time":1783778297070,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":9,"time":1783778297070,"data":{"turn":1,"step":1,"content":[{"type":"tool-call","id":"call_workspace_read","name":"read","arguments":"{\"file_path\":\"nested/task.txt\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[4,5,6,7,8],"surfaceOp":"append"} -{"type":"tool/call","seq":10,"time":1783778297070,"data":{"turn":1,"step":1,"callId":"call_workspace_read","name":"read","arguments":"{\"file_path\":\"nested/task.txt\"}"}} -{"type":"tool/result","seq":11,"time":1783778297072,"data":{"turn":1,"step":1,"callId":"call_workspace_read","content":[{"type":"text","text":"{{cwd}}/nested/task.txt\nfile\n\n1: snapshot task\n\n(End of file - total 1 lines)\n"}],"isError":false},"sourceEventSeqs":[10],"surfaceOp":"append"} -{"type":"context/message","seq":12,"time":1783778297072,"data":{"content":[{"type":"text","text":"\nAdditional instructions from: nested/AGENTS.md\n\nThese instructions apply to work under `nested`. Use them as guidance when relevant; more specific instructions take precedence. They do not override system, developer, or direct user instructions.\n\nNested snapshot instruction.\n\n"}],"source":{"kind":"plugin","plugin":"workspace-context"},"meta":{"kind":"workspace-instructions","version":1,"changes":[{"action":"set","scope":"nested\u0000AGENTS.md","path":"nested/AGENTS.md","digest":"c446df9a85c7e73a3055f394a4822a19ac9ead5a"}]}},"surfaceOp":"append"} -{"type":"step/end","seq":13,"time":1783778297072,"data":{"turn":1,"step":1}} -{"type":"step/start","seq":14,"time":1783778297072,"data":{"turn":1,"step":2}} -{"type":"assistant/chunk","seq":15,"time":1783778297073,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} -{"type":"assistant/chunk","seq":16,"time":1783778297073,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":0,"text":"DONE"}}} -{"type":"assistant/chunk","seq":17,"time":1783778297073,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DONE"}}}} -{"type":"assistant/chunk","seq":18,"time":1783778297073,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":2}}}} -{"type":"assistant/chunk","seq":19,"time":1783778297073,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":20,"time":1783778297073,"data":{"turn":1,"step":2,"content":[{"type":"text","text":"DONE"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":10,"outputTokens":2}},"sourceEventSeqs":[15,16,17,18,19],"surfaceOp":"append"} -{"type":"step/end","seq":21,"time":1783778297073,"data":{"turn":1,"step":2}} -{"type":"turn/end","seq":22,"time":1783778297073,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"session/title","seq":2,"time":1783778297066,"data":{"title":"Read nested/task.txt with the read","messageSeqs":[1],"source":{"kind":"fallback"}}} +{"type":"step/start","seq":3,"time":1783778297069,"data":{"turn":1,"step":1}} +{"type":"request/header","seq":4,"time":1783778297070,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}","messagePrefix":[{"role":"user","content":[{"type":"text","text":"\nThe following workspace instructions may be relevant to your work. Use them as guidance when applicable. More specific instructions take precedence over broader ones. They do not override system, developer, or direct user instructions.\n\nInstructions from: AGENTS.md\n\nRoot snapshot instruction.\n\n"}]}]},"reason":"initial"}} +{"type":"assistant/chunk","seq":5,"time":1783778297070,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":6,"time":1783778297070,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"call_workspace_read","name":"read","argumentsDelta":"{\"file_path\":\"nested/task.txt\"}"}}} +{"type":"assistant/chunk","seq":7,"time":1783778297070,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_workspace_read","name":"read","arguments":"{\"file_path\":\"nested/task.txt\"}"}}}} +{"type":"assistant/chunk","seq":8,"time":1783778297070,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} +{"type":"assistant/chunk","seq":9,"time":1783778297070,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":10,"time":1783778297070,"data":{"turn":1,"step":1,"content":[{"type":"tool-call","id":"call_workspace_read","name":"read","arguments":"{\"file_path\":\"nested/task.txt\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[5,6,7,8,9],"surfaceOp":"append"} +{"type":"tool/call","seq":11,"time":1783778297070,"data":{"turn":1,"step":1,"callId":"call_workspace_read","name":"read","arguments":"{\"file_path\":\"nested/task.txt\"}"}} +{"type":"tool/result","seq":12,"time":1783778297072,"data":{"turn":1,"step":1,"callId":"call_workspace_read","content":[{"type":"text","text":"{{cwd}}/nested/task.txt\nfile\n\n1: snapshot task\n\n(End of file - total 1 lines)\n"}],"isError":false},"sourceEventSeqs":[11],"surfaceOp":"append"} +{"type":"context/message","seq":13,"time":1783778297072,"data":{"content":[{"type":"text","text":"\nAdditional instructions from: nested/AGENTS.md\n\nThese instructions apply to work under `nested`. Use them as guidance when relevant; more specific instructions take precedence. They do not override system, developer, or direct user instructions.\n\nNested snapshot instruction.\n\n"}],"source":{"kind":"plugin","plugin":"workspace-context"},"meta":{"kind":"workspace-instructions","version":1,"changes":[{"action":"set","scope":"nested\u0000AGENTS.md","path":"nested/AGENTS.md","digest":"c446df9a85c7e73a3055f394a4822a19ac9ead5a"}]}},"surfaceOp":"append"} +{"type":"step/end","seq":14,"time":1783778297072,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":15,"time":1783778297072,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":16,"time":1783778297073,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":17,"time":1783778297073,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":0,"text":"DONE"}}} +{"type":"assistant/chunk","seq":18,"time":1783778297073,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DONE"}}}} +{"type":"assistant/chunk","seq":19,"time":1783778297073,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":2}}}} +{"type":"assistant/chunk","seq":20,"time":1783778297073,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":21,"time":1783778297073,"data":{"turn":1,"step":2,"content":[{"type":"text","text":"DONE"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":10,"outputTokens":2}},"sourceEventSeqs":[16,17,18,19,20],"surfaceOp":"append"} +{"type":"step/end","seq":22,"time":1783778297073,"data":{"turn":1,"step":2}} +{"type":"turn/end","seq":23,"time":1783778297073,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/headless-agent/advanced.cordis.snapshot.yml b/examples/headless-agent/advanced.cordis.snapshot.yml index 71efe0e00b..1327e5a808 100644 --- a/examples/headless-agent/advanced.cordis.snapshot.yml +++ b/examples/headless-agent/advanced.cordis.snapshot.yml @@ -21,6 +21,9 @@ provider: deepseek model: deepseek-v4-flash persistenceRoot: './.sessions' + # Replay fixtures are raw JSONL; the whole-config patch must restate + # the compression choice or the default zstd frames hide the logs. + persistenceCompression: none workspaceContext: maxBytes: 65536 tools: diff --git a/examples/headless-agent/cordis.yml b/examples/headless-agent/cordis.yml index 82e63fd3bd..944fe7e180 100644 --- a/examples/headless-agent/cordis.yml +++ b/examples/headless-agent/cordis.yml @@ -29,7 +29,10 @@ name: '@deepseek-ai/dsh-cli-demo' config: provider: deepseek - model: deepseek-v4-pro + # Stays on flash: the goal/ralph replay corpora were recorded on it, and + # their nested-include overlays cannot re-pin the app config (a config + # patch cannot target an entry behind a nested include). + model: deepseek-v4-flash persistenceRoot: './.sessions' persistenceCompression: !!js "process.env.DSH_SNAPSHOT === undefined ? 'zstd' : 'none'" workspaceContext: diff --git a/examples/tui-agent/tests/snapshots/bash-terminal-card/terminal.expected.txt b/examples/tui-agent/tests/snapshots/bash-terminal-card/terminal.expected.txt index 5b7505cc54..9ffee44378 100644 --- a/examples/tui-agent/tests/snapshots/bash-terminal-card/terminal.expected.txt +++ b/examples/tui-agent/tests/snapshots/bash-terminal-card/terminal.expected.txt @@ -1,13 +1,13 @@ terminal 100x36 buffer=normal length=36 base=0 viewport=0 lifecycle started=1 stopped=0 progress=inactive -title "DSH TUI snapshot" +title "Use the bash tool to — DSH TUI snapshot" cursor hidden column=1 viewportRow=25 bufferRow=25 buffer 0| " DEEPSEEK HARNESS" style 1-8 fg=bright-blue bold style 10-16 bold -1| " Recorded replay: bash-terminal-card" - style 1-35 fg=bright-black +1| " Use the bash tool to" + style 1-20 fg=bright-black 2| " deepseek-v4-flash • main-session" style 1-34 dim 3| @@ -57,7 +57,7 @@ buffer style 1-1 inverse 26| "────────────────────────────────────────────────────────────────────────────────────────────────────" style 0-99 dim -27| "deepseek-v4-flash /workspace/project ↑3.0k ↓115 cache 48% tools:col" +27| "deepseek-v4-flash /workspace/project ↑3.0k ↓115 cache 48% 3% contex" style 0-88 dim style 91-99 dim 28-35| diff --git a/examples/tui-agent/tests/snapshots/code-mode/terminal.expected.txt b/examples/tui-agent/tests/snapshots/code-mode/terminal.expected.txt index b1a587e71f..0c51281584 100644 --- a/examples/tui-agent/tests/snapshots/code-mode/terminal.expected.txt +++ b/examples/tui-agent/tests/snapshots/code-mode/terminal.expected.txt @@ -1,13 +1,13 @@ terminal 100x36 buffer=normal length=36 base=0 viewport=0 lifecycle started=1 stopped=0 progress=inactive -title "DSH TUI snapshot" +title "Using ONE run_code program: call — DSH TUI snapshot" cursor hidden column=1 viewportRow=27 bufferRow=27 buffer 0| " DEEPSEEK HARNESS" style 1-8 fg=bright-blue bold style 10-16 bold -1| " Recorded replay: code-mode" - style 1-26 fg=bright-black +1| " Using ONE run_code program: call" + style 1-32 fg=bright-black 2| " deepseek-v4-flash • main-session" style 1-34 dim 3| @@ -63,7 +63,7 @@ buffer style 1-1 inverse 28| "────────────────────────────────────────────────────────────────────────────────────────────────────" style 0-99 dim -29| "deepseek-v4-flash /workspace/project ↑3.1k ↓158 cache 50% tools:collapsed" +29| "deepseek-v4-flash /workspace/project ↑3.1k ↓158 cache 50% 3% context tools:" style 0-79 dim - style 85-99 dim + style 82-99 dim 30-35| diff --git a/examples/tui-agent/tests/snapshots/cordis-dynamic-toolchain/terminal.expected.txt b/examples/tui-agent/tests/snapshots/cordis-dynamic-toolchain/terminal.expected.txt index e0b6a4c1fb..6508e75b19 100644 --- a/examples/tui-agent/tests/snapshots/cordis-dynamic-toolchain/terminal.expected.txt +++ b/examples/tui-agent/tests/snapshots/cordis-dynamic-toolchain/terminal.expected.txt @@ -1,13 +1,13 @@ terminal 100x36 buffer=normal length=48 base=12 viewport=12 lifecycle started=1 stopped=0 progress=inactive -title "DSH TUI snapshot" +title "Run this advanced flow exactly — DSH TUI snapshot" cursor hidden column=1 viewportRow=33 bufferRow=45 buffer 0| " DEEPSEEK HARNESS" style 1-8 fg=bright-blue bold style 10-16 bold -1| " Recorded replay: cordis-dynamic-toolchain" - style 1-41 fg=bright-black +1| " Run this advanced flow exactly" + style 1-30 fg=bright-black 2| " deepseek-v4-flash • main-session" style 1-34 dim 3| @@ -101,6 +101,6 @@ buffer style 1-1 inverse 46| "────────────────────────────────────────────────────────────────────────────────────────────────────" style 0-99 dim -47| "deepseek-v4-flash /workspace/project ↑18 ↓18 cache 0% tools:c" +47| "deepseek-v4-flash /workspace/project ↑18 ↓18 cache 0% 7% cont" style 0-90 dim style 93-99 dim diff --git a/examples/tui-agent/tests/snapshots/dynamic-workflow/terminal.expected.txt b/examples/tui-agent/tests/snapshots/dynamic-workflow/terminal.expected.txt index fd88ea5b40..48adbbb11a 100644 --- a/examples/tui-agent/tests/snapshots/dynamic-workflow/terminal.expected.txt +++ b/examples/tui-agent/tests/snapshots/dynamic-workflow/terminal.expected.txt @@ -1,13 +1,13 @@ terminal 100x36 buffer=normal length=45 base=9 viewport=9 lifecycle started=1 stopped=0 progress=inactive -title "DSH TUI snapshot" +title "Use the workflow tool exactly — DSH TUI snapshot" cursor hidden column=1 viewportRow=33 bufferRow=42 buffer 0| " DEEPSEEK HARNESS" style 1-8 fg=bright-blue bold style 10-16 bold -1| " Recorded replay: dynamic-workflow" - style 1-33 fg=bright-black +1| " Use the workflow tool exactly" + style 1-29 fg=bright-black 2| " deepseek-v4-flash • main-session" style 1-34 dim 3| @@ -91,6 +91,6 @@ buffer style 1-1 inverse 43| "────────────────────────────────────────────────────────────────────────────────────────────────────" style 0-99 dim -44| "deepseek-v4-flash /workspace/project ↑3.5k ↓227 cache 47% tools:colla" +44| "deepseek-v4-flash /workspace/project ↑3.5k ↓227 cache 47% 3% context " style 0-86 dim style 89-99 dim diff --git a/examples/tui-agent/tests/snapshots/multi-turn-conversation/terminal.expected.txt b/examples/tui-agent/tests/snapshots/multi-turn-conversation/terminal.expected.txt index ecc3c796e9..43d589e9ac 100644 --- a/examples/tui-agent/tests/snapshots/multi-turn-conversation/terminal.expected.txt +++ b/examples/tui-agent/tests/snapshots/multi-turn-conversation/terminal.expected.txt @@ -1,13 +1,13 @@ terminal 100x36 buffer=normal length=36 base=0 viewport=0 lifecycle started=1 stopped=0 progress=inactive -title "DSH TUI snapshot" +title "Reply with exactly the word: — DSH TUI snapshot" cursor hidden column=1 viewportRow=26 bufferRow=26 buffer 0| " DEEPSEEK HARNESS" style 1-8 fg=bright-blue bold style 10-16 bold -1| " Recorded replay: multi-turn-conversation" - style 1-40 fg=bright-black +1| " Reply with exactly the word:" + style 1-28 fg=bright-black 2| " deepseek-v4-flash • main-session" style 1-34 dim 3| @@ -54,7 +54,7 @@ buffer style 1-1 inverse 27| "────────────────────────────────────────────────────────────────────────────────────────────────────" style 0-99 dim -28| "deepseek-v4-flash /workspace/project ↑2.9k ↓41 cache 49% tools" +28| "deepseek-v4-flash /workspace/project ↑2.9k ↓41 cache 49% 3% co" style 0-92 dim style 95-99 dim 29-35| diff --git a/examples/tui-agent/tests/snapshots/parallel-file-reads/terminal.expected.txt b/examples/tui-agent/tests/snapshots/parallel-file-reads/terminal.expected.txt index 5fd1a0942e..70fa5b173a 100644 --- a/examples/tui-agent/tests/snapshots/parallel-file-reads/terminal.expected.txt +++ b/examples/tui-agent/tests/snapshots/parallel-file-reads/terminal.expected.txt @@ -1,13 +1,13 @@ terminal 100x36 buffer=normal length=37 base=1 viewport=1 lifecycle started=1 stopped=0 progress=inactive -title "DSH TUI snapshot" +title "Use the read tool twice — DSH TUI snapshot" cursor hidden column=1 viewportRow=33 bufferRow=34 buffer 0| " DEEPSEEK HARNESS" style 1-8 fg=bright-blue bold style 10-16 bold -1| " Recorded replay: parallel-file-reads" - style 1-36 fg=bright-black +1| " Use the read tool twice" + style 1-23 fg=bright-black 2| " deepseek-v4-flash • main-session" style 1-34 dim 3| @@ -76,6 +76,6 @@ buffer style 1-1 inverse 35| "────────────────────────────────────────────────────────────────────────────────────────────────────" style 0-99 dim -36| "deepseek-v4-flash /workspace/project ↑20 ↓6 cache 0% tools:collaps" +36| "deepseek-v4-flash /workspace/project ↑20 ↓6 cache 0% 3% context t" style 0-84 dim style 87-99 dim diff --git a/examples/tui-agent/tests/snapshots/todo-plan/terminal.expected.txt b/examples/tui-agent/tests/snapshots/todo-plan/terminal.expected.txt index 9022f28170..ec38d43c99 100644 --- a/examples/tui-agent/tests/snapshots/todo-plan/terminal.expected.txt +++ b/examples/tui-agent/tests/snapshots/todo-plan/terminal.expected.txt @@ -1,12 +1,12 @@ terminal 100x36 buffer=normal length=36 base=0 viewport=0 lifecycle started=1 stopped=0 progress=inactive -title "DSH TUI snapshot" +title "Use the todo_write tool to — DSH TUI snapshot" cursor hidden column=1 viewportRow=31 bufferRow=31 buffer 0| " DEEPSEEK HARNESS" style 1-8 fg=bright-blue bold style 10-16 bold -1| " Recorded replay: todo-plan" +1| " Use the todo_write tool to" style 1-26 fg=bright-black 2| " deepseek-v4-flash • main-session" style 1-34 dim @@ -66,7 +66,7 @@ buffer style 1-1 inverse 32| "────────────────────────────────────────────────────────────────────────────────────────────────────" style 0-99 dim -33| "deepseek-v4-flash /workspace/project ↑3.1k ↓145 cache 47% tools:collapsed" +33| "deepseek-v4-flash /workspace/project ↑3.1k ↓145 cache 47% 3% context tools:" style 0-79 dim - style 85-99 dim + style 82-99 dim 34-35| diff --git a/packages/ui/tui/tests/snapshots/advanced-cards-collapsed.expected.txt b/packages/ui/tui/tests/snapshots/advanced-cards-collapsed.expected.txt index d8303b757c..0e3c73750a 100644 --- a/packages/ui/tui/tests/snapshots/advanced-cards-collapsed.expected.txt +++ b/packages/ui/tui/tests/snapshots/advanced-cards-collapsed.expected.txt @@ -23,11 +23,12 @@ buffer 7| "▌ /workspace/project " style 0-0 fg=green style 2-19 dim -8| "▌ packages/ui/tui 100% " +8| "▌ … +4 lines (Ctrl+O to expand) " style 0-0 fg=green -9| "▌ … 4 more lines (Ctrl+O to expand) " + style 2-30 dim +9| "▌ [exit 0] " style 0-0 fg=green - style 2-34 dim + style 2-9 dim 10| "▌ " style 0-0 fg=green 11| @@ -43,12 +44,12 @@ buffer 15| "▌ - old line " style 0-0 fg=green style 2-11 fg=red -16| "▌ - keep " +16| "▌ … +5 lines (Ctrl+O to expand) " style 0-0 fg=green - style 2-7 fg=red -17| "▌ … 5 more lines (Ctrl+O to expand) " + style 2-30 dim +17| "▌ + expect(screen).toMatchSnapshot() " style 0-0 fg=green - style 2-34 dim + style 2-35 fg=green 18| "▌ " style 0-0 fg=green 19| @@ -92,7 +93,7 @@ buffer style 1-1 inverse 37| "────────────────────────────────────────────────────────────────────────────────────────────────────" style 0-99 dim -38| "deepseek-v4-flash /workspace/project ↑0 ↓0 tools:collapsed" +38| "deepseek-v4-flash /workspace/project ↑0 ↓0 0% context tools:collapsed" style 0-43 dim - style 85-99 dim + style 73-99 dim 39| diff --git a/packages/ui/tui/tests/snapshots/advanced-cards-expanded.expected.txt b/packages/ui/tui/tests/snapshots/advanced-cards-expanded.expected.txt index a87393e203..2321eff61d 100644 --- a/packages/ui/tui/tests/snapshots/advanced-cards-expanded.expected.txt +++ b/packages/ui/tui/tests/snapshots/advanced-cards-expanded.expected.txt @@ -112,6 +112,6 @@ buffer style 1-1 inverse 46| "────────────────────────────────────────────────────────────────────────────────────────────────────" style 0-99 dim -47| "deepseek-v4-flash /workspace/project ↑0 ↓0 tools:expanded" +47| "deepseek-v4-flash /workspace/project ↑0 ↓0 0% context tools:expanded" style 0-43 dim - style 86-99 dim + style 74-99 dim diff --git a/packages/ui/tui/tests/snapshots/banner-gradient.expected.txt b/packages/ui/tui/tests/snapshots/banner-gradient.expected.txt index 312b7737dd..59d1377702 100644 --- a/packages/ui/tui/tests/snapshots/banner-gradient.expected.txt +++ b/packages/ui/tui/tests/snapshots/banner-gradient.expected.txt @@ -23,7 +23,7 @@ viewport style 1-1 inverse 5| "────────────────────────────────────────────────────────────────────────────────────────────────" style 0-95 dim -6| "deepseek-v4-flash /workspace/project ↑0 ↓0 tools:collapsed" +6| "deepseek-v4-flash /workspace/project ↑0 ↓0 0% context tools:collapsed" style 0-43 dim - style 81-95 dim + style 69-95 dim 7-35| diff --git a/packages/ui/tui/tests/snapshots/code-mode-pending.expected.txt b/packages/ui/tui/tests/snapshots/code-mode-pending.expected.txt index 703e2b2a58..2bfeb79449 100644 --- a/packages/ui/tui/tests/snapshots/code-mode-pending.expected.txt +++ b/packages/ui/tui/tests/snapshots/code-mode-pending.expected.txt @@ -36,7 +36,7 @@ buffer style 1-1 inverse 14| "────────────────────────────────────────────────────────────────────────────────────────────────" style 0-95 dim -15| "deepseek-v4-flash /workspace/project ↑0 ↓0 tools:collapsed" +15| "deepseek-v4-flash /workspace/project ↑0 ↓0 0% context tools:collapsed" style 0-43 dim - style 81-95 dim + style 69-95 dim 16-35| diff --git a/packages/ui/tui/tests/snapshots/conversation-streaming.expected.txt b/packages/ui/tui/tests/snapshots/conversation-streaming.expected.txt index a9924dd453..52e003eea0 100644 --- a/packages/ui/tui/tests/snapshots/conversation-streaming.expected.txt +++ b/packages/ui/tui/tests/snapshots/conversation-streaming.expected.txt @@ -1,7 +1,7 @@ terminal 96x36 buffer=normal length=36 base=0 viewport=0 -lifecycle started=1 stopped=0 progress=inactive +lifecycle started=1 stopped=0 progress=active title "DSH snapshot" -cursor hidden column=1 viewportRow=15 bufferRow=15 +cursor hidden column=1 viewportRow=17 bufferRow=17 viewport 0| " DEEPSEEK HARNESS" style 1-8 fg=bright-blue bold @@ -30,13 +30,17 @@ viewport style 1-9 fg=bright-magenta bold 13| " Streaming visible state… " style 11-23 bold -14| "────────────────────────────────────────────────────────────────────────────────────────────────" - style 0-95 dim -15| " " - style 1-1 inverse +14| +15| " ⠋ Responding 0s · total 0s — Enter sends steering, Esc cancels " + style 1-1 fg=bright-blue + style 3-62 fg=bright-black 16| "────────────────────────────────────────────────────────────────────────────────────────────────" - style 0-95 dim -17| "deepseek-v4-flash /workspace/project ↑0 ↓0 tools:collapsed" + style 0-95 fg=bright-blue +17| " " + style 1-1 inverse +18| "────────────────────────────────────────────────────────────────────────────────────────────────" + style 0-95 fg=bright-blue +19| "deepseek-v4-flash /workspace/project ↑0 ↓0 0% context tools:collapsed" style 0-43 dim - style 81-95 dim -18-35| + style 69-95 dim +20-35| diff --git a/packages/ui/tui/tests/snapshots/cordis-tools-pending.expected.txt b/packages/ui/tui/tests/snapshots/cordis-tools-pending.expected.txt index 6d0e55ef62..47c225008d 100644 --- a/packages/ui/tui/tests/snapshots/cordis-tools-pending.expected.txt +++ b/packages/ui/tui/tests/snapshots/cordis-tools-pending.expected.txt @@ -43,7 +43,7 @@ buffer style 1-1 inverse 17| "────────────────────────────────────────────────────────────────────────────────────────────────" style 0-95 dim -18| "deepseek-v4-flash /workspace/project ↑0 ↓0 tools:collapsed" +18| "deepseek-v4-flash /workspace/project ↑0 ↓0 0% context tools:collapsed" style 0-43 dim - style 81-95 dim + style 69-95 dim 19-35| diff --git a/packages/ui/tui/tests/snapshots/disposed-terminal.expected.txt b/packages/ui/tui/tests/snapshots/disposed-terminal.expected.txt index 618b4fa708..4224beecc3 100644 --- a/packages/ui/tui/tests/snapshots/disposed-terminal.expected.txt +++ b/packages/ui/tui/tests/snapshots/disposed-terminal.expected.txt @@ -1,7 +1,7 @@ terminal 92x32 buffer=normal length=32 base=0 viewport=0 lifecycle started=1 stopped=1 progress=inactive title "DSH snapshot" -cursor visible column=0 viewportRow=21 bufferRow=21 +cursor visible column=0 viewportRow=30 bufferRow=30 buffer 0| " DEEPSEEK HARNESS" style 1-8 fg=bright-blue bold @@ -15,30 +15,47 @@ buffer style 1-18 fg=bright-blue bold 5| " Enter send • Shift/Alt+Enter newline • Up/Down prompt history " style 1-61 fg=bright-black -6| " Esc cancel active turn • Ctrl+O expand tool cards • Ctrl+R toggle reasoning " +6| " Esc cancel active turn • Ctrl+O toggle tool cards • Ctrl+R toggle reasoning " style 1-75 fg=bright-black 7| " Ctrl+C cancel while running; clear input or exit while idle • Ctrl+D exit " style 1-73 fg=bright-black -8| " /help /clear /reasoning /tools /redraw /reload /resume /exit " - style 1-60 fg=bright-black -9| " /skill: [instructions] load a skill into the conversation " +8| " " +9| " /clear — Clear the transcript view (session history is unchanged) " + style 1-65 fg=bright-black +10| " /exit — Exit after the active turn reaches idle " + style 1-47 fg=bright-black +11| " /help — Show keyboard shortcuts and commands " + style 1-44 fg=bright-black +12| " /model [[provider/]model] — Show or switch this session's model " style 1-63 fg=bright-black -10| -11| " Unknown command: /unknown-advanced-command " - style 1-42 fg=yellow -12| -13| " provider stream failed after partial output " +13| " /reasoning — Toggle reasoning blocks " + style 1-36 fg=bright-black +14| " /redraw — Invalidate components and redraw the terminal " + style 1-55 fg=bright-black +15| " /reload — EXPERIMENTAL (dev): re-read loader config files and apply the diff (idle only) " + style 1-88 fg=bright-black +16| " /resume — List this workspace's resumable sessions " + style 1-50 fg=bright-black +17| " /tools — Expand or collapse all tool cards " + style 1-42 fg=bright-black +18| " /skill: [instructions] — load a skill into the conversation " + style 1-65 fg=bright-black +19| +20| " provider stream failed after partial output " style 1-43 fg=red -14| -15| " The previous process ended during this turn. " +21| +22| " The previous process ended during this turn. " style 1-44 fg=yellow -16| "────────────────────────────────────────────────────────────────────────────────────────────" +23| +24| " Unknown command: /unknown-advanced-command " + style 1-42 fg=yellow +25| "────────────────────────────────────────────────────────────────────────────────────────────" style 0-91 dim -17| " " +26| " " style 1-1 inverse -18| "────────────────────────────────────────────────────────────────────────────────────────────" +27| "────────────────────────────────────────────────────────────────────────────────────────────" style 0-91 dim -19| "deepseek-v4-flash /workspace/project ↑0 ↓0 tools:collapsed" +28| "deepseek-v4-flash /workspace/project ↑0 ↓0 0% context tools:collapsed" style 0-43 dim - style 77-91 dim -20-31| + style 65-91 dim +29-31| diff --git a/packages/ui/tui/tests/snapshots/dynamic-workflow-pending.expected.txt b/packages/ui/tui/tests/snapshots/dynamic-workflow-pending.expected.txt index 43516d7d0a..ace55782e9 100644 --- a/packages/ui/tui/tests/snapshots/dynamic-workflow-pending.expected.txt +++ b/packages/ui/tui/tests/snapshots/dynamic-workflow-pending.expected.txt @@ -23,8 +23,9 @@ buffer style 0-0 fg=yellow 8| "▌ () => agent('Audit layout', { label: 'layout', phase: 'Inspect' }), " style 0-0 fg=yellow -9| "▌ () => agent('Audit lifecycle', { label: 'lifecycle', phase: 'Inspect' }), " +9| "▌ … +1 lines (Ctrl+O to expand) " style 0-0 fg=yellow + style 2-30 dim 10| "▌ ]) " style 0-0 fg=yellow 11| "▌ phase('Verify') " @@ -39,7 +40,7 @@ buffer style 1-1 inverse 16| "────────────────────────────────────────────────────────────────────────────────────────────────" style 0-95 dim -17| "deepseek-v4-flash /workspace/project ↑0 ↓0 tools:collapsed" +17| "deepseek-v4-flash /workspace/project ↑0 ↓0 0% context tools:collapsed" style 0-43 dim - style 81-95 dim + style 69-95 dim 18-35| diff --git a/packages/ui/tui/tests/snapshots/errors-and-help.expected.txt b/packages/ui/tui/tests/snapshots/errors-and-help.expected.txt index 1fae080a72..b6bb78ce3a 100644 --- a/packages/ui/tui/tests/snapshots/errors-and-help.expected.txt +++ b/packages/ui/tui/tests/snapshots/errors-and-help.expected.txt @@ -1,7 +1,7 @@ terminal 92x32 buffer=normal length=32 base=0 viewport=0 lifecycle started=1 stopped=0 progress=inactive title "DSH snapshot" -cursor hidden column=1 viewportRow=17 bufferRow=17 +cursor hidden column=1 viewportRow=26 bufferRow=26 buffer 0| " DEEPSEEK HARNESS" style 1-8 fg=bright-blue bold @@ -15,30 +15,47 @@ buffer style 1-18 fg=bright-blue bold 5| " Enter send • Shift/Alt+Enter newline • Up/Down prompt history " style 1-61 fg=bright-black -6| " Esc cancel active turn • Ctrl+O expand tool cards • Ctrl+R toggle reasoning " +6| " Esc cancel active turn • Ctrl+O toggle tool cards • Ctrl+R toggle reasoning " style 1-75 fg=bright-black 7| " Ctrl+C cancel while running; clear input or exit while idle • Ctrl+D exit " style 1-73 fg=bright-black -8| " /help /clear /reasoning /tools /redraw /reload /resume /exit " - style 1-60 fg=bright-black -9| " /skill: [instructions] load a skill into the conversation " +8| " " +9| " /clear — Clear the transcript view (session history is unchanged) " + style 1-65 fg=bright-black +10| " /exit — Exit after the active turn reaches idle " + style 1-47 fg=bright-black +11| " /help — Show keyboard shortcuts and commands " + style 1-44 fg=bright-black +12| " /model [[provider/]model] — Show or switch this session's model " style 1-63 fg=bright-black -10| -11| " Unknown command: /unknown-advanced-command " - style 1-42 fg=yellow -12| -13| " provider stream failed after partial output " +13| " /reasoning — Toggle reasoning blocks " + style 1-36 fg=bright-black +14| " /redraw — Invalidate components and redraw the terminal " + style 1-55 fg=bright-black +15| " /reload — EXPERIMENTAL (dev): re-read loader config files and apply the diff (idle only) " + style 1-88 fg=bright-black +16| " /resume — List this workspace's resumable sessions " + style 1-50 fg=bright-black +17| " /tools — Expand or collapse all tool cards " + style 1-42 fg=bright-black +18| " /skill: [instructions] — load a skill into the conversation " + style 1-65 fg=bright-black +19| +20| " provider stream failed after partial output " style 1-43 fg=red -14| -15| " The previous process ended during this turn. " +21| +22| " The previous process ended during this turn. " style 1-44 fg=yellow -16| "────────────────────────────────────────────────────────────────────────────────────────────" +23| +24| " Unknown command: /unknown-advanced-command " + style 1-42 fg=yellow +25| "────────────────────────────────────────────────────────────────────────────────────────────" style 0-91 dim -17| " " +26| " " style 1-1 inverse -18| "────────────────────────────────────────────────────────────────────────────────────────────" +27| "────────────────────────────────────────────────────────────────────────────────────────────" style 0-91 dim -19| "deepseek-v4-flash /workspace/project ↑0 ↓0 tools:collapsed" +28| "deepseek-v4-flash /workspace/project ↑0 ↓0 0% context tools:collapsed" style 0-43 dim - style 77-91 dim -20-31| + style 65-91 dim +29-31| diff --git a/packages/ui/tui/tests/snapshots/model-selector.expected.txt b/packages/ui/tui/tests/snapshots/model-selector.expected.txt index 4bd9dcbbbc..f10c99d03a 100644 --- a/packages/ui/tui/tests/snapshots/model-selector.expected.txt +++ b/packages/ui/tui/tests/snapshots/model-selector.expected.txt @@ -3,33 +3,23 @@ lifecycle started=1 stopped=0 progress=inactive title "DSH snapshot" cursor hidden column=0 viewportRow=31 bufferRow=31 buffer -0| "╭──────────────────────────────────────────────────────────────────────────────────────────╮" - style 0-91 fg=bright-blue -1| "│ DEEPSEEK HARNESS │" - style 0-0 fg=bright-blue - style 2-9 fg=bright-blue bold - style 11-17 bold - style 91-91 fg=bright-blue -2| "│ Snapshot agent ready. │" - style 0-0 fg=bright-blue - style 2-22 fg=bright-black - style 91-91 fg=bright-blue -3| "│ deepseek-v4-flash • main-session │" - style 0-0 fg=bright-blue - style 2-35 dim - style 91-91 fg=bright-blue -4| "╰──────────────────────────────────────────────────────────────────────────────────────────╯" - style 0-91 fg=bright-blue +0| " DEEPSEEK HARNESS" + style 1-8 fg=bright-blue bold + style 10-16 bold +1| " Snapshot agent ready." + style 1-21 fg=bright-black +2| " deepseek-v4-flash • main-session" + style 1-34 dim +3| "────────────────────────────────────────────────────────────────────────────────────────────" + style 0-91 dim +4| " " + style 1-1 inverse 5| "────────────────────────────────────────────────────────────────────────────────────────────" style 0-91 dim -6| " " - style 1-1 inverse -7| "────────────────────────────────────────────────────────────────────────────────────────────" - style 0-91 dim -8| "/workspace/project ↑0 ↓0 0% context tools:compact deepseek-v4-flash(reasoning:on)" - style 0-24 dim - style 34-91 dim -9-12| +6| "deepseek-v4-flash /workspace/project ↑0 ↓0 0% context tools:collapsed" + style 0-43 dim + style 65-91 dim +7-12| 13| " ╭ Select model ────────────────────────────────────────────────────────╮ " style 10-81 fg=bright-blue 14| " │ → deepseek/deepseek-v4-flash DeepSeek V4 Flash — current │ " diff --git a/packages/ui/tui/tests/snapshots/model-switching.expected.txt b/packages/ui/tui/tests/snapshots/model-switching.expected.txt index 7027c435db..5800901f08 100644 --- a/packages/ui/tui/tests/snapshots/model-switching.expected.txt +++ b/packages/ui/tui/tests/snapshots/model-switching.expected.txt @@ -1,35 +1,25 @@ terminal 92x32 buffer=normal length=32 base=0 viewport=0 lifecycle started=1 stopped=0 progress=inactive title "DSH snapshot" -cursor hidden column=1 viewportRow=8 bufferRow=8 +cursor hidden column=1 viewportRow=6 bufferRow=6 buffer -0| "╭──────────────────────────────────────────────────────────────────────────────────────────╮" - style 0-91 fg=bright-blue -1| "│ DEEPSEEK HARNESS │" - style 0-0 fg=bright-blue - style 2-9 fg=bright-blue bold - style 11-17 bold - style 91-91 fg=bright-blue -2| "│ Snapshot agent ready. │" - style 0-0 fg=bright-blue - style 2-22 fg=bright-black - style 91-91 fg=bright-blue -3| "│ deepseek-v4-pro • main-session │" - style 0-0 fg=bright-blue - style 2-33 dim - style 91-91 fg=bright-blue -4| "╰──────────────────────────────────────────────────────────────────────────────────────────╯" - style 0-91 fg=bright-blue -5| -6| " Model selected: deepseek/deepseek-v4-pro. New steps will use it. " +0| " DEEPSEEK HARNESS" + style 1-8 fg=bright-blue bold + style 10-16 bold +1| " Snapshot agent ready." + style 1-21 fg=bright-black +2| " deepseek-v4-pro • main-session" + style 1-32 dim +3| +4| " Model selected: deepseek/deepseek-v4-pro. New steps will use it. " style 1-64 fg=bright-black +5| "────────────────────────────────────────────────────────────────────────────────────────────" + style 0-91 dim +6| " " + style 1-1 inverse 7| "────────────────────────────────────────────────────────────────────────────────────────────" style 0-91 dim -8| " " - style 1-1 inverse -9| "────────────────────────────────────────────────────────────────────────────────────────────" - style 0-91 dim -10| "/workspace/project ↑0 ↓0 0% context tools:compact deepseek-v4-pro(reasoning:on)" - style 0-24 dim - style 36-91 dim -11-31| +8| "deepseek-v4-pro /workspace/project ↑0 ↓0 0% context tools:collapsed" + style 0-41 dim + style 65-91 dim +9-31| diff --git a/packages/ui/tui/tests/snapshots/question-dialog-validation.expected.txt b/packages/ui/tui/tests/snapshots/question-dialog-validation.expected.txt index 1eeb749c9d..4e17a0e652 100644 --- a/packages/ui/tui/tests/snapshots/question-dialog-validation.expected.txt +++ b/packages/ui/tui/tests/snapshots/question-dialog-validation.expected.txt @@ -1,7 +1,7 @@ terminal 56x20 buffer=normal length=20 base=0 viewport=0 lifecycle started=1 stopped=0 progress=inactive title "DSH snapshot" -cursor hidden column=56 viewportRow=13 bufferRow=13 +cursor hidden column=56 viewportRow=17 bufferRow=17 viewport 0| " DEEPSEEK HARNESS" style 1-8 fg=bright-blue bold @@ -12,50 +12,30 @@ viewport style 1-34 dim 3| "────────────────────────────────────────────────────────" style 0-55 dim -4| " ╭ Coverage ────────────────────────────────────╮ " +4| " " style 1-1 inverse - style 4-51 fg=bright-blue -5| "────│ Which advanced TUI states belong in the │────" - style 0-3 dim - style 4-4 fg=bright-blue - style 6-50 bold - style 51-51 fg=bright-blue bold - style 52-55 dim -6| "deep│ required matrix? │coll" - style 0-3 dim - style 4-4 fg=bright-blue - style 6-21 bold - style 51-51 fg=bright-blue - style 52-55 dim -7| " │ │ " - style 4-4 fg=bright-blue - style 51-51 fg=bright-blue -8| " │ › [ ] Code Mode — run_code programs and capt │ " - style 4-4 fg=bright-blue - style 6-6 fg=bright-blue inverse - style 7-20 inverse - style 21-49 fg=bright-black inverse - style 51-51 fg=bright-blue -9| " │ [ ] Workflows — phases and parallel agents │ " - style 4-4 fg=bright-blue - style 21-49 fg=bright-black - style 51-51 fg=bright-blue -10| " │ [ ] Cordis tools — inspect, mount, and unm │ " - style 4-4 fg=bright-blue - style 24-49 fg=bright-black - style 51-51 fg=bright-blue -11| " │ 1/4 │ " - style 4-4 fg=bright-blue - style 6-8 dim - style 51-51 fg=bright-blue -12| " │ ↑↓ navigate • Space toggle • Enter submit • │ " - style 4-4 fg=bright-blue - style 6-49 dim - style 51-51 fg=bright-blue -13| " │ Select at least one option, or press C for a │ " - style 4-4 fg=bright-blue - style 6-49 fg=red - style 51-51 fg=bright-blue -14| " ╰──────────────────────────────────────────────╯ " - style 4-51 fg=bright-blue -15-19| +5| " " +6| " Question 1/3 (3 unanswered) · Coverage " + style 2-39 fg=bright-black +7| " Which advanced TUI states belong in the required " +8| " matrix? " +9| " " +10| " › 1. [ ] Code Mode run_code programs and capture " + style 2-19 fg=bright-blue bold + style 25-53 fg=bright-black +11| " 2. [ ] Workflows phases and parallel agents " + style 25-50 fg=bright-black +12| " 3. [ ] Cordis tools inspect, mount, and unmount " + style 25-51 fg=bright-black +13| " 1/4 " + style 2-4 dim +14| " Tab custom answer • ↑/↓ navigate • Space toggle • " + style 2-55 dim +15| " Enter submit • Esc interrupt " + style 2-29 dim +16| " Select at least one option, or press Tab for a " + style 2-55 fg=red +17| " custom answer. " + style 2-15 fg=red +18| " " +19| diff --git a/packages/ui/tui/tests/snapshots/question-dialog.expected.txt b/packages/ui/tui/tests/snapshots/question-dialog.expected.txt index 2bb82734f4..220f5dc1c7 100644 --- a/packages/ui/tui/tests/snapshots/question-dialog.expected.txt +++ b/packages/ui/tui/tests/snapshots/question-dialog.expected.txt @@ -14,45 +14,29 @@ viewport style 0-55 dim 4| " " style 1-1 inverse -5| "────╭ Coverage ────────────────────────────────────╮────" - style 0-3 dim - style 4-51 fg=bright-blue - style 52-55 dim -6| "deep│ Which advanced TUI states belong in the │coll" - style 0-3 dim - style 4-4 fg=bright-blue - style 6-50 bold - style 51-51 fg=bright-blue bold - style 52-55 dim -7| " │ required matrix? │ " - style 4-4 fg=bright-blue - style 6-21 bold - style 51-51 fg=bright-blue -8| " │ │ " - style 4-4 fg=bright-blue - style 51-51 fg=bright-blue -9| " │ › [ ] Code Mode — run_code programs and capt │ " - style 4-4 fg=bright-blue - style 6-6 fg=bright-blue inverse - style 7-20 inverse - style 21-49 fg=bright-black inverse - style 51-51 fg=bright-blue -10| " │ [ ] Workflows — phases and parallel agents │ " - style 4-4 fg=bright-blue - style 21-49 fg=bright-black - style 51-51 fg=bright-blue -11| " │ [ ] Cordis tools — inspect, mount, and unm │ " - style 4-4 fg=bright-blue - style 24-49 fg=bright-black - style 51-51 fg=bright-blue -12| " │ 1/4 │ " - style 4-4 fg=bright-blue - style 6-8 dim - style 51-51 fg=bright-blue -13| " │ ↑↓ navigate • Space toggle • Enter submit • │ " - style 4-4 fg=bright-blue - style 6-49 dim - style 51-51 fg=bright-blue -14| " ╰──────────────────────────────────────────────╯ " - style 4-51 fg=bright-blue -15-19| +5| "────────────────────────────────────────────────────────" + style 0-55 dim +6| "deepseek-v4-flash /workspace/project ↑0 ↓0 0% context" + style 0-43 dim + style 46-55 dim +7| " " +8| " Question 1/3 (3 unanswered) · Coverage " + style 2-39 fg=bright-black +9| " Which advanced TUI states belong in the required " +10| " matrix? " +11| " " +12| " › 1. [ ] Code Mode run_code programs and capture " + style 2-19 fg=bright-blue bold + style 25-53 fg=bright-black +13| " 2. [ ] Workflows phases and parallel agents " + style 25-50 fg=bright-black +14| " 3. [ ] Cordis tools inspect, mount, and unmount " + style 25-51 fg=bright-black +15| " 1/4 " + style 2-4 dim +16| " Tab custom answer • ↑/↓ navigate • Space toggle • " + style 2-55 dim +17| " Enter submit • Esc interrupt " + style 2-29 dim +18| " " +19| diff --git a/packages/ui/tui/tests/snapshots/resume-sessions.expected.txt b/packages/ui/tui/tests/snapshots/resume-sessions.expected.txt index ffa82ce4b9..7711b71636 100644 --- a/packages/ui/tui/tests/snapshots/resume-sessions.expected.txt +++ b/packages/ui/tui/tests/snapshots/resume-sessions.expected.txt @@ -26,7 +26,7 @@ buffer style 1-1 inverse 11| "────────────────────────────────────────────────────────────────────────────────────────────" style 0-91 dim -12| "deepseek-v4-flash /workspace/project ↑0 ↓0 tools:collapsed" +12| "deepseek-v4-flash /workspace/project ↑0 ↓0 0% context tools:collapsed" style 0-43 dim - style 77-91 dim + style 65-91 dim 13-31| diff --git a/packages/ui/tui/tests/snapshots/retry-cancelled.expected.txt b/packages/ui/tui/tests/snapshots/retry-cancelled.expected.txt index 2e6de69775..accef4fffc 100644 --- a/packages/ui/tui/tests/snapshots/retry-cancelled.expected.txt +++ b/packages/ui/tui/tests/snapshots/retry-cancelled.expected.txt @@ -1,48 +1,38 @@ terminal 96x36 buffer=normal length=36 base=0 viewport=0 lifecycle started=1 stopped=0 progress=inactive title "DSH snapshot" -cursor hidden column=1 viewportRow=15 bufferRow=15 +cursor hidden column=1 viewportRow=13 bufferRow=13 buffer -0| "╭──────────────────────────────────────────────────────────────────────────────────────────────╮" - style 0-95 fg=bright-blue -1| "│ DEEPSEEK HARNESS │" +0| " DEEPSEEK HARNESS" + style 1-8 fg=bright-blue bold + style 10-16 bold +1| " Snapshot agent ready." + style 1-21 fg=bright-black +2| " deepseek-v4-flash • main-session" + style 1-34 dim +3| +4| "▌ " style 0-0 fg=bright-blue - style 2-9 fg=bright-blue bold - style 11-17 bold - style 95-95 fg=bright-blue -2| "│ Snapshot agent ready. │" - style 0-0 fg=bright-blue - style 2-22 fg=bright-black - style 95-95 fg=bright-blue -3| "│ deepseek-v4-flash • main-session │" - style 0-0 fg=bright-blue - style 2-35 dim - style 95-95 fg=bright-blue -4| "╰──────────────────────────────────────────────────────────────────────────────────────────────╯" - style 0-95 fg=bright-blue -5| -6| "▌ " - style 0-0 fg=bright-blue -7| "▌ You " +5| "▌ You " style 0-0 fg=bright-blue style 2-4 fg=bright-blue bold -8| "▌ Start then cancel. " +6| "▌ Start then cancel. " style 0-0 fg=bright-blue -9| "▌ " +7| "▌ " style 0-0 fg=bright-blue -10| -11| " Retrying model request (1/2) in 1000ms: temporary transport failure " +8| +9| " Retrying model request (1/2) in 1000ms: temporary transport failure " style 1-67 fg=yellow -12| -13| " Turn cancelled. " +10| +11| " Turn cancelled. " style 1-15 fg=yellow +12| "────────────────────────────────────────────────────────────────────────────────────────────────" + style 0-95 dim +13| " " + style 1-1 inverse 14| "────────────────────────────────────────────────────────────────────────────────────────────────" style 0-95 dim -15| " " - style 1-1 inverse -16| "────────────────────────────────────────────────────────────────────────────────────────────────" - style 0-95 dim -17| "/workspace/project ↑0 ↓0 0% context tools:compact deepseek-v4-flash(reasoning:on)" - style 0-24 dim - style 38-95 dim -18-35| +15| "deepseek-v4-flash /workspace/project ↑0 ↓0 0% context tools:collapsed" + style 0-43 dim + style 69-95 dim +16-35| diff --git a/packages/ui/tui/tests/snapshots/retry-exhausted.expected.txt b/packages/ui/tui/tests/snapshots/retry-exhausted.expected.txt index c3be43370f..1b038d5a83 100644 --- a/packages/ui/tui/tests/snapshots/retry-exhausted.expected.txt +++ b/packages/ui/tui/tests/snapshots/retry-exhausted.expected.txt @@ -1,45 +1,35 @@ terminal 96x36 buffer=normal length=36 base=0 viewport=0 lifecycle started=1 stopped=0 progress=inactive title "DSH snapshot" -cursor hidden column=1 viewportRow=13 bufferRow=13 +cursor hidden column=1 viewportRow=11 bufferRow=11 buffer -0| "╭──────────────────────────────────────────────────────────────────────────────────────────────╮" - style 0-95 fg=bright-blue -1| "│ DEEPSEEK HARNESS │" +0| " DEEPSEEK HARNESS" + style 1-8 fg=bright-blue bold + style 10-16 bold +1| " Snapshot agent ready." + style 1-21 fg=bright-black +2| " deepseek-v4-flash • main-session" + style 1-34 dim +3| +4| "▌ " style 0-0 fg=bright-blue - style 2-9 fg=bright-blue bold - style 11-17 bold - style 95-95 fg=bright-blue -2| "│ Snapshot agent ready. │" - style 0-0 fg=bright-blue - style 2-22 fg=bright-black - style 95-95 fg=bright-blue -3| "│ deepseek-v4-flash • main-session │" - style 0-0 fg=bright-blue - style 2-35 dim - style 95-95 fg=bright-blue -4| "╰──────────────────────────────────────────────────────────────────────────────────────────────╯" - style 0-95 fg=bright-blue -5| -6| "▌ " - style 0-0 fg=bright-blue -7| "▌ You " +5| "▌ You " style 0-0 fg=bright-blue style 2-4 fg=bright-blue bold -8| "▌ Let the bounded policy exhaust. " +6| "▌ Let the bounded policy exhaust. " style 0-0 fg=bright-blue -9| "▌ " +7| "▌ " style 0-0 fg=bright-blue -10| -11| " provider still unavailable " +8| +9| " provider still unavailable " style 1-26 fg=red +10| "────────────────────────────────────────────────────────────────────────────────────────────────" + style 0-95 dim +11| " " + style 1-1 inverse 12| "────────────────────────────────────────────────────────────────────────────────────────────────" style 0-95 dim -13| " " - style 1-1 inverse -14| "────────────────────────────────────────────────────────────────────────────────────────────────" - style 0-95 dim -15| "/workspace/project ↑0 ↓0 0% context tools:compact deepseek-v4-flash(reasoning:on)" - style 0-24 dim - style 38-95 dim -16-35| +13| "deepseek-v4-flash /workspace/project ↑0 ↓0 0% context tools:collapsed" + style 0-43 dim + style 69-95 dim +14-35| diff --git a/packages/ui/tui/tests/snapshots/retry-recovered.expected.txt b/packages/ui/tui/tests/snapshots/retry-recovered.expected.txt index e23e6aa6c7..c0ae86361c 100644 --- a/packages/ui/tui/tests/snapshots/retry-recovered.expected.txt +++ b/packages/ui/tui/tests/snapshots/retry-recovered.expected.txt @@ -1,49 +1,39 @@ terminal 96x36 buffer=normal length=36 base=0 viewport=0 lifecycle started=1 stopped=0 progress=inactive title "DSH snapshot" -cursor hidden column=1 viewportRow=16 bufferRow=16 +cursor hidden column=1 viewportRow=14 bufferRow=14 buffer -0| "╭──────────────────────────────────────────────────────────────────────────────────────────────╮" - style 0-95 fg=bright-blue -1| "│ DEEPSEEK HARNESS │" +0| " DEEPSEEK HARNESS" + style 1-8 fg=bright-blue bold + style 10-16 bold +1| " Snapshot agent ready." + style 1-21 fg=bright-black +2| " deepseek-v4-flash • main-session" + style 1-34 dim +3| +4| "▌ " style 0-0 fg=bright-blue - style 2-9 fg=bright-blue bold - style 11-17 bold - style 95-95 fg=bright-blue -2| "│ Snapshot agent ready. │" - style 0-0 fg=bright-blue - style 2-22 fg=bright-black - style 95-95 fg=bright-blue -3| "│ deepseek-v4-flash • main-session │" - style 0-0 fg=bright-blue - style 2-35 dim - style 95-95 fg=bright-blue -4| "╰──────────────────────────────────────────────────────────────────────────────────────────────╯" - style 0-95 fg=bright-blue -5| -6| "▌ " - style 0-0 fg=bright-blue -7| "▌ You " +5| "▌ You " style 0-0 fg=bright-blue style 2-4 fg=bright-blue bold -8| "▌ Recover this request. " +6| "▌ Recover this request. " style 0-0 fg=bright-blue -9| "▌ " +7| "▌ " style 0-0 fg=bright-blue -10| -11| " Retrying model request (1/2) in 500ms: provider rate limit " +8| +9| " Retrying model request (1/2) in 500ms: provider rate limit " style 1-58 fg=yellow -12| -13| " Assistant " +10| +11| " Assistant " style 1-9 fg=bright-magenta bold -14| " Recovered on the next bounded attempt. " +12| " Recovered on the next bounded attempt. " +13| "────────────────────────────────────────────────────────────────────────────────────────────────" + style 0-95 dim +14| " " + style 1-1 inverse 15| "────────────────────────────────────────────────────────────────────────────────────────────────" style 0-95 dim -16| " " - style 1-1 inverse -17| "────────────────────────────────────────────────────────────────────────────────────────────────" - style 0-95 dim -18| "/workspace/project ↑0 ↓0 0% context tools:compact deepseek-v4-flash(reasoning:on)" - style 0-24 dim - style 38-95 dim -19-35| +16| "deepseek-v4-flash /workspace/project ↑0 ↓0 0% context tools:collapsed" + style 0-43 dim + style 69-95 dim +17-35| diff --git a/packages/ui/tui/tests/snapshots/retry-scheduled.expected.txt b/packages/ui/tui/tests/snapshots/retry-scheduled.expected.txt index 07f73cff2a..16dee8242a 100644 --- a/packages/ui/tui/tests/snapshots/retry-scheduled.expected.txt +++ b/packages/ui/tui/tests/snapshots/retry-scheduled.expected.txt @@ -1,45 +1,35 @@ terminal 96x36 buffer=normal length=36 base=0 viewport=0 lifecycle started=1 stopped=0 progress=inactive title "DSH snapshot" -cursor hidden column=1 viewportRow=13 bufferRow=13 +cursor hidden column=1 viewportRow=11 bufferRow=11 buffer -0| "╭──────────────────────────────────────────────────────────────────────────────────────────────╮" - style 0-95 fg=bright-blue -1| "│ DEEPSEEK HARNESS │" +0| " DEEPSEEK HARNESS" + style 1-8 fg=bright-blue bold + style 10-16 bold +1| " Snapshot agent ready." + style 1-21 fg=bright-black +2| " deepseek-v4-flash • main-session" + style 1-34 dim +3| +4| "▌ " style 0-0 fg=bright-blue - style 2-9 fg=bright-blue bold - style 11-17 bold - style 95-95 fg=bright-blue -2| "│ Snapshot agent ready. │" - style 0-0 fg=bright-blue - style 2-22 fg=bright-black - style 95-95 fg=bright-blue -3| "│ deepseek-v4-flash • main-session │" - style 0-0 fg=bright-blue - style 2-35 dim - style 95-95 fg=bright-blue -4| "╰──────────────────────────────────────────────────────────────────────────────────────────────╯" - style 0-95 fg=bright-blue -5| -6| "▌ " - style 0-0 fg=bright-blue -7| "▌ You " +5| "▌ You " style 0-0 fg=bright-blue style 2-4 fg=bright-blue bold -8| "▌ Recover this request. " +6| "▌ Recover this request. " style 0-0 fg=bright-blue -9| "▌ " +7| "▌ " style 0-0 fg=bright-blue -10| -11| " Retrying model request (1/2) in 500ms: provider rate limit " +8| +9| " Retrying model request (1/2) in 500ms: provider rate limit " style 1-58 fg=yellow +10| "────────────────────────────────────────────────────────────────────────────────────────────────" + style 0-95 dim +11| " " + style 1-1 inverse 12| "────────────────────────────────────────────────────────────────────────────────────────────────" style 0-95 dim -13| " " - style 1-1 inverse -14| "────────────────────────────────────────────────────────────────────────────────────────────────" - style 0-95 dim -15| "/workspace/project ↑0 ↓0 0% context tools:compact deepseek-v4-flash(reasoning:on)" - style 0-24 dim - style 38-95 dim -16-35| +13| "deepseek-v4-flash /workspace/project ↑0 ↓0 0% context tools:collapsed" + style 0-43 dim + style 69-95 dim +14-35| diff --git a/packages/ui/tui/tests/snapshots/surface-after-compaction-wide.expected.txt b/packages/ui/tui/tests/snapshots/surface-after-compaction-wide.expected.txt index 1e562ff3d6..6acf1e0483 100644 --- a/packages/ui/tui/tests/snapshots/surface-after-compaction-wide.expected.txt +++ b/packages/ui/tui/tests/snapshots/surface-after-compaction-wide.expected.txt @@ -21,7 +21,7 @@ buffer style 1-1 inverse 8| "────────────────────────────────────────────────────────────────────────────────────────────────────────" style 0-103 dim -9| "deepseek-v4-flash /workspace/project ↑0 ↓0 tools:collapsed" +9| "deepseek-v4-flash /workspace/project ↑0 ↓0 0% context tools:collapsed" style 0-43 dim - style 89-103 dim + style 77-103 dim 10-29| diff --git a/packages/ui/tui/tests/snapshots/surface-before-compaction.expected.txt b/packages/ui/tui/tests/snapshots/surface-before-compaction.expected.txt index 6d863f50ab..e69fb478b0 100644 --- a/packages/ui/tui/tests/snapshots/surface-before-compaction.expected.txt +++ b/packages/ui/tui/tests/snapshots/surface-before-compaction.expected.txt @@ -35,8 +35,9 @@ buffer style 2-19 dim 13| "▌ packages/ui/tui 100% " style 0-0 fg=green -14| "▌ 4016 tests passed " +14| "▌ … +1 lines (Ctrl+O to expand) " style 0-0 fg=green + style 2-30 dim 15| "▌ 1 test skipped " style 0-0 fg=green 16| "▌ coverage complete " @@ -52,7 +53,7 @@ buffer style 1-1 inverse 21| "────────────────────────────────────────────────────────────────────────────────" style 0-79 dim -22| "deepseek-v4-flash /workspace/project ↑0 ↓0 tools:collapsed" +22| "deepseek-v4-flash /workspace/project ↑0 ↓0 0% context tools:collapsed" style 0-43 dim - style 65-79 dim + style 53-79 dim 23| diff --git a/packages/ui/tui/tests/snapshots/untrusted-controls.expected.txt b/packages/ui/tui/tests/snapshots/untrusted-controls.expected.txt index 1fe3d3ead4..fd2437a492 100644 --- a/packages/ui/tui/tests/snapshots/untrusted-controls.expected.txt +++ b/packages/ui/tui/tests/snapshots/untrusted-controls.expected.txt @@ -39,36 +39,19 @@ buffer 17| "▌ Unsafe description \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m " style 0-0 fg=green style 2-65 fg=bright-black -18| "▌ /unsafe/\\x1b╭ Unsafe header \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m ─────────╮ " +18| "▌ /unsafe/\\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m " style 0-0 fg=green - style 2-13 dim - style 14-85 fg=bright-blue -19| "▌ Unsafe outpu│ Unsafe question \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m │ " + style 2-54 dim +19| "▌ Unsafe output \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m " style 0-0 fg=green - style 14-14 fg=bright-blue - style 16-76 bold - style 85-85 fg=bright-blue -20| "▌ [signal SIG\\│ │ " +20| "▌ [signal SIG\\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m] " style 0-0 fg=green - style 2-13 fg=red - style 14-14 fg=bright-blue - style 85-85 fg=bright-blue -21| "▌ │ › ● Unsafe option \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m — Un │ " + style 2-58 fg=red +21| "▌ " style 0-0 fg=green - style 14-14 fg=bright-blue - style 16-16 fg=bright-blue inverse - style 17-17 inverse - style 18-18 fg=bright-blue inverse - style 19-78 inverse - style 79-83 fg=bright-black inverse - style 85-85 fg=bright-blue -22| " │ ↑↓ navigate • Enter select • C custom • Esc cancel │ " - style 14-14 fg=bright-blue - style 16-65 dim - style 85-85 fg=bright-blue -23| " Context · uns╰──────────────────────────────────────────────────────────────────────╯ " - style 1-13 dim - style 14-85 fg=bright-blue +22| +23| " Context · unsafe-\\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m " + style 1-62 dim 24| " Unsafe context \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m " style 1-60 fg=bright-black 25| @@ -78,19 +61,17 @@ buffer 28| " Unsafe turn error \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m " style 1-63 fg=red 29| -30| " Unsafe live error \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m " - style 1-63 fg=red -31| -32| "Plan" - style 0-3 fg=bright-blue bold -33| " ● Unsafe todo \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m" - style 2-2 fg=yellow -34| "────────────────────────────────────────────────────────────────────────────────────────────────────" - style 0-99 dim -35| " " - style 1-1 inverse -36| "────────────────────────────────────────────────────────────────────────────────────────────────────" - style 0-99 dim -37| "deepseek-v4-flash /workspace/project ↑0 ↓0 tools:collapsed" +30| " " +31| " Question 1/1 (1 unanswered) · Unsafe header \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m " + style 2-90 fg=bright-black +32| " Unsafe question \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m " +33| " " +34| " › 1. Unsafe option \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m Unsafe detail \\x1b]2;snapshot-c " + style 2-65 fg=bright-blue bold + style 67-97 fg=bright-black +35| " Tab custom answer • ↑/↓ navigate • Enter submit • Esc interrupt " + style 2-64 dim +36| " " +37| "deepseek-v4-flash /workspace/project ↑0 ↓0 0% context tools:collapsed" style 0-43 dim - style 85-99 dim + style 73-99 dim From 66c4a353b932f7c5609c0431f6fca6e874df9d6f Mon Sep 17 00:00:00 2001 From: Turtle Date: Wed, 22 Jul 2026 12:57:33 +0800 Subject: [PATCH 51/90] docs: regenerate catalogs, graphs, and the README pairing record after the rebase --- README.i18n.yaml | 4 ++-- docs/event-producer-consumer.md | 2 +- docs/module-graph.md | 16 ++++++++++------ examples/tui-agent/composition.md | 3 --- knip.json | 3 +-- 5 files changed, 14 insertions(+), 14 deletions(-) diff --git a/README.i18n.yaml b/README.i18n.yaml index 576106cf32..020593bf20 100644 --- a/README.i18n.yaml +++ b/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 -README.md: 3c6488f08e9ba487bf04c932aa6cc5e5ea7eac06 -README.zh.md: afbf5b9ef5dea065be4954c8699596851fc6e6ed +README.md: 8b34d6177834e1c410b2c3ecaf32154de42520b4 +README.zh.md: cf029cc0bb8c5aa527d14753803eac4c28ab9de7 diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 517c08408d..4ea36965a0 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -15,7 +15,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `agent/post-step` | `serial` | [`packages/core/agent/src/types.ts:296`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`compact-basic`](../packages/compact/compact-basic) | | `agent/pre-step` | `serial` | [`packages/core/agent/src/types.ts:230`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`time-context`](../packages/context/time-context), [`user-approval`](../packages/ui/user-approval) | | `agent/prompt-submit` | `waterfall` | [`packages/core/agent/src/types.ts:243`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`acp`](../packages/ui/acp), [`goal-session`](../packages/goal/goal-session), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) | -| `agent/queued` | `emit` | [`packages/core/agent/src/types.ts:191`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-session`](../packages/goal/goal-session) | +| `agent/queued` | `emit` | [`packages/core/agent/src/types.ts:191`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | | `agent/request` | `waterfall` | [`packages/core/agent/src/types.ts:257`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent) | | `agent/request-error` | `waterfall` | [`packages/core/agent/src/types.ts:311`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compact-basic`](../packages/compact/compact-basic), [`llm-retry`](../packages/llm/llm-retry) | | `agent/session-prefix` | `waterfall` | [`packages/core/agent/src/types.ts:272`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`tool-skill`](../packages/skill/tool-skill), [`workspace-context`](../packages/context/workspace-context) | diff --git a/docs/module-graph.md b/docs/module-graph.md index f4cba95c2a..2fc9a0bcea 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -181,7 +181,6 @@ flowchart TD pkg_subagent_subprocess --> pkg_invariants pkg_acp_snapshot --> pkg_invariants pkg_loader_smoke --> pkg_invariants - pkg_app_boot --> pkg_invariants pkg_code_runtime --> pkg_invariants pkg_jsonrpc_demo --> pkg_invariants pkg_llm --> pkg_brand @@ -190,8 +189,6 @@ flowchart TD pkg_code_runtime_worker --> pkg_invariants pkg_helper --> pkg_brand pkg_helper --> pkg_invariants - pkg_scripts --> pkg_app_boot - pkg_scripts --> pkg_invariants pkg_telemetry --> pkg_brand pkg_telemetry --> pkg_invariants pkg_telemetry --> pkg_paths @@ -258,6 +255,9 @@ flowchart TD pkg_llm_replay --> pkg_invariants pkg_llm_replay --> pkg_llm pkg_llm_replay --> pkg_session + pkg_app_boot --> pkg_invariants + pkg_app_boot --> pkg_paths + pkg_app_boot --> pkg_system_prompt pkg_lsp_local --> pkg_brand pkg_lsp_local --> pkg_invariants pkg_lsp_local --> pkg_llm @@ -335,6 +335,8 @@ flowchart TD pkg_time_context --> pkg_agent pkg_time_context --> pkg_invariants pkg_time_context --> pkg_session + pkg_scripts --> pkg_app_boot + pkg_scripts --> pkg_invariants pkg_tasks --> pkg_agent pkg_tasks --> pkg_brand pkg_tasks --> pkg_invariants @@ -568,7 +570,9 @@ flowchart TD pkg_tui --> pkg_llm pkg_tui --> pkg_llm_retry pkg_tui --> pkg_session + pkg_tui --> pkg_session_persistence pkg_tui --> pkg_session_title + pkg_tui --> pkg_skill pkg_tui --> pkg_system_prompt pkg_tui --> pkg_token_meter pkg_tui --> pkg_tools @@ -665,13 +669,11 @@ flowchart TD | [`subagent-subprocess`](../packages/subagent/subagent-subprocess) | `subagent` | [`invariants`](../packages/support/invariants) | | [`acp-snapshot`](../packages/support/acp-snapshot) | `support` | [`invariants`](../packages/support/invariants) | | [`loader-smoke`](../packages/support/loader-smoke) | `support` | [`invariants`](../packages/support/invariants) | -| [`app-boot`](../packages/ui/app-boot) | `ui` | [`invariants`](../packages/support/invariants) | | [`code-runtime`](../packages/code-runtime/code-runtime) | `code-runtime` | [`invariants`](../packages/support/invariants) | | [`jsonrpc-demo`](../packages/examples/jsonrpc-demo) | `examples` | [`invariants`](../packages/support/invariants) | | [`llm`](../packages/llm/llm) | `llm` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants) | | [`code-runtime-worker`](../packages/code-runtime/code-runtime-worker) | `code-runtime` | [`code-runtime`](../packages/code-runtime/code-runtime), [`invariants`](../packages/support/invariants) | | [`helper`](../packages/sdk/helper) | `sdk` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants) | -| [`scripts`](../packages/sdk/scripts) | `sdk` | [`app-boot`](../packages/ui/app-boot), [`invariants`](../packages/support/invariants) | | [`telemetry`](../packages/sdk/telemetry) | `sdk` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths) | | [`llm-deepseek`](../packages/llm/llm-deepseek) | `llm` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`timeout`](../packages/util/timeout) | | [`llm-pi-ai`](../packages/llm/llm-pi-ai) | `llm` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`timeout`](../packages/util/timeout) | @@ -694,6 +696,7 @@ flowchart TD | [`session-persistence`](../packages/session-persistence/session-persistence) | `session-persistence` | [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | | [`session-title`](../packages/session-title/session-title) | `session-title` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`llm-replay`](../packages/support/llm-replay) | `support` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | +| [`app-boot`](../packages/ui/app-boot) | `ui` | [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths), [`system-prompt`](../packages/core/system-prompt) | | [`lsp-local`](../packages/lsp/lsp-local) | `lsp` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`lsp`](../packages/lsp/lsp), [`timeout`](../packages/util/timeout) | | [`sandbox-local`](../packages/sandbox/sandbox-local) | `sandbox` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox) | | [`sandbox-policy`](../packages/sandbox/sandbox-policy) | `sandbox` | [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session) | @@ -714,6 +717,7 @@ flowchart TD | [`user-approval`](../packages/ui/user-approval) | `ui` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt) | | [`user-interaction`](../packages/ui/user-interaction) | `ui` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) | | [`time-context`](../packages/context/time-context) | `context` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | +| [`scripts`](../packages/sdk/scripts) | `sdk` | [`app-boot`](../packages/ui/app-boot), [`invariants`](../packages/support/invariants) | | [`tasks`](../packages/tasks/tasks) | `tasks` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) | | [`workflow`](../packages/workflow/workflow) | `workflow` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`tools`](../packages/core/tools) | `core` | [`agent`](../packages/core/agent), [`code-runtime`](../packages/code-runtime/code-runtime), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`user-approval`](../packages/ui/user-approval) | @@ -751,7 +755,7 @@ flowchart TD | [`tool-subagent`](../packages/subagent/tool-subagent) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) | | [`hooks-claude`](../packages/hooks/hooks-claude) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | | [`jsonrpc`](../packages/ui/jsonrpc) | `ui` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`llm-deepseek`](../packages/llm/llm-deepseek), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | -| [`tui`](../packages/ui/tui) | `ui` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`commands`](../packages/ui/commands), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`session`](../packages/core/session), [`session-title`](../packages/session-title/session-title), [`system-prompt`](../packages/core/system-prompt), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools), [`user-interaction`](../packages/ui/user-interaction) | +| [`tui`](../packages/ui/tui) | `ui` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`commands`](../packages/ui/commands), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-title`](../packages/session-title/session-title), [`skill`](../packages/skill/skill), [`system-prompt`](../packages/core/system-prompt), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools), [`user-interaction`](../packages/ui/user-interaction) | | [`agent-spine-demo`](../packages/examples/agent-spine-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`paths`](../packages/util/paths), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-title`](../packages/session-title/session-title), [`skill`](../packages/skill/skill), [`skill-local`](../packages/skill/skill-local), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tool-bash`](../packages/bash/tool-bash), [`tool-goal`](../packages/goal/tool-goal), [`tool-skill`](../packages/skill/tool-skill), [`tool-tasks`](../packages/tasks/tool-tasks), [`tools`](../packages/core/tools), [`workspace-context`](../packages/context/workspace-context) | | [`tool-ralph`](../packages/workflow/tool-ralph) | `workflow` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | | [`workflow-workerthread`](../packages/workflow/workflow-workerthread) | `workflow` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | diff --git a/examples/tui-agent/composition.md b/examples/tui-agent/composition.md index 249d2f6aaf..2f1b3201c9 100644 --- a/examples/tui-agent/composition.md +++ b/examples/tui-agent/composition.md @@ -45,8 +45,6 @@ flowchart LR cfg --> plugin_tui_tool_workflow plugin_tui_tool_ralph["tool-ralph
@deepseek-ai/dsh-tool-ralph"] cfg --> plugin_tui_tool_ralph - plugin_tui_tool_todo["tool-todo
@deepseek-ai/dsh-tool-todo"] - cfg --> plugin_tui_tool_todo plugin_tui_fs_local["fs-local
@deepseek-ai/dsh-fs-local"] cfg --> plugin_tui_fs_local plugin_tui_fs_policy["fs-policy
@deepseek-ai/dsh-fs-policy"] @@ -80,7 +78,6 @@ flowchart LR | `workflow-workerthread` | `@deepseek-ai/dsh-workflow-workerthread` | | `tool-workflow` | `@deepseek-ai/dsh-tool-workflow` | | `tool-ralph` | `@deepseek-ai/dsh-tool-ralph` | -| `tool-todo` | `@deepseek-ai/dsh-tool-todo` | | `fs-local` | `@deepseek-ai/dsh-fs-local` | | `fs-policy` | `@deepseek-ai/dsh-fs-policy` | | `tool-fs` | `@deepseek-ai/dsh-tool-fs` | diff --git a/knip.json b/knip.json index 0d84184cb9..7a237f35a0 100644 --- a/knip.json +++ b/knip.json @@ -55,8 +55,7 @@ "project": ["src/**/*.ts", "tests/**/*.ts"] }, "packages/util/brand": { - "project": ["src/**/*.ts"], - "ignoreDependencies": ["cordis"] + "project": ["src/**/*.ts"] }, "packages/util/timeout": { "entry": ["tests/**/*.spec.ts"], From cf95986d5b0fe43761b2edec4ee352fffac8d054 Mon Sep 17 00:00:00 2001 From: Turtle Date: Wed, 22 Jul 2026 13:03:07 +0800 Subject: [PATCH 52/90] fix(examples): restore the tui-demo pre-boot TTY refusal and stabilize the PTY smoke The built-bin fail-loud test needs the TTY refusal before Loader boot (a compose-time throw is logged per-entry, not rethrown); the PTY driver drops COLORTERM so a developer's truecolor shell cannot flip the banner to the gradient path mid-assertion; the scripted fixture persists raw JSONL so the smoke's system-prompt inspection can read the log. --- .../tui-agent/tests/fixtures/tui-scripted.cordis.yml | 3 +++ examples/tui-agent/tests/tui-keyless-smoke.e2e.ts | 4 ++++ packages/examples/tui-demo/src/bin.ts | 12 ++++++++++-- 3 files changed, 17 insertions(+), 2 deletions(-) diff --git a/examples/tui-agent/tests/fixtures/tui-scripted.cordis.yml b/examples/tui-agent/tests/fixtures/tui-scripted.cordis.yml index 1c29beb8df..6bed7fa57e 100644 --- a/examples/tui-agent/tests/fixtures/tui-scripted.cordis.yml +++ b/examples/tui-agent/tests/fixtures/tui-scripted.cordis.yml @@ -21,6 +21,9 @@ provider: tui-scripted model: tui-scripted-model persistenceRoot: './.sessions' + # The smoke's log inspection reads plain `.jsonl`; keep the scripted + # fixture uncompressed like the other snapshot-facing configs. + persistenceCompression: none workspaceContext: maxBytes: 65536 welcome: 'scripted TUI ready.' diff --git a/examples/tui-agent/tests/tui-keyless-smoke.e2e.ts b/examples/tui-agent/tests/tui-keyless-smoke.e2e.ts index 8a54c9a4a3..15ccba479a 100644 --- a/examples/tui-agent/tests/tui-keyless-smoke.e2e.ts +++ b/examples/tui-agent/tests/tui-keyless-smoke.e2e.ts @@ -22,6 +22,10 @@ env.update({ "COLUMNS": "100", "LINES": "30", }) +# Deterministic banner: a developer shell's COLORTERM=truecolor would switch the +# banner to the per-letter gradient (one SGR per letter), breaking the literal +# DEEPSEEK assertions. The gradient path has its own unit and snapshot coverage. +env.pop("COLORTERM", None) if resume_session_id: env["RESUME_SESSION_ID"] = resume_session_id pid, fd = pty.fork() diff --git a/packages/examples/tui-demo/src/bin.ts b/packages/examples/tui-demo/src/bin.ts index 237e4391b5..5073e203df 100644 --- a/packages/examples/tui-demo/src/bin.ts +++ b/packages/examples/tui-demo/src/bin.ts @@ -11,8 +11,16 @@ import { boot, installFailLoud, loadEnv, resolveConfigPath } from '@deepseek-ai/ const NAME = 'dsh-tui-demo' /* v8 ignore start -- thin self-executing composition over the unit-tested - dsh-app-boot helpers; exercised end-to-end by the keyless Loader-path and - built-bin smokes */ + dsh-app-boot helpers; exercised end-to-end by the tui-agent PTY smoke and + the built-bin fail-loud smoke */ +// Refuse pipes BEFORE booting: a compose-time throw inside the Loader tree is +// logged per-entry rather than rethrown, so a piped launch would otherwise +// settle into an idle UI-less process instead of exiting nonzero. +if (!process.stdin.isTTY || !process.stdout.isTTY) { + process.stderr.write(`${NAME}: the TUI requires stdin and stdout to be interactive TTYs; ` + + 'use the one-shot dsh-cli-demo bin for pipes and automation\n') + process.exit(1) +} installFailLoud(NAME) loadEnv(NAME) await boot(NAME, resolveConfigPath(process.argv[2] ?? './cordis.yml', undefined)) From 478079cc98460effb8ced86b6882a260e5988c7e Mon Sep 17 00:00:00 2001 From: Turtle Date: Wed, 22 Jul 2026 13:13:04 +0800 Subject: [PATCH 53/90] test: close the merged branches' coverage gaps Cover the fs-provider load path without a cancellation signal, the app-level resumeCommand forwarding, and a command returning an error result. --- .../tests/workspace-context.spec.ts | 14 ++++++++++++++ packages/examples/tui-demo/tests/tui-agent.spec.ts | 8 +++++++- packages/ui/tui/tests/tui.spec.ts | 10 ++++++++++ 3 files changed, 31 insertions(+), 1 deletion(-) diff --git a/packages/context/workspace-context/tests/workspace-context.spec.ts b/packages/context/workspace-context/tests/workspace-context.spec.ts index c02f1f5a45..620f8f6eee 100644 --- a/packages/context/workspace-context/tests/workspace-context.spec.ts +++ b/packages/context/workspace-context/tests/workspace-context.spec.ts @@ -383,6 +383,20 @@ describe('workspace context instruction discovery', () => { } }) + it('loads through a FileSystem provider without a cancellation signal', async () => { + // Direct-library callers may omit `signal`; the fs-backed probe must pass + // no options object rather than `{ signal: undefined }`. + const ctx = new Context() + await ctx.plugin(RecordingFileSystem) + const fs = ctx.fs as RecordingFileSystem + fs.entries.set('/repo/.git', { type: 'directory' }) + fs.entries.set('/repo/AGENTS.md', { type: 'file', content: 'signalless rule' }) + const rendered = await loadBaselineInstructions({ cwd: '/repo', maxBytes: 65536 }, fs) + expect(rendered?.text).toContain('signalless rule') + expect(fs.signals).toHaveLength(0) + await ctx.fiber.dispose() + }) + it('skips a file that becomes unreadable after discovery without failing the request', async () => { const root = await tempRepo() const home = await tempRepo() diff --git a/packages/examples/tui-demo/tests/tui-agent.spec.ts b/packages/examples/tui-demo/tests/tui-agent.spec.ts index d1cb0808a7..433eb5c0b7 100644 --- a/packages/examples/tui-demo/tests/tui-agent.spec.ts +++ b/packages/examples/tui-demo/tests/tui-agent.spec.ts @@ -33,6 +33,7 @@ describe('dsh-tui-demo app', () => { persistenceRoot: '/tmp/tui-sessions', persistenceCompression: 'none', welcome: 'TUI ready', + resumeCommand: 'dsh --resume {session}', ui: { color: false, maxToolOutputLines: 3 }, skills: { tool: { catalogDescriptionMaxLength: 8 } }, toolBash: { enableRunInBackground: false }, @@ -52,7 +53,12 @@ describe('dsh-tui-demo app', () => { expect(calls[0]?.config).toBeUndefined() expect(calls[2]?.config).toEqual({ root: '/tmp/tui-sessions', compression: 'none' }) const tuiConfig = calls[4]?.config as { sessionId: string } - expect(tuiConfig).toMatchObject({ welcome: 'TUI ready', color: false, maxToolOutputLines: 3 }) + expect(tuiConfig).toMatchObject({ + welcome: 'TUI ready', + resumeCommand: 'dsh --resume {session}', + color: false, + maxToolOutputLines: 3, + }) expect(tuiConfig.sessionId).toMatch(/^main-session-[0-9a-f-]{36}$/) const spineConfig = calls[5]?.config as { readonly agents: Array> diff --git a/packages/ui/tui/tests/tui.spec.ts b/packages/ui/tui/tests/tui.spec.ts index af988bc78c..4598da4d9a 100644 --- a/packages/ui/tui/tests/tui.spec.ts +++ b/packages/ui/tui/tests/tui.spec.ts @@ -1099,6 +1099,11 @@ describe('pi-tui chat lifecycle and transcript', () => { description: 'Fail a plugin command', handler: () => { throw new Error('plugin command exploded') }, }) + result.ctx.commands.register({ + name: 'plugin-error', + description: 'Return an error result', + handler: () => ({ kind: 'error' as const, text: 'plugin error result' }), + }) result.terminal.send('/plugin-check value ') result.terminal.send('\r') @@ -1115,6 +1120,10 @@ describe('pi-tui chat lifecycle and transcript', () => { result.terminal.send('\r') await tick() expect(result.terminal.output).toContain('Command failed: plugin command exploded') + result.terminal.send('/plugin-error') + result.terminal.send('\r') + await tick() + expect(result.terminal.output).toContain('plugin error result') result.terminal.send('/help') result.terminal.send('\r') await tick() @@ -1124,6 +1133,7 @@ describe('pi-tui chat lifecycle and transcript', () => { await result.controller.dispose() expect(result.ctx.commands.list(result.agent).map(command => command.name)).toEqual([ 'plugin-check', + 'plugin-error', 'plugin-fail', ]) await result.ctx.fiber.dispose() From a58229187ff60020157a88c7f9503aa012db83bd Mon Sep 17 00:00:00 2001 From: Turtle Date: Wed, 22 Jul 2026 13:06:11 +0800 Subject: [PATCH 54/90] chore(llm-pi-ai): bump pi-ai to 0.81.1 for the gpt-5.6 model catalog pi-ai 0.80 restructured its entrypoints: the static catalog reads now live on /providers/all as getBuiltinModels/getBuiltinProviders (keyed by the catalog-only BuiltinProvider type, replacing KnownProvider at those call sites), and the global streamSimple moved to /compat. The config schema gains the new 'max' reasoning level. --- packages/llm/llm-pi-ai/package.json | 2 +- packages/llm/llm-pi-ai/src/adapter.ts | 12 ++--- packages/llm/llm-pi-ai/src/config.ts | 6 +-- packages/llm/llm-pi-ai/tests/adapter.spec.ts | 4 +- .../llm/llm-pi-ai/tests/sdk-options.spec.ts | 6 ++- pnpm-lock.yaml | 51 ++++++++++++++----- pnpm-workspace.yaml | 3 ++ 7 files changed, 55 insertions(+), 29 deletions(-) diff --git a/packages/llm/llm-pi-ai/package.json b/packages/llm/llm-pi-ai/package.json index 06b4cbeb3a..590e49f323 100644 --- a/packages/llm/llm-pi-ai/package.json +++ b/packages/llm/llm-pi-ai/package.json @@ -33,7 +33,7 @@ "cordis": "^4.0.0-rc.7" }, "dependencies": { - "@earendil-works/pi-ai": "^0.79.1", + "@earendil-works/pi-ai": "^0.81.1", "schemastery": "^3.18.0" }, "devDependencies": { diff --git a/packages/llm/llm-pi-ai/src/adapter.ts b/packages/llm/llm-pi-ai/src/adapter.ts index 7d91e9c351..ed0fb9fae4 100644 --- a/packages/llm/llm-pi-ai/src/adapter.ts +++ b/packages/llm/llm-pi-ai/src/adapter.ts @@ -4,13 +4,11 @@ * @module dsh-llm-pi-ai/adapter */ -import { - getModels, - streamSimple, -} from '@earendil-works/pi-ai' +import { streamSimple } from '@earendil-works/pi-ai/compat' +import { getBuiltinModels } from '@earendil-works/pi-ai/providers/all' +import type { BuiltinProvider } from '@earendil-works/pi-ai/providers/all' import type { Api, - KnownProvider, Model, SimpleStreamOptions, } from '@earendil-works/pi-ai' @@ -33,7 +31,7 @@ export interface PiAiAdapterOptions { * override, preserving the catalog's API/capability/compatibility metadata. */ function resolveModel(profile: PiAiProviderProfile, modelId: string): Model { - const model = getModels(profile.provider as KnownProvider).find(candidate => candidate.id === modelId) as Model | undefined + const model = getBuiltinModels(profile.provider as BuiltinProvider).find(candidate => candidate.id === modelId) as Model | undefined if (model === undefined) { throw new LlmError(`pi-ai provider "${profile.provider}" has no catalog model "${modelId}"`, 'UNKNOWN_MODEL') } @@ -82,7 +80,7 @@ export class PiAiAdapter extends LlmAdapter { if (profile === undefined) { return Promise.reject(new LlmError(`pi-ai adapter does not own provider "${provider}"`, 'NO_ADAPTER')) } - return Promise.resolve(getModels(profile.provider as KnownProvider).map(model => ({ + return Promise.resolve(getBuiltinModels(profile.provider as BuiltinProvider).map(model => ({ provider, id: model.id, name: model.name, diff --git a/packages/llm/llm-pi-ai/src/config.ts b/packages/llm/llm-pi-ai/src/config.ts index d5b5d70867..199463aaf6 100644 --- a/packages/llm/llm-pi-ai/src/config.ts +++ b/packages/llm/llm-pi-ai/src/config.ts @@ -4,7 +4,7 @@ * @module dsh-llm-pi-ai/config */ -import { getProviders } from '@earendil-works/pi-ai' +import { getBuiltinProviders } from '@earendil-works/pi-ai/providers/all' import type { CacheRetention, ThinkingBudgets, ThinkingLevel, Transport } from '@earendil-works/pi-ai' import z from 'schemastery' import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' @@ -62,7 +62,7 @@ const profile = z.object({ apiKey: z.string(), baseURL: z.string(), headers: z.dict(z.string()), - reasoning: z.union(['minimal', 'low', 'medium', 'high', 'xhigh']), + reasoning: z.union(['minimal', 'low', 'medium', 'high', 'xhigh', 'max']), thinkingBudgets, cacheRetention: z.union(['none', 'short', 'long']), transport: z.union(['sse', 'websocket', 'websocket-cached', 'auto']), @@ -84,7 +84,7 @@ export const Config: z = z.object({ */ export function resolveProfiles(profiles: readonly PiAiProviderProfile[]): ResolvedPiAiProviderProfile[] { if (profiles.length === 0) throw new Error('llm-pi-ai: providers must contain at least one profile') - const supported = new Set(getProviders()) + const supported = new Set(getBuiltinProviders()) const seen = new Set() return profiles.map((source) => { const legacy = source as PiAiProviderProfile & { diff --git a/packages/llm/llm-pi-ai/tests/adapter.spec.ts b/packages/llm/llm-pi-ai/tests/adapter.spec.ts index 7b28cdc1b5..f37b07f624 100644 --- a/packages/llm/llm-pi-ai/tests/adapter.spec.ts +++ b/packages/llm/llm-pi-ai/tests/adapter.spec.ts @@ -5,8 +5,8 @@ import { Context } from 'cordis' import LlmService, { CONTEXT_WINDOW_EXCEEDED_CODE, LlmError, userAgent } from '@deepseek-ai/dsh-llm' import * as LlmPiAi from '@deepseek-ai/dsh-llm-pi-ai' import { PiAiAdapter } from '@deepseek-ai/dsh-llm-pi-ai' -import { getModels } from '@earendil-works/pi-ai' import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' +import { getBuiltinModels } from '@earendil-works/pi-ai/providers/all' import { resolveProfiles } from '../src/config.ts' import { assemble } from './assemble.ts' @@ -244,7 +244,7 @@ describe('PiAiAdapter provider routing', () => { }) it('uses the resolved catalog context window for usage-based overflow detection', async () => { - const model = getModels('deepseek').find(candidate => candidate.id === 'deepseek-v4-flash') + const model = getBuiltinModels('deepseek').find(candidate => candidate.id === 'deepseek-v4-flash') if (model === undefined) throw new Error('deepseek-v4-flash missing from pi-ai test catalog') const events = [ '{"choices":[{"delta":{"role":"assistant","content":""},"index":0,"finish_reason":null}]}', diff --git a/packages/llm/llm-pi-ai/tests/sdk-options.spec.ts b/packages/llm/llm-pi-ai/tests/sdk-options.spec.ts index e85c44c110..d96297c242 100644 --- a/packages/llm/llm-pi-ai/tests/sdk-options.spec.ts +++ b/packages/llm/llm-pi-ai/tests/sdk-options.spec.ts @@ -2,8 +2,10 @@ import { afterEach, describe, expect, it, vi } from 'vitest' const streamSimple = vi.hoisted(() => vi.fn()) -vi.mock('@earendil-works/pi-ai', async (importOriginal) => { - const actual = await importOriginal() +// The 0.81 SDK moved `streamSimple` to the compat entry; the adapter imports it +// from there, so the mock must target the same specifier. +vi.mock('@earendil-works/pi-ai/compat', async (importOriginal) => { + const actual = await importOriginal() return { ...actual, streamSimple } }) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 95373d7396..c0cec09d7e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -88,7 +88,7 @@ importers: version: 6.1.1(typescript@6.0.3)(vite@8.0.16(@types/node@22.20.0)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) vitest: specifier: ^4.1.8 - version: 4.1.8(@types/node@22.20.0)(@vitest/coverage-v8@4.1.8)(jsdom@29.1.1)(vite@8.0.16(@types/node@22.20.0)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) + version: 4.1.8(@opentelemetry/api@1.9.0)(@types/node@22.20.0)(@vitest/coverage-v8@4.1.8)(jsdom@29.1.1)(vite@8.0.16(@types/node@22.20.0)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) apps/cli: dependencies: @@ -1496,8 +1496,8 @@ importers: packages/llm/llm-pi-ai: dependencies: '@earendil-works/pi-ai': - specifier: ^0.79.1 - version: 0.79.3(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.21.0)(zod@4.4.3) + specifier: ^0.81.1 + version: 0.81.1(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.21.0)(zod@4.4.3) schemastery: specifier: ^3.18.0 version: 3.18.0 @@ -2443,7 +2443,7 @@ importers: version: link:../loader-smoke vitest: specifier: ^4.1.8 - version: 4.1.8(@types/node@25.9.3)(@vitest/coverage-v8@4.1.8)(jsdom@29.1.1)(vite@8.0.16(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) + version: 4.1.8(@opentelemetry/api@1.9.0)(@types/node@25.9.3)(@vitest/coverage-v8@4.1.8)(jsdom@29.1.1)(vite@8.0.16(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) devDependencies: '@deepseek-ai/dsh-invariants': specifier: workspace:^ @@ -4040,8 +4040,8 @@ packages: search-insights: optional: true - '@earendil-works/pi-ai@0.79.3': - resolution: {integrity: sha512-lMSput/haP5uZAGbXhS5rAYd3GB7GYdJkoAUxg3VFummBeqGqGqllaTWrbHFN12kVGyVfWHhdySNXkiqVh65Iw==} + '@earendil-works/pi-ai@0.81.1': + resolution: {integrity: sha512-hzHE7Z8l5mgJk+ke67Lge0rwS2+wbKJrFKl9o5M1R1rh33+cCT7D1AHz1OAtX5wFs90E1/BTGhyJRTUHaMxGvQ==} engines: {node: '>=22.19.0'} hasBin: true @@ -4542,8 +4542,13 @@ packages: '@mermaid-js/parser@1.2.0': resolution: {integrity: sha512-oYPyv8A4As1yH5Bx+04iQEQxXuIQDe0GKCNSRgao6z8AM9jixXIfP0vsppRLvGf+nKIOb9/LdpWA4YuJiVvESA==} - '@mistralai/mistralai@2.2.1': - resolution: {integrity: sha512-uKU8CZmL2RzYKmplsU01hii4p3pe4HqJefpWNRWXm1Tcm0Sm4xXfwSLIy4k7ZCPlbETCGcp69E7hZs+WOJ5itQ==} + '@mistralai/mistralai@2.2.6': + resolution: {integrity: sha512-W8pX7zHxjJvMIpw8JMxeJEleapXX0Q9NPszdNzqkM3MIEoIGPObdodujj+WHteXEvGfaP/AMwlNyRfEzSY6dQQ==} + peerDependencies: + '@opentelemetry/api': ^1.9.0 + peerDependenciesMeta: + '@opentelemetry/api': + optional: true '@modelcontextprotocol/sdk@1.29.0': resolution: {integrity: sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==} @@ -4572,6 +4577,14 @@ packages: '@nodable/entities@2.2.0': resolution: {integrity: sha512-9uGyhaQavEUMC8AIddIjau4NsnsXhou+j5sBAGojCM1oxmQpVKTWR/9JxABD6UAv12vpIms55fPZKFQEhG6uBg==} + '@opentelemetry/api@1.9.0': + resolution: {integrity: sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==} + engines: {node: '>=8.0.0'} + + '@opentelemetry/semantic-conventions@1.43.0': + resolution: {integrity: sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg==} + engines: {node: '>=14'} + '@oxc-parser/binding-android-arm-eabi@0.133.0': resolution: {integrity: sha512-l/44caGse+VpnY9gx0yvvc5QnnG3yG1FO3KZgYvNL1GZrfK86zIwAOgGEVlxDyRymzrU/KHiblPFpevKOmJmUA==} engines: {node: ^20.19.0 || >=22.12.0} @@ -8543,12 +8556,13 @@ snapshots: transitivePeerDependencies: - '@algolia/client-search' - '@earendil-works/pi-ai@0.79.3(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.21.0)(zod@4.4.3)': + '@earendil-works/pi-ai@0.81.1(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.21.0)(zod@4.4.3)': dependencies: '@anthropic-ai/sdk': 0.91.1(zod@4.4.3) '@aws-sdk/client-bedrock-runtime': 3.1048.0 '@google/genai': 1.52.0(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3)) - '@mistralai/mistralai': 2.2.1 + '@mistralai/mistralai': 2.2.6(@opentelemetry/api@1.9.0) + '@opentelemetry/api': 1.9.0 '@smithy/node-http-handler': 4.7.3 http-proxy-agent: 7.0.2 https-proxy-agent: 7.0.6 @@ -8907,11 +8921,14 @@ snapshots: dependencies: '@chevrotain/types': 11.1.2 - '@mistralai/mistralai@2.2.1': + '@mistralai/mistralai@2.2.6(@opentelemetry/api@1.9.0)': dependencies: + '@opentelemetry/semantic-conventions': 1.43.0 ws: 8.21.0 zod: 4.4.3 zod-to-json-schema: 3.25.2(zod@4.4.3) + optionalDependencies: + '@opentelemetry/api': 1.9.0 transitivePeerDependencies: - bufferutil - utf-8-validate @@ -8976,6 +8993,10 @@ snapshots: '@nodable/entities@2.2.0': {} + '@opentelemetry/api@1.9.0': {} + + '@opentelemetry/semantic-conventions@1.43.0': {} + '@oxc-parser/binding-android-arm-eabi@0.133.0': optional: true @@ -9724,7 +9745,7 @@ snapshots: obug: 2.1.3 std-env: 4.1.0 tinyrainbow: 3.1.0 - vitest: 4.1.8(@types/node@22.20.0)(@vitest/coverage-v8@4.1.8)(jsdom@29.1.1)(vite@8.0.16(@types/node@22.20.0)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) + vitest: 4.1.8(@opentelemetry/api@1.9.0)(@types/node@22.20.0)(@vitest/coverage-v8@4.1.8)(jsdom@29.1.1)(vite@8.0.16(@types/node@22.20.0)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) '@vitest/expect@4.1.8': dependencies: @@ -12457,7 +12478,7 @@ snapshots: - typescript - universal-cookie - vitest@4.1.8(@types/node@22.20.0)(@vitest/coverage-v8@4.1.8)(jsdom@29.1.1)(vite@8.0.16(@types/node@22.20.0)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)): + vitest@4.1.8(@opentelemetry/api@1.9.0)(@types/node@22.20.0)(@vitest/coverage-v8@4.1.8)(jsdom@29.1.1)(vite@8.0.16(@types/node@22.20.0)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)): dependencies: '@vitest/expect': 4.1.8 '@vitest/mocker': 4.1.8(vite@8.0.16(@types/node@22.20.0)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) @@ -12480,13 +12501,14 @@ snapshots: vite: 8.0.16(@types/node@22.20.0)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0) why-is-node-running: 2.3.0 optionalDependencies: + '@opentelemetry/api': 1.9.0 '@types/node': 22.20.0 '@vitest/coverage-v8': 4.1.8(vitest@4.1.8) jsdom: 29.1.1 transitivePeerDependencies: - msw - vitest@4.1.8(@types/node@25.9.3)(@vitest/coverage-v8@4.1.8)(jsdom@29.1.1)(vite@8.0.16(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)): + vitest@4.1.8(@opentelemetry/api@1.9.0)(@types/node@25.9.3)(@vitest/coverage-v8@4.1.8)(jsdom@29.1.1)(vite@8.0.16(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)): dependencies: '@vitest/expect': 4.1.8 '@vitest/mocker': 4.1.8(vite@8.0.16(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) @@ -12509,6 +12531,7 @@ snapshots: vite: 8.0.16(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0) why-is-node-running: 2.3.0 optionalDependencies: + '@opentelemetry/api': 1.9.0 '@types/node': 25.9.3 '@vitest/coverage-v8': 4.1.8(vitest@4.1.8) jsdom: 29.1.1 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 869cd50f0f..533950e6ab 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -51,3 +51,6 @@ minimumReleaseAgeExclude: # during the same-day sync that updates package manifests and the lockfile. - '@cordisjs/plugin-loader@1.0.0-rc.5' - cordis@4.0.0-rc.7 + # Fresh pi-ai releases carry the model catalog updates that are the whole + # point of bumping it; waiting out the release age would defeat that. + - '@earendil-works/pi-ai@0.81.1' From e65ea9bdbc2b58fc6b2640c087ee215db935ee93 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Wed, 22 Jul 2026 14:42:43 +0800 Subject: [PATCH 55/90] ci: benchmark consolidated hosted gates --- .github/workflows/ci.yml | 148 +++++++++++++++++++++++++++++++++++++++ package.json | 2 + scripts/run-gates.ts | 32 ++++++++- 3 files changed, 181 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f601bc1a2c..a41eb480d5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,6 +14,7 @@ on: options: - serial-reference - larger-runner-benchmark + - consolidated-runner-benchmark - optimized-larger-runners concurrency: @@ -713,6 +714,153 @@ jobs: if: matrix.platform == 'windows' run: pnpm run docs:build + # Manual comparison of the intended low-fanout topology. Linux runs the + # complete unsharded primary aggregate with bounded in-runner parallelism; + # Windows runs both blocking build surfaces concurrently through run-gates. + consolidated-runner-benchmark: + if: github.event_name == 'workflow_dispatch' && inputs.suite == 'consolidated-runner-benchmark' + name: consolidated / ${{ matrix.platform }} / ${{ matrix.cores }} cores + runs-on: ${{ matrix.runner }} + timeout-minutes: 15 + strategy: + fail-fast: false + max-parallel: 12 + matrix: + include: + - platform: linux + cores: '4' + runner: dsh-ubuntu-24-04-4core + workers: '4' + - platform: linux + cores: '8' + runner: dsh-ubuntu-24-04-8core + workers: '8' + - platform: linux + cores: '16' + runner: dsh-ubuntu-24-04-16core + workers: '16' + - platform: linux + cores: '32' + runner: dsh-ubuntu-24-04-32core + workers: '32' + - platform: linux + cores: '64' + runner: dsh-ubuntu-24-04-64core + workers: '32' + - platform: linux + cores: '96' + runner: dsh-ubuntu-24-04-96core + workers: '32' + - platform: windows + cores: '4' + runner: dsh-windows-2025-4core + workers: '2' + - platform: windows + cores: '8' + runner: dsh-windows-2025-8core + workers: '2' + - platform: windows + cores: '16' + runner: dsh-windows-2025-16core + workers: '2' + - platform: windows + cores: '32' + runner: dsh-windows-2025-32core + workers: '2' + - platform: windows + cores: '64' + runner: dsh-windows-2025-64core + workers: '2' + - platform: windows + cores: '96' + runner: dsh-windows-2025-96core + workers: '2' + steps: + - uses: actions/checkout@v6 + + - uses: actions/setup-node@v6 + with: + node-version: ${{ env.PRIMARY_NODE_VERSION }} + + - name: Report runner capacity + run: >- + node -e "const os = require('node:os'); + console.log(JSON.stringify({ arch: process.arch, cpus: os.cpus().length, + memoryGiB: Math.round(os.totalmem() / 2 ** 30) }))" + + - name: Enable corepack (pnpm) + run: corepack enable + + - name: Resolve pnpm store path (Linux) + if: matrix.platform == 'linux' + id: pnpm-store-linux + run: echo "path=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT" + + - name: Resolve pnpm store path (Windows) + if: matrix.platform == 'windows' + id: pnpm-store-windows + shell: pwsh + run: '"path=$(pnpm store path --silent)" >> $env:GITHUB_OUTPUT' + + - uses: actions/cache@v4 + with: + path: ${{ steps.pnpm-store-linux.outputs.path || steps.pnpm-store-windows.outputs.path }} + key: ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm-${{ hashFiles('pnpm-lock.yaml') }} + restore-keys: | + ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm- + + - uses: actions/cache@v4 + if: matrix.platform == 'linux' + with: + path: .cache/eslint + key: ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-eslint-full-${{ hashFiles('pnpm-lock.yaml', 'eslint.config.mjs', 'tsconfig.json', 'packages/*/*/tsconfig.json', 'examples/*/tsconfig.json') }} + restore-keys: | + ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-eslint-full- + + - name: Install and prepare Linux + if: matrix.platform == 'linux' + run: | + pnpm install --frozen-lockfile & + install_pid=$! + ( + if ! sudo apt-get install -yq --no-install-recommends bubblewrap; then + echo "initial bubblewrap install failed; refreshing APT indexes and retrying" + sudo apt-get update -q + sudo apt-get install -yq --no-install-recommends bubblewrap + fi + sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 \ + || echo "apparmor userns knob absent — the functional probe decides" + ) & + sandbox_pid=$! + install_status=0 + wait "$install_pid" || install_status=$? + sandbox_status=0 + wait "$sandbox_pid" || sandbox_status=$? + if (( install_status != 0 )); then exit "$install_status"; fi + exit "$sandbox_status" + + - name: Install (immutable) + if: matrix.platform == 'windows' + shell: pwsh + run: pnpm install --frozen-lockfile + + - name: Run complete unsharded primary Node CI concurrently + if: matrix.platform == 'linux' + env: + DSH_COVERAGE_MAX_WORKERS: ${{ matrix.workers }} + DSH_ESLINT_CACHE: '1' + DSH_GATE_CONCURRENCY: ${{ matrix.workers }} + DSH_PUBLINT_CONCURRENCY: ${{ matrix.workers }} + DSH_SNAPSHOT_MAX_CONCURRENCY: ${{ matrix.workers }} + run: pnpm run check:ci + + - name: Run blocking Windows builds concurrently + if: matrix.platform == 'windows' + shell: pwsh + env: + DSH_GATE_CONCURRENCY: ${{ matrix.workers }} + run: pnpm run check:ci:windows-blocking + # Single stable required check for branch protection: require "all checks # passed" instead of enumerating matrix legs whose names change as lanes and # node versions evolve. Every blocking job in THIS workflow must be listed in diff --git a/package.json b/package.json index 9dca83dc6a..b758974ef7 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,8 @@ "check:ci:coverage": "tsx scripts/run-gates.ts ci-coverage", "check:ci:snapshot": "tsx scripts/run-gates.ts ci-snapshot", "check:ci:artifacts": "tsx scripts/run-gates.ts ci-artifacts", + "check:ci:windows-blocking": "tsx scripts/run-gates.ts ci-windows-blocking", + "check:ci:windows-observational": "tsx scripts/run-gates.ts ci-windows-observational", "check:node-compat": "tsx scripts/run-gates.ts node-compat", "check:pre-push": "tsx scripts/run-gates.ts pre-push", "knip": "knip --treat-config-hints-as-errors", diff --git a/scripts/run-gates.ts b/scripts/run-gates.ts index 504dc26ba7..b117c62d11 100644 --- a/scripts/run-gates.ts +++ b/scripts/run-gates.ts @@ -20,6 +20,8 @@ type Mode = | 'ci-coverage' | 'ci-snapshot' | 'ci-artifacts' + | 'ci-windows-blocking' + | 'ci-windows-observational' | 'node-compat' | 'pre-push' | 'doc-sync' @@ -90,13 +92,15 @@ function parseMode(raw: string | undefined): Mode { case 'ci-coverage': case 'ci-snapshot': case 'ci-artifacts': + case 'ci-windows-blocking': + case 'ci-windows-observational': case 'node-compat': case 'pre-push': case 'doc-sync': return raw default: throw new Error( - `run-gates: expected mode ci-primary | ci-static | ci-lint | ci-coverage | ci-snapshot | ci-artifacts | node-compat | pre-push | doc-sync, got ${JSON.stringify(raw)}.`, + `run-gates: expected mode ci-primary | ci-static | ci-lint | ci-coverage | ci-snapshot | ci-artifacts | ci-windows-blocking | ci-windows-observational | node-compat | pre-push | doc-sync, got ${JSON.stringify(raw)}.`, ) } } @@ -179,6 +183,10 @@ function gatesForMode(selected: Mode): Gate[] { : [pnpmScript('build', 'build'), snapshotGate()] case 'ci-artifacts': return ciArtifactGates() + case 'ci-windows-blocking': + return ciWindowsBlockingGates() + case 'ci-windows-observational': + return ciWindowsObservationalGates() case 'node-compat': return [ ...flagEnabled('DSH_NODE_COMPAT_SKIP_TYPECHECK') ? [] : [pnpmScript('typecheck', 'typecheck')], @@ -275,6 +283,28 @@ function ciArtifactGates(): Gate[] { return [...metadataGates, builtBinSmokeGate()] } +function ciWindowsBlockingGates(): Gate[] { + return [ + pnpmScript('windows-build', 'build', { label: 'build' }), + pnpmScript('windows-site', 'docs:build', { label: 'production site' }), + ] +} + +function ciWindowsObservationalGates(): Gate[] { + return [ + ...ciStaticGates(), + lintGate(), + pnpmScript('duplication', 'duplication'), + pnpmScript('publint', 'publint', { needs: ['build'] }), + pnpmScript('node-next-types', 'verify-node-next-types', { + label: 'node-next types', + needs: ['build'], + }), + builtPackageInvariantsGate(['build']), + builtBinSmokeGate(), + ] +} + function lintGate(eslintTargets: readonly string[] = ['.']): Gate { if (process.env.DSH_ESLINT_CACHE === '1') { return pnpmExec('lint', [ From f1f35ccaef3291f8eae0bbcb2c6a057ee6ed72e0 Mon Sep 17 00:00:00 2001 From: Turtle Date: Wed, 22 Jul 2026 14:48:34 +0800 Subject: [PATCH 56/90] test(e2e): align keyless expectations with the shipped config and scope keys The acp escalation smoke advertises the shipped deepseek-v4-pro; the workspace-context e2e asserts the per-candidate scope key. The PTY harness drops COLORTERM (deterministic banner) and gains configArgs/prepare/inspect for the dsh CLI scenarios. --- examples/acp-agent/tests/escalation.e2e.ts | 2 +- examples/tui-agent/tests/pty-harness.ts | 32 +++++++++++++++---- .../tests/workspace-context.e2e.ts | 3 +- .../tests/workspace-context.spec.ts | 16 +--------- 4 files changed, 30 insertions(+), 23 deletions(-) diff --git a/examples/acp-agent/tests/escalation.e2e.ts b/examples/acp-agent/tests/escalation.e2e.ts index 59d6d75caa..ae53252743 100644 --- a/examples/acp-agent/tests/escalation.e2e.ts +++ b/examples/acp-agent/tests/escalation.e2e.ts @@ -112,7 +112,7 @@ describe('default sandbox composition keyless smoke (real cordis.yml via the Loa // ONE select advertises, current from the configured default preset. const created = await client.newSession({ cwd: workdir, mcpServers: [] }) const advertised = created.configOptions ?? [] - const modelValue = JSON.stringify(['deepseek', 'deepseek-v4-flash']) + const modelValue = JSON.stringify(['deepseek', 'deepseek-v4-pro']) expect(advertised.map(option => [option.id, 'currentValue' in option ? option.currentValue : undefined])) .toEqual([['model', modelValue], ['permission', 'workspace-write']]) // A switch responds with the COMPLETE refreshed state (the spec contract), diff --git a/examples/tui-agent/tests/pty-harness.ts b/examples/tui-agent/tests/pty-harness.ts index 116f7cc9a1..257198b7d2 100644 --- a/examples/tui-agent/tests/pty-harness.ts +++ b/examples/tui-agent/tests/pty-harness.ts @@ -10,6 +10,10 @@ node, launch_args_json, launch_env_json, cwd, actions_json, expected_exit, timeo env = os.environ.copy() env.update(json.loads(launch_env_json)) env.update({"COLUMNS": "100", "LINES": "30"}) +# Deterministic banner: a developer shell's COLORTERM=truecolor would switch the +# banner to the per-letter gradient (one SGR per letter), breaking literal +# DEEPSEEK assertions. The gradient path has its own unit and snapshot coverage. +env.pop("COLORTERM", None) actions = json.loads(actions_json) pid, fd = pty.fork() if pid == 0: @@ -63,12 +67,19 @@ export interface TuiPtySmokeOptions { readonly label: string readonly tempDirPrefix: string readonly binScript: string - readonly configPath: string + /** Config argument; ignored when {@link configArgs} is set. */ + readonly configPath?: string + /** Full argument vector for the bin (e.g. `[]` for a bin with a built-in default config). */ + readonly configArgs?: readonly string[] readonly tsconfigPath: string readonly actions?: readonly TuiPtyAction[] readonly env?: Readonly readonly expectedExitCode?: number readonly timeoutMs?: number + /** Seed the isolated workspace (`cwd`, with `$DSH_HOME` at `.dsh` and the agents home at `.agents`) before launch. */ + readonly prepare?: (cwd: string) => Promise + /** Inspect the workspace after a passing run, before the temp dir is removed. */ + readonly inspect?: (cwd: string) => Promise } function definedEnv(env: NodeJS.ProcessEnv): Record { @@ -135,6 +146,9 @@ async function runWindowsPtySmoke( env: definedEnv({ ...process.env, ...launch.env, + // Match the POSIX driver: no COLORTERM, so the banner never takes the + // truecolor gradient path under a developer's shell. + COLORTERM: undefined, COLUMNS: '100', LINES: '30', }), @@ -175,9 +189,13 @@ export async function runTuiPtySmoke(options: TuiPtySmokeOptions): Promise block.type === 'text').map(block => block.text).join('') diff --git a/packages/context/workspace-context/tests/workspace-context.spec.ts b/packages/context/workspace-context/tests/workspace-context.spec.ts index 620f8f6eee..f902bdaef0 100644 --- a/packages/context/workspace-context/tests/workspace-context.spec.ts +++ b/packages/context/workspace-context/tests/workspace-context.spec.ts @@ -1,5 +1,5 @@ import { chmod, mkdtemp, mkdir, rm, stat, symlink, utimes, writeFile } from 'node:fs/promises' -import { dirname, join } from 'node:path' +import { dirname, join, resolve } from 'node:path' import { tmpdir } from 'node:os' import { describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' @@ -383,20 +383,6 @@ describe('workspace context instruction discovery', () => { } }) - it('loads through a FileSystem provider without a cancellation signal', async () => { - // Direct-library callers may omit `signal`; the fs-backed probe must pass - // no options object rather than `{ signal: undefined }`. - const ctx = new Context() - await ctx.plugin(RecordingFileSystem) - const fs = ctx.fs as RecordingFileSystem - fs.entries.set('/repo/.git', { type: 'directory' }) - fs.entries.set('/repo/AGENTS.md', { type: 'file', content: 'signalless rule' }) - const rendered = await loadBaselineInstructions({ cwd: '/repo', maxBytes: 65536 }, fs) - expect(rendered?.text).toContain('signalless rule') - expect(fs.signals).toHaveLength(0) - await ctx.fiber.dispose() - }) - it('skips a file that becomes unreadable after discovery without failing the request', async () => { const root = await tempRepo() const home = await tempRepo() From 03348aa382c5775fbe92eadc0854079e2d277121 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Wed, 22 Jul 2026 14:53:46 +0800 Subject: [PATCH 57/90] ci: parallelize aggregate lint --- .github/workflows/ci.yml | 1 + scripts/run-gates.ts | 19 +++++++++++++++++++ 2 files changed, 20 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a41eb480d5..719e2f8e9b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -849,6 +849,7 @@ jobs: env: DSH_COVERAGE_MAX_WORKERS: ${{ matrix.workers }} DSH_ESLINT_CACHE: '1' + DSH_ESLINT_CONCURRENCY: ${{ matrix.workers }} DSH_GATE_CONCURRENCY: ${{ matrix.workers }} DSH_PUBLINT_CONCURRENCY: ${{ matrix.workers }} DSH_SNAPSHOT_MAX_CONCURRENCY: ${{ matrix.workers }} diff --git a/scripts/run-gates.ts b/scripts/run-gates.ts index b117c62d11..98326d6db9 100644 --- a/scripts/run-gates.ts +++ b/scripts/run-gates.ts @@ -306,10 +306,12 @@ function ciWindowsObservationalGates(): Gate[] { } function lintGate(eslintTargets: readonly string[] = ['.']): Gate { + const concurrencyArgs = eslintConcurrencyArgs() if (process.env.DSH_ESLINT_CACHE === '1') { return pnpmExec('lint', [ 'eslint', ...eslintTargets, + ...concurrencyArgs, '--cache', '--cache-location', '.cache/eslint/', @@ -320,11 +322,28 @@ function lintGate(eslintTargets: readonly string[] = ['.']): Gate { env: { NODE_OPTIONS: nodeOptions('--max-old-space-size=8192') }, }) } + if (concurrencyArgs.length > 0) { + return pnpmExec('lint', ['eslint', ...eslintTargets, ...concurrencyArgs], { + label: 'lint', + env: { NODE_OPTIONS: nodeOptions('--max-old-space-size=8192') }, + }) + } return pnpmScript('lint', 'lint', { env: { NODE_OPTIONS: nodeOptions('--max-old-space-size=8192') }, }) } +function eslintConcurrencyArgs(): string[] { + const raw = process.env.DSH_ESLINT_CONCURRENCY + if (raw === undefined || raw === '') return [] + if (raw === 'auto') return ['--concurrency=auto'] + const parsed = Number.parseInt(raw, 10) + if (!Number.isSafeInteger(parsed) || parsed < 1 || String(parsed) !== raw) { + throw new Error(`run-gates: DSH_ESLINT_CONCURRENCY must be a positive integer or auto, got ${JSON.stringify(raw)}.`) + } + return [`--concurrency=${raw}`] +} + function coverageGate(): Gate { const shard = process.env.DSH_COVERAGE_SHARD return pnpmExec('coverage', [ From 2a9f2485940a2363d550a8713f7133cd26100555 Mon Sep 17 00:00:00 2001 From: Turtle Date: Wed, 22 Jul 2026 15:01:54 +0800 Subject: [PATCH 58/90] refactor(tui): drop the TUI-local auto-title; titles come from the session-title service MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Master's log-backed session-title capability already titles sessions durably (deterministic fallback in the spine, optional model providers). Remove the TUI's own autoTitle generation — the latch, prompt, cap, and llm stream call — and keep the terminal rename: the TUI folds the logged title on mount and sets '' on every accepted session/title event. The tui-agent example and the scripted PTY fixture mount session-title-first-message-llm so titles stay model-made; the scripted adapter's tool-less branch now answers that provider's auxiliary request. See .agents/notes/implemented/simplification/2026-07-22-tui-titles-from-session-title-service.md --- .../2026-07-21-tui-auto-pane-title.i18n.yaml | 4 +- .../feature/2026-07-21-tui-auto-pane-title.md | 2 + .../2026-07-21-tui-auto-pane-title.zh.md | 2 + ...-07-21-tui-auto-title-default-on.i18n.yaml | 4 +- .../2026-07-21-tui-auto-title-default-on.md | 2 + ...2026-07-21-tui-auto-title-default-on.zh.md | 2 + ...itles-from-session-title-service.i18n.yaml | 6 + ...2-tui-titles-from-session-title-service.md | 25 ++ ...ui-titles-from-session-title-service.zh.md | 25 ++ docs/config-catalog.md | 44 +++- examples/tui-agent/composition.md | 3 + examples/tui-agent/cordis.yml | 15 +- .../tests/fixtures/tui-scripted-llm.ts | 5 +- .../tests/fixtures/tui-scripted.cordis.yml | 13 +- .../tui-agent/tests/tui-keyless-smoke.e2e.ts | 8 +- examples/tui-agent/tests/tui.snapshot.ts | 3 - packages/ui/tui/README.md | 1 - packages/ui/tui/src/index.ts | 83 +------ packages/ui/tui/tests/tui.spec.ts | 224 +----------------- 19 files changed, 144 insertions(+), 327 deletions(-) create mode 100644 .agents/notes/implemented/simplification/2026-07-22-tui-titles-from-session-title-service.i18n.yaml create mode 100644 .agents/notes/implemented/simplification/2026-07-22-tui-titles-from-session-title-service.md create mode 100644 .agents/notes/implemented/simplification/2026-07-22-tui-titles-from-session-title-service.zh.md diff --git a/.agents/notes/implemented/feature/2026-07-21-tui-auto-pane-title.i18n.yaml b/.agents/notes/implemented/feature/2026-07-21-tui-auto-pane-title.i18n.yaml index 25e8aa06b6..737a9da6ca 100644 --- a/.agents/notes/implemented/feature/2026-07-21-tui-auto-pane-title.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-21-tui-auto-pane-title.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 -2026-07-21-tui-auto-pane-title.md: 354602cac465b20157af563ccc32acf82cc54e73 -2026-07-21-tui-auto-pane-title.zh.md: 97178d723b92b243f3bb1b58aa86b681eb9e8573 +2026-07-21-tui-auto-pane-title.md: 069fd33a8874d9ad3d4472dd13f5130b2df65f08 +2026-07-21-tui-auto-pane-title.zh.md: 580f36b2563e21231a22cab3f0c1689c6f3e8d9d diff --git a/.agents/notes/implemented/feature/2026-07-21-tui-auto-pane-title.md b/.agents/notes/implemented/feature/2026-07-21-tui-auto-pane-title.md index 354602cac4..069fd33a88 100644 --- a/.agents/notes/implemented/feature/2026-07-21-tui-auto-pane-title.md +++ b/.agents/notes/implemented/feature/2026-07-21-tui-auto-pane-title.md @@ -4,6 +4,8 @@ Status: implemented English | [中文](2026-07-21-tui-auto-pane-title.zh.md) +> **Superseded** by the [session-title consolidation Agent Note](../simplification/2026-07-22-tui-titles-from-session-title-service.md): the TUI-local `autoTitle` generation is removed; titles come from the log-backed session-title service, and the terminal rename consumes `session/title` events. + > **Superseded** for the default and the resume behavior by the [auto-title default-on Agent Note](2026-07-21-tui-auto-title-default-on.md): `autoTitle` now defaults on, and a resumed session re-derives its title from the stored first message instead of keeping the static one. The OSC 0 path, the one-shot latch, the model-summary shape, the fire-and-forget call, and every failure fallback below stand. ## Problem diff --git a/.agents/notes/implemented/feature/2026-07-21-tui-auto-pane-title.zh.md b/.agents/notes/implemented/feature/2026-07-21-tui-auto-pane-title.zh.md index 97178d723b..580f36b256 100644 --- a/.agents/notes/implemented/feature/2026-07-21-tui-auto-pane-title.zh.md +++ b/.agents/notes/implemented/feature/2026-07-21-tui-auto-pane-title.zh.md @@ -4,6 +4,8 @@ Status: implemented [English](2026-07-21-tui-auto-pane-title.md) | 中文 +> **已被取代**:见[标题归一 Agent Note](../simplification/2026-07-22-tui-titles-from-session-title-service.md)。TUI 本地的 `autoTitle` 生成已移除;标题来自日志承载的 session-title 服务,终端重命名消费 `session/title` 事件。 + > **已被取代**(就默认值与恢复行为而言),见[自动标题默认开启 Agent Note](2026-07-21-tui-auto-title-default-on.md):`autoTitle` 现默认开启,恢复会话会从已存储的首条消息重新推导标题,而非保留静态标题。下文的 OSC 0 路径、一次性门闩、模型概括形态、发出后不等待其返回的调用,以及每一条失败兜底,均仍然成立。 ## Problem diff --git a/.agents/notes/implemented/feature/2026-07-21-tui-auto-title-default-on.i18n.yaml b/.agents/notes/implemented/feature/2026-07-21-tui-auto-title-default-on.i18n.yaml index 2137e9d594..830ca3e2e0 100644 --- a/.agents/notes/implemented/feature/2026-07-21-tui-auto-title-default-on.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-21-tui-auto-title-default-on.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 -2026-07-21-tui-auto-title-default-on.md: 1121cf39c79b01dc5e45ff31fccbe82ccee6d101 -2026-07-21-tui-auto-title-default-on.zh.md: 5569c9d71b7481bcf74e39320a52f1f47e7161ed +2026-07-21-tui-auto-title-default-on.md: 35809e1ef6bade3e09c34b17608eff5f8fb5bd22 +2026-07-21-tui-auto-title-default-on.zh.md: aa20cfde1359605f2ac5a8f0427f4518c611ecd1 diff --git a/.agents/notes/implemented/feature/2026-07-21-tui-auto-title-default-on.md b/.agents/notes/implemented/feature/2026-07-21-tui-auto-title-default-on.md index 1121cf39c7..35809e1ef6 100644 --- a/.agents/notes/implemented/feature/2026-07-21-tui-auto-title-default-on.md +++ b/.agents/notes/implemented/feature/2026-07-21-tui-auto-title-default-on.md @@ -4,6 +4,8 @@ Status: implemented English | [中文](2026-07-21-tui-auto-title-default-on.zh.md) +> **Superseded** by the [session-title consolidation Agent Note](../simplification/2026-07-22-tui-titles-from-session-title-service.md): the TUI-local `autoTitle` generation is removed; titles come from the log-backed session-title service, and the terminal rename consumes `session/title` events. + ## Problem The [auto-title Agent Note](2026-07-21-tui-auto-pane-title.md) shipped `autoTitle` off by default and, on a resumed session, kept the static title because the first `user/message` was already logged. In use both choices defeated the feature's purpose. A per-session descriptive pane title is what makes one tmux pane or terminal tab distinguishable from the next; leaving it off by default means the product ships an inert feature that almost no user turns on, and skipping re-derivation on resume means a resumed session — exactly the long-lived session most worth labelling — falls back to the shared static string. The user asked for a descriptive per-session name to be the normal experience. diff --git a/.agents/notes/implemented/feature/2026-07-21-tui-auto-title-default-on.zh.md b/.agents/notes/implemented/feature/2026-07-21-tui-auto-title-default-on.zh.md index 5569c9d71b..aa20cfde13 100644 --- a/.agents/notes/implemented/feature/2026-07-21-tui-auto-title-default-on.zh.md +++ b/.agents/notes/implemented/feature/2026-07-21-tui-auto-title-default-on.zh.md @@ -4,6 +4,8 @@ Status: implemented [English](2026-07-21-tui-auto-title-default-on.md) | 中文 +> **已被取代**:见[标题归一 Agent Note](../simplification/2026-07-22-tui-titles-from-session-title-service.md)。TUI 本地的 `autoTitle` 生成已移除;标题来自日志承载的 session-title 服务,终端重命名消费 `session/title` 事件。 + ## Problem [自动标题 Agent Note](2026-07-21-tui-auto-pane-title.md) 交付时 `autoTitle` 默认关闭,并且在恢复会话中因首条 `user/message` 已入日志而保留静态标题。实际使用中这两个选择都违背了该功能的初衷。让一个 tmux 窗格或终端标签页区别于下一个的,正是每会话各异的描述性窗格标题;默认关闭意味着产品交付了一个几乎无人开启的惰性功能,而恢复时不重新推导,则意味着恢复会话——恰恰是最值得标记的长命会话——退回到共用的静态字符串。用户要求把每会话的描述性名称做成常态体验。 diff --git a/.agents/notes/implemented/simplification/2026-07-22-tui-titles-from-session-title-service.i18n.yaml b/.agents/notes/implemented/simplification/2026-07-22-tui-titles-from-session-title-service.i18n.yaml new file mode 100644 index 0000000000..72e2341385 --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-07-22-tui-titles-from-session-title-service.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 +2026-07-22-tui-titles-from-session-title-service.md: b54b99647230255cf241415f94aa21b2630c44cd +2026-07-22-tui-titles-from-session-title-service.zh.md: 67cc3332f0694887d5af0d71997d140b74669f46 diff --git a/.agents/notes/implemented/simplification/2026-07-22-tui-titles-from-session-title-service.md b/.agents/notes/implemented/simplification/2026-07-22-tui-titles-from-session-title-service.md new file mode 100644 index 0000000000..b54b996472 --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-07-22-tui-titles-from-session-title-service.md @@ -0,0 +1,25 @@ +# Agent Note: TUI titles come from the session-title service + +Status: implemented + +English | [中文](2026-07-22-tui-titles-from-session-title-service.zh.md) + +## Problem + +Two model-title implementations coexisted after the tui-staging line merged onto master. The TUI carried its own `autoTitle` feature: a fire-and-forget `ctx.llm.stream` call after the first user message that set the terminal window title via OSC 0, with a one-shot latch, its own prompt, its own 40-character cap, and its own resume re-derivation ([auto-title Agent Note](../feature/2026-07-21-tui-auto-pane-title.md), [default-on Agent Note](../feature/2026-07-21-tui-auto-title-default-on.md)). Master had meanwhile landed [log-backed session titles](../feature/2026-07-21-log-backed-session-titles.md): a `sessionTitle` capability whose accepted revisions are durable `session/title` events, with a deterministic fallback and optional model providers. The TUI already consumed `session/title` for its header subtitle and window title, so a session could be titled twice by different strategies, and the TUI's process-local title was invisible to every other consumer (ACP, resume listings, forks). + +## Decision + +The TUI-local generation is removed; the session-title service is the one title source. `TuiConfig.autoTitle`, the latch, the abort controller, the title prompt, and `titleLine` are gone from `dsh-tui`. The terminal rename stays: the TUI folds the latest logged title on mount (`foldSessionTitle`), renders it as the banner subtitle, and sets the terminal window title to `` on every accepted `session/title` event — including resumed sessions, whose titles now replay from the log instead of being re-generated. + +Model-made titles are a composition choice: `examples/tui-agent/cordis.yml` (and the scripted PTY fixture) mount `@deepseek-ai/dsh-session-title-first-message-llm`, which inherits the main request's route and replaces the spine's deterministic fallback with a short model summary. Deployments without the provider keep the fallback title from `dsh-agent-spine-demo`'s bundled `SessionTitleService`. + +## Alternatives considered + +**Keep both, letting the logged title win.** This was the first merge resolution: auto-title owned the whole window title until a logged `session/title` arrived in suffix form. It preserved behavior but doubled the model calls on every fresh session and left the TUI's title unobservable in the log, violating model-visible ⟺ logged in spirit and splitting the title contract across two owners. + +**Port auto-title's prompt and cap into the service as a third provider.** The first-message-llm provider already exists with the same cadence, a reviewed prompt contract, durable request records, and supersession fencing; a second near-identical provider would be pure duplication. + +## Consequences + +One title pipeline: durable, replayable, visible to every consumer, and fenced against stale completions by the service. The TUI sheds ~90 lines and its `llm`-streaming path. The cost is that a title now requires the provider plugin in the composition for model quality — a leaf choice, not a TUI default — and the terminal title changes shape from the bare model summary to the suffixed ` — <product>` form the log-backed path always used. The superseded auto-title Agent Notes carry pointers here. diff --git a/.agents/notes/implemented/simplification/2026-07-22-tui-titles-from-session-title-service.zh.md b/.agents/notes/implemented/simplification/2026-07-22-tui-titles-from-session-title-service.zh.md new file mode 100644 index 0000000000..67cc3332f0 --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-07-22-tui-titles-from-session-title-service.zh.md @@ -0,0 +1,25 @@ +# Agent Note: TUI 标题来自 session-title 服务 + +Status: implemented + +[English](2026-07-22-tui-titles-from-session-title-service.md) | 中文 + +## 问题 + +tui-staging 分支合入 master 后,两套模型标题实现并存。TUI 自带 `autoTitle` 特性:在首条用户消息后发起一次 fire-and-forget 的 `ctx.llm.stream` 调用,通过 OSC 0 设置终端窗口标题,带有一次性闩锁、自己的提示词、自己的 40 字符截断和自己的恢复重推导([auto-title Agent Note](../feature/2026-07-21-tui-auto-pane-title.md)、[default-on Agent Note](../feature/2026-07-21-tui-auto-title-default-on.md))。而 master 已落地[日志承载的会话标题](../feature/2026-07-21-log-backed-session-titles.md):一个 `sessionTitle` 能力,其被接受的修订是持久的 `session/title` 事件,带确定性回退和可选的模型 provider。TUI 已经消费 `session/title` 作为横幅副标题和窗口标题,于是一个会话可能被两种策略各标题一次,且 TUI 的进程本地标题对其他所有消费者(ACP、恢复列表、fork)不可见。 + +## 决策 + +移除 TUI 本地生成;session-title 服务是唯一的标题来源。`TuiConfig.autoTitle`、闩锁、abort controller、标题提示词和 `titleLine` 全部从 `dsh-tui` 删除。终端重命名保留:TUI 在挂载时折叠最新的已记录标题(`foldSessionTitle`),将其渲染为横幅副标题,并在每个被接受的 `session/title` 事件上把终端窗口标题设为 `<会话标题> — <配置标题>` —— 包括恢复的会话,其标题现在从日志回放而不是重新生成。 + +模型生成的标题是组合选择:`examples/tui-agent/cordis.yml`(以及脚本化 PTY fixture)挂载 `@deepseek-ai/dsh-session-title-first-message-llm`,它继承主请求的确切路由,用简短的模型摘要替换 spine 的确定性回退。未挂载该 provider 的部署保留 `dsh-agent-spine-demo` 内置 `SessionTitleService` 的回退标题。 + +## 备选方案 + +**两者并存,已记录标题胜出。** 这是第一版合并决议:auto-title 独占整个窗口标题,直到已记录的 `session/title` 以后缀形式到达。它保留了行为,但每个新会话产生双倍模型调用,且 TUI 的标题在日志中不可观察,实质上违反 model-visible ⟺ logged,并把标题契约拆给两个所有者。 + +**把 auto-title 的提示词和截断移植为服务的第三个 provider。** first-message-llm provider 已经存在,节奏相同,且有经过评审的提示词契约、持久的请求记录和替换围栏;再造一个近乎相同的 provider 纯属重复。 + +## 影响 + +标题管线归一:持久、可回放、对所有消费者可见,并由服务对过期完成设防。TUI 削减约 90 行及其 `llm` 流式路径。代价是模型质量的标题现在需要在组合中挂载 provider 插件 —— 这是叶配置选择,不是 TUI 默认值 —— 且终端标题形状从裸模型摘要变为日志路径一贯使用的 `<标题> — <产品>` 后缀形式。被取代的 auto-title Agent Note 携带指向本文的指针。 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 496c254ef3..db5254d0c7 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1460,10 +1460,18 @@ Requires: `agents` · `commands` · `userInteraction` · `tools` · `llm` · `sy ```ts config-catalog /** Serializable plugin configuration. */ export interface Config extends TuiConfig { - /** Header subtitle. Defaults to `ready.`. */ + /** Banner subtitle line. When absent, the banner has no subtitle and sweeps in on start. */ welcome?: string /** Exact shared agent/session identity driven by this terminal. Defaults to `main`. */ sessionId?: string + /** + * Shell command template shown for resuming this session: printed on exit and + * listed by `/resume`, with every `{session}` occurrence replaced by the live + * session id. Absent disables both surfaces. Deployments set it only when a + * persistence backend makes the session resumable (e.g. + * `RESUME_SESSION_ID={session} dsh`). + */ + resumeCommand?: string } /** Presentation settings for the pi-tui terminal mode. */ @@ -1488,12 +1496,19 @@ export interface TuiConfig { showHardwareCursor?: boolean /** Apply the built-in ANSI color palette. */ color?: boolean - /** Terminal window title while the UI is mounted. */ + /** + * Paint the startup banner's product name in the DeepSeek brand gradient + * using 24-bit truecolor. Requires {@link TuiConfig.color}; falls back to the + * flat accent color when either is off. Unset auto-detects `COLORTERM` at the + * process boundary, so most deployments leave it unset. + */ + truecolor?: boolean + /** Terminal window title while the UI is mounted; a logged session title prefixes it. */ title?: string } ``` -Source: [`packages/ui/tui/src/index.ts:129`](../packages/ui/tui/src/index.ts) +Source: [`packages/ui/tui/src/index.ts:145`](../packages/ui/tui/src/index.ts) ## `@deepseek-ai/dsh-tui-demo` @@ -1520,8 +1535,15 @@ export interface Config { persistenceRoot?: string /** JSONL artifact encoding; defaults to checksummed Zstandard frames. */ persistenceCompression?: JsonlCompression - /** TUI subtitle rendered on start. Defaults to `ready.`. */ + /** TUI transcript's optional first line; absent renders nothing on start. */ welcome?: string + /** + * Shell command template the TUI prints on exit and lists under `/resume`, + * with `{session}` replaced by the live session id (forwarded to the front + * door). Set it to a command that resumes via this app's env var, e.g. + * `RESUME_SESSION_ID={session} dsh`. + */ + resumeCommand?: string /** Full-screen TUI presentation settings. */ ui?: uiTui.TuiConfig /** Skill registry, local-provider, and model-facing consumer config. */ @@ -1541,7 +1563,7 @@ export interface Config { Depends on: [`agentCore`](../packages/examples/agent-spine-demo/src/index.ts) · [`JsonlCompression`](../packages/session-persistence/session-persistence-jsonl/src/index.ts) · [`ToolsConfig`](#deepseek-aidsh-tools) · [`uiTui`](../packages/ui/tui/src/index.ts) -Source: [`packages/examples/tui-demo/src/index.ts:33`](../packages/examples/tui-demo/src/index.ts) +Source: [`packages/examples/tui-demo/src/index.ts:32`](../packages/examples/tui-demo/src/index.ts) ## `@deepseek-ai/dsh-user-approval` @@ -1726,12 +1748,20 @@ export interface Config { maxBytes: number /** Maximum UTF-8 bytes read from one instruction file; larger files are ignored. */ maxSourceBytes?: number - /** Ordered same-directory project candidates; the first existing regular file wins in each scope. */ + /** + * Ordered same-directory project candidates; every existing file loads, with + * per-directory trimmed-content duplicates collapsed to the earliest candidate. + */ instructionFileCandidates?: string[] + /** + * Ordered same-directory local-overlay candidates loaded after the base files + * under the same per-directory trimmed-content dedup; empty disables the overlay. + */ + localInstructionFileCandidates?: string[] } ``` -Source: [`packages/context/workspace-context/src/config.ts:16`](../packages/context/workspace-context/src/config.ts) +Source: [`packages/context/workspace-context/src/config.ts:17`](../packages/context/workspace-context/src/config.ts) ## Loadable plugins with no config diff --git a/examples/tui-agent/composition.md b/examples/tui-agent/composition.md index 2f1b3201c9..b380e7610c 100644 --- a/examples/tui-agent/composition.md +++ b/examples/tui-agent/composition.md @@ -23,6 +23,8 @@ flowchart LR bundle_agent_core --> spine_sessions["ctx.sessions"] bundle_agent_core --> spine_tools["ctx.tools + tool-bash"] bundle_agent_core --> spine_loop["ctx.agents + ctx.agentLoop"] + plugin_tui_session_title_llm["session-title-llm<br/>@deepseek-ai/dsh-session-title-first-message-llm"] + cfg --> plugin_tui_session_title_llm plugin_tui_token_meter["token-meter<br/>@deepseek-ai/dsh-token-meter"] cfg --> plugin_tui_token_meter plugin_tui_tool_result_prune["tool-result-prune<br/>@deepseek-ai/dsh-compact-tool-result-prune"] @@ -67,6 +69,7 @@ flowchart LR | `llm-deepseek` | `@deepseek-ai/dsh-llm-deepseek` | | `bash` | `@deepseek-ai/dsh-bash-local` | | `tui-agent` | `@deepseek-ai/dsh-tui-demo` | +| `session-title-llm` | `@deepseek-ai/dsh-session-title-first-message-llm` | | `token-meter` | `@deepseek-ai/dsh-token-meter` | | `tool-result-prune` | `@deepseek-ai/dsh-compact-tool-result-prune` | | `compact-basic` | `@deepseek-ai/dsh-compact-basic` | diff --git a/examples/tui-agent/cordis.yml b/examples/tui-agent/cordis.yml index efbf67307d..e7a1c8877a 100644 --- a/examples/tui-agent/cordis.yml +++ b/examples/tui-agent/cordis.yml @@ -45,8 +45,6 @@ ui: showReasoning: true maxToolOutputLines: 6 - # Rename the terminal after the first message with a short model-made title. - autoTitle: true # Keep the persona to identity and behavior; tool plugins own tool guidance. # The loop resolves {{model}} from this agent's configuration. persona: | @@ -55,6 +53,19 @@ Verify your work by running the code or tests. Keep answers brief and factual. +# Model-made session titles on the first-message cadence: replaces the spine's +# deterministic fallback title with a short model summary. The TUI renders the +# logged `session/title` as the banner subtitle and the terminal window title. +# Omitting provider/model inherits the main request's exact route. +- id: session-title-llm + name: '@deepseek-ai/dsh-session-title-first-message-llm' + config: + targetWords: 5 + targetCjkCharacters: 10 + maxInputBytes: 4096 + maxOutputTokens: 64 + timeoutMs: 60000 + # Replay-aware request pressure with one service-wide context window. - id: token-meter name: '@deepseek-ai/dsh-token-meter' diff --git a/examples/tui-agent/tests/fixtures/tui-scripted-llm.ts b/examples/tui-agent/tests/fixtures/tui-scripted-llm.ts index 72253b52aa..b90f69f47e 100644 --- a/examples/tui-agent/tests/fixtures/tui-scripted-llm.ts +++ b/examples/tui-agent/tests/fixtures/tui-scripted-llm.ts @@ -38,8 +38,9 @@ class ScriptedTuiAdapter extends LlmAdapter { } override async * stream(options: GenerateOptions): AsyncIterable<StreamChunk> { - // The TUI's auto-title request carries no tool schemas, unlike every agent - // turn; answer it with a fixed title so the PTY test can assert the OSC set. + // The session-title provider's auxiliary request carries no tool schemas, + // unlike every agent turn; answer it with a fixed title so the PTY test can + // assert the logged title reaches the terminal window title. if ((options.tools?.length ?? 0) === 0) { for (const chunk of textChunks(TITLE_TEXT)) yield chunk return diff --git a/examples/tui-agent/tests/fixtures/tui-scripted.cordis.yml b/examples/tui-agent/tests/fixtures/tui-scripted.cordis.yml index 6bed7fa57e..cfc7a8e344 100644 --- a/examples/tui-agent/tests/fixtures/tui-scripted.cordis.yml +++ b/examples/tui-agent/tests/fixtures/tui-scripted.cordis.yml @@ -30,4 +30,15 @@ persona: 'Scripted model {{model}}.' ui: showReasoning: true - autoTitle: true + +# Model-made session titles, as in the shipped cordis.yml: the scripted adapter +# answers the tool-less title request with a fixed string so the PTY test can +# assert the logged title reaches the terminal window title. +- id: session-title-llm + name: '@deepseek-ai/dsh-session-title-first-message-llm' + config: + targetWords: 5 + targetCjkCharacters: 10 + maxInputBytes: 4096 + maxOutputTokens: 64 + timeoutMs: 10000 diff --git a/examples/tui-agent/tests/tui-keyless-smoke.e2e.ts b/examples/tui-agent/tests/tui-keyless-smoke.e2e.ts index 15ccba479a..c069e72ae9 100644 --- a/examples/tui-agent/tests/tui-keyless-smoke.e2e.ts +++ b/examples/tui-agent/tests/tui-keyless-smoke.e2e.ts @@ -256,9 +256,11 @@ describe('tui-agent keyless smoke (real Loader tree in a PTY)', () => { expect(output).toContain('How should the scripted run proceed?') expect(output).toContain('Safe') expect(output).toContain('Decision received. Scripted TUI run complete.') - // Auto-title: the first user message drives a tool-less title call that the - // scripted adapter answers, and the TUI sets it via OSC 0. - expect(output).toContain('\u001B]0;scripted session title\u0007') + // Session title: the first user message drives the first-message-llm + // provider's tool-less title call; the scripted adapter answers it, the + // accepted title lands in the log, and the TUI renders the terminal window + // title as `<session title> — <configured title>` via OSC 0. + expect(output).toContain('\u001B]0;scripted session title — DeepSeek Harness\u0007') expect(output).toContain('\u001B[?2004l') }, LOADER_SMOKE_TEST_TIMEOUT_MS) diff --git a/examples/tui-agent/tests/tui.snapshot.ts b/examples/tui-agent/tests/tui.snapshot.ts index 41d8d8160e..e0272d8a71 100644 --- a/examples/tui-agent/tests/tui.snapshot.ts +++ b/examples/tui-agent/tests/tui.snapshot.ts @@ -268,9 +268,6 @@ async function runScenario(scenario: Scenario): Promise<ScenarioResult> { color: true, showReasoning: true, title: 'DSH TUI snapshot', - // Off so replay stays deterministic: an auto-title request is not among the - // recorded turns. Auto-title's PTY coverage lives in the keyless smoke. - autoTitle: false, welcome: `Recorded replay: ${scenario.name}`, maxToolOutputLines: 8, }, { diff --git a/packages/ui/tui/README.md b/packages/ui/tui/README.md index d11712367f..bc6521d058 100644 --- a/packages/ui/tui/README.md +++ b/packages/ui/tui/README.md @@ -42,7 +42,6 @@ When `resumeCommand` is set and a `sessionPersistence` backend is mounted, exiti | `color` | `true` | Apply the built-in ANSI palette (see [Color](#color)) | | `title` | `DeepSeek Harness` | Product suffix for the terminal window title. | | `resumeCommand` | — | Shell command template for the exit hint and `/resume`, with `{session}` expanded to the session id; unset disables both. Needs a `sessionPersistence` backend | -| `autoTitle` | `true` | Replace `title` with a short model-made title derived from the session's first user message; a resumed session re-derives it from that stored message on mount (needs an `llm` service and an agent provider/model) | ```yaml - id: terminal diff --git a/packages/ui/tui/src/index.ts b/packages/ui/tui/src/index.ts index f56803adec..77857c0d71 100644 --- a/packages/ui/tui/src/index.ts +++ b/packages/ui/tui/src/index.ts @@ -46,10 +46,9 @@ import { import type {} from '@deepseek-ai/dsh-agent-loop' import type {} from '@deepseek-ai/dsh-token-meter' import type {} from '@deepseek-ai/dsh-commands' -import { assertNever, BlockAssembler, errorChain } from '@deepseek-ai/dsh-llm' +import { assertNever, errorChain } from '@deepseek-ai/dsh-llm' import type { ContentBlock, - GenerateOptions, LlmModelInfo, StreamChunk, TokenUsage, @@ -108,15 +107,8 @@ export interface TuiConfig { * process boundary, so most deployments leave it unset. */ truecolor?: boolean - /** Terminal window title while the UI is mounted. */ + /** Terminal window title while the UI is mounted; a logged session title prefixes it. */ title?: string - /** - * Replace {@link TuiConfig.title} with a short model-generated title derived - * from the session's first user message; a resumed session re-derives it from - * that stored message on mount. No-op without an `llm` service or an agent - * provider/model. On by default. - */ - autoTitle?: boolean } const showReasoningSchema = z.boolean().default(true) @@ -132,7 +124,6 @@ const colorSchema = z.boolean().default(true) // No default: an unset value auto-detects truecolor from COLORTERM in `apply`. const truecolorSchema = z.boolean() const titleSchema = z.string().default('DeepSeek Harness') -const autoTitleSchema = z.boolean().default(true) /** Schemastery schema for presentation settings embedded by app bundles. */ export const TuiConfigSchema: z<TuiConfig> = z.object({ @@ -148,7 +139,6 @@ export const TuiConfigSchema: z<TuiConfig> = z.object({ color: colorSchema, truecolor: truecolorSchema, title: titleSchema, - autoTitle: autoTitleSchema, }) /** Serializable plugin configuration. */ @@ -183,7 +173,6 @@ export const Config: z<Config> = z.object({ color: colorSchema, truecolor: truecolorSchema, title: titleSchema, - autoTitle: autoTitleSchema, }) /** Fully defaulted TUI presentation settings. */ @@ -200,7 +189,6 @@ export interface ResolvedTuiConfig { color: boolean truecolor: boolean title: string - autoTitle: boolean } /** Runtime boundary used by the interactive TUI. */ @@ -239,7 +227,6 @@ export function resolveTuiConfig(config: TuiConfig | undefined): ResolvedTuiConf color: config?.color ?? true, truecolor: config?.truecolor ?? false, title: config?.title ?? 'DeepSeek Harness', - autoTitle: config?.autoTitle ?? true, } } @@ -426,24 +413,6 @@ function contentText(content: readonly ContentBlock[]): string { return parts.join('') } -/** Longest auto-generated title kept before the tail is elided; fits common tmux/tab widths. */ -const AUTO_TITLE_MAX_LENGTH = 40 - -/** Task framing for the auto-title model call; written from the model's view, not the UI's. */ -const AUTO_TITLE_SYSTEM_PROMPT = [ - "Summarize the user's request as a short title of 2 to 5 lowercase words.", - 'Use no punctuation or quotation marks. Reply with only the title.', -].join('\n') - -/** First non-empty line of the model's reply, trimmed and capped for a terminal title. */ -function titleLine(text: string): string { - const line = text.split('\n').map(part => part.trim()).find(part => part.length > 0) ?? '' - return line.length > AUTO_TITLE_MAX_LENGTH ? `${line.slice(0, AUTO_TITLE_MAX_LENGTH - 1)}…` : line -} - -/** Auto-title is best-effort: a stream error or shutdown abort leaves the current title unchanged. */ -const ignoreTitleFailure = (): void => {} - function textBlocks(content: readonly ContentBlock[], type: 'text' | 'reasoning'): string { return content .filter((block): block is Extract<ContentBlock, { type: typeof type }> => block.type === type) @@ -1351,14 +1320,6 @@ export function createTuiChat( const skills = ctx.get('skills') const cwd = agent.session.header.cwd ?? process.cwd() const skillAbort = new AbortController() - // Auto-title replaces the static title with a short model-generated title - // derived from the session's first user message. A resumed session re-derives - // it from that stored message on mount (see below); a fresh session derives it - // when the first message arrives. It is already settled — keeping the static - // title — only when the feature is off. The abort cancels an in-flight title - // stream at shutdown. - const titleAbort = new AbortController() - let titleSettled = !resolved.autoTitle const tokens = sessionTokens(agent.session) const toolCards = new Map<string, ToolCardComponent>() const allToolCards = new Set<ToolCardComponent>() @@ -1534,44 +1495,6 @@ export function createTuiChat( }) } - // Fire-and-forget a title request. The prompt is the trimmed first-message - // text; an empty one is skipped without consuming the one-shot slot. The `llm` - // service is optional, so a deployment without it (or without an agent - // provider/model) silently keeps the static title. - const generateTitle = (prompt: string): void => { - if (titleSettled || prompt.length === 0) return - titleSettled = true - const llm = ctx.get('llm') - const { provider, model } = agent.options - if (llm === undefined || !provider || !model) return - const options: GenerateOptions = { - provider, - model, - system: AUTO_TITLE_SYSTEM_PROMPT, - messages: [{ role: 'user', content: [{ type: 'text', text: prompt }] }], - sessionId: agent.session.id, - signal: titleAbort.signal, - } - const applyTitle = async (): Promise<void> => { - const assembler = new BlockAssembler() - for await (const chunk of llm.stream(options)) assembler.push(chunk) - const title = titleLine(contentText(assembler.message().content)) - // Unlike a logged `session/title` (which suffixes the product title), the - // process-local auto-title owns the whole terminal title. A logged title - // arriving later still wins through `updateTerminalTitle`. - if (!disposed && title.length > 0) runtime.terminal.setTitle(displayText(title)) - } - void applyTitle().catch(ignoreTitleFailure) - } - - // Resume: derive the title from the session's already-logged first user - // message. A fresh session has none here and titles from the live message via - // the session-event listener instead. - const firstUserMessage = agent.session.events.find( - (event): event is Extract<SessionEvent, { type: 'user/message' }> => event.type === 'user/message', - ) - if (firstUserMessage !== undefined) generateTitle(contentText(firstUserMessage.data.content).trim()) - const clearStatus = (): void => { if (runningStatus !== undefined) { clearInterval(runningStatus.timer) @@ -1929,7 +1852,6 @@ export function createTuiChat( shuttingDown ??= (async () => { disposed = true contextResolution = undefined - titleAbort.abort() clearStatus() modelOverlay?.hide() modelOverlay = undefined @@ -2318,7 +2240,6 @@ export function createTuiChat( if (session !== agent.session) return recordEventUsage(tokens, event) advanceTurnPhase(event) - if (event.type === 'user/message') generateTitle(contentText(event.data.content).trim()) if (event.type === 'steering/message' && pendingSteering > 0) { // A queued steering message reached the model as it drained; drop it from // the badge. Clamped because loop-authored steering (e.g. continuation diff --git a/packages/ui/tui/tests/tui.spec.ts b/packages/ui/tui/tests/tui.spec.ts index 4598da4d9a..10be7f99bf 100644 --- a/packages/ui/tui/tests/tui.spec.ts +++ b/packages/ui/tui/tests/tui.spec.ts @@ -4,7 +4,7 @@ import { describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' import type { Terminal } from '@earendil-works/pi-tui' import AgentRegistry, { agentEvents, assembleContextFor, type Agent } from '@deepseek-ai/dsh-agent' -import LlmService, { LlmAdapter, type GenerateOptions, type LlmCallConfig, type StreamChunk } from '@deepseek-ai/dsh-llm' +import { type LlmCallConfig } from '@deepseek-ai/dsh-llm' import CommandService, { type CommandInvocation } from '@deepseek-ai/dsh-commands' import SessionStore, { SessionId, type SessionHeader } from '@deepseek-ai/dsh-session' import SkillService, { type SkillDefinition, type SkillSummary } from '@deepseek-ai/dsh-skill' @@ -148,7 +148,6 @@ describe('TUI config', () => { color: true, truecolor: false, title: 'DeepSeek Harness', - autoTitle: true, }) expect(resolveTuiConfig({ showReasoning: false, @@ -163,7 +162,6 @@ describe('TUI config', () => { color: false, truecolor: true, title: 'DSH', - autoTitle: false, })).toEqual({ showReasoning: false, maxToolOutputLines: 2, @@ -177,7 +175,6 @@ describe('TUI config', () => { color: false, truecolor: true, title: 'DSH', - autoTitle: false, }) }) }) @@ -2148,222 +2145,3 @@ describe('banner sweep reveal', () => { expect(result.terminal.output.length).toBe(settled) }) }) - -/** Streams one fixed reply (or throws) so a test can drive the auto-title call. */ -class TitleAdapter extends LlmAdapter { - lastOptions: GenerateOptions | undefined - calls = 0 - constructor(private readonly reply: string | Error) { - super() - } - - async *stream(options: GenerateOptions): AsyncIterable<StreamChunk> { - this.calls += 1 - this.lastOptions = options - if (this.reply instanceof Error) throw this.reply - yield { type: 'block-start', index: 0, blockType: 'text' } - yield { type: 'text-delta', index: 0, text: this.reply } - yield { type: 'block-end', index: 0, block: { type: 'text', text: this.reply } } - yield { type: 'finish', reason: { kind: 'stop' } } - } -} - -/** Provide the `llm` service (with `adapter` on provider `mock`) plus the tools stub the TUI injects. */ -function withLlm(adapter: LlmAdapter): (ctx: Context) => Promise<void> { - return async (ctx: Context) => { - await ctx.plugin(LlmService) - ctx.llm.registerAdapter(['mock'], adapter) - ctx.provide('tools', { get: () => undefined } as never) - } -} - -describe('TUI auto-title', () => { - const agentOptions: Agent['options'] = { provider: 'mock', model: 'mock-model' } - - it('replaces the title with a model-generated title after the first user message', async () => { - const adapter = new TitleAdapter('fix the login redirect') - const result = await setup({ config: { autoTitle: true }, agentOptions, configureContext: withLlm(adapter) }) - appendUser(result.session, 'the login page throws a 500 on submit, please investigate') - await tick() - expect(result.terminal.title).toBe('fix the login redirect') - // The request carries the task framing, the user's first message, and no tools. - expect(adapter.lastOptions?.provider).toBe('mock') - expect(adapter.lastOptions?.model).toBe('mock-model') - expect(adapter.lastOptions?.system).toContain('short title') - expect(adapter.lastOptions?.tools).toBeUndefined() - expect(adapter.lastOptions?.messages).toEqual([ - { role: 'user', content: [{ type: 'text', text: 'the login page throws a 500 on submit, please investigate' }] }, - ]) - await dispose(result) - }) - - it('requests a title only once, even after later user messages', async () => { - const adapter = new TitleAdapter('the settled title') - const result = await setup({ config: { autoTitle: true }, agentOptions, configureContext: withLlm(adapter) }) - appendUser(result.session, 'the first request that earns the title') - await tick() - expect(result.terminal.title).toBe('the settled title') - appendUser(result.session, 'a second request that must not re-title') - await tick() - expect(adapter.calls).toBe(1) - expect(result.terminal.title).toBe('the settled title') - await dispose(result) - }) - - it('uses the first non-empty line and truncates an over-long title with an ellipsis', async () => { - const adapter = new TitleAdapter('\n this title is deliberately far too long to fit a terminal tab \nextra') - const result = await setup({ config: { autoTitle: true }, agentOptions, configureContext: withLlm(adapter) }) - appendUser(result.session, 'do the big thing') - await tick() - expect(result.terminal.title).toBe('this title is deliberately far too long…') - expect(result.terminal.title.length).toBe(40) - await dispose(result) - }) - - it('skips a whitespace-only first message without consuming the one-shot slot', async () => { - const adapter = new TitleAdapter('the real title') - const result = await setup({ config: { autoTitle: true }, agentOptions, configureContext: withLlm(adapter) }) - appendUser(result.session, ' ') - await tick() - expect(adapter.lastOptions).toBeUndefined() - expect(result.terminal.title).toBe('DeepSeek Harness') - appendUser(result.session, 'the first real request') - await tick() - expect(result.terminal.title).toBe('the real title') - await dispose(result) - }) - - it('leaves the title unchanged when the model returns no usable text', async () => { - const adapter = new TitleAdapter(' \n ') - const result = await setup({ config: { autoTitle: true }, agentOptions, configureContext: withLlm(adapter) }) - appendUser(result.session, 'anything at all') - await tick() - expect(result.terminal.title).toBe('DeepSeek Harness') - await dispose(result) - }) - - it('leaves the title unchanged when the title request fails', async () => { - const adapter = new TitleAdapter(new Error('router unavailable')) - const result = await setup({ config: { autoTitle: true }, agentOptions, configureContext: withLlm(adapter) }) - appendUser(result.session, 'trigger a failing title request') - await tick() - expect(result.terminal.title).toBe('DeepSeek Harness') - await dispose(result) - }) - - it('re-derives the title on resume from the already-logged first user message', async () => { - const adapter = new TitleAdapter('resumed session title') - const result = await setup({ - config: { autoTitle: true }, - agentOptions, - configureContext: withLlm(adapter), - beforeMount: (session) => { - appendUser(session, 'the original first request') - appendUser(session, 'a later request that must not seed the title') - }, - }) - await tick() - // The title comes from the stored first message, not any later one. - expect(adapter.lastOptions?.messages).toEqual([ - { role: 'user', content: [{ type: 'text', text: 'the original first request' }] }, - ]) - expect(result.terminal.title).toBe('resumed session title') - // A message that arrives after the resume must not re-title. - appendUser(result.session, 'a follow-up message') - await tick() - expect(adapter.calls).toBe(1) - expect(result.terminal.title).toBe('resumed session title') - await dispose(result) - }) - - it('keeps the static title when auto-title is disabled', async () => { - const adapter = new TitleAdapter('should not run') - const result = await setup({ config: { autoTitle: false }, agentOptions, configureContext: withLlm(adapter) }) - appendUser(result.session, 'a normal message with the feature off') - await tick() - expect(adapter.lastOptions).toBeUndefined() - expect(result.terminal.title).toBe('DeepSeek Harness') - await dispose(result) - }) - - it('keeps the static title when no llm service is available', async () => { - const result = await setup({ config: { autoTitle: true }, agentOptions }) - appendUser(result.session, 'no model can answer this') - await tick() - expect(result.terminal.title).toBe('DeepSeek Harness') - await dispose(result) - }) - - it('keeps the static title when the agent has no provider', async () => { - const adapter = new TitleAdapter('unused') - const result = await setup({ - config: { autoTitle: true }, - agentOptions: { model: 'mock-model' }, - configureContext: withLlm(adapter), - }) - appendUser(result.session, 'the provider is missing') - await tick() - expect(adapter.lastOptions).toBeUndefined() - expect(result.terminal.title).toBe('DeepSeek Harness') - await dispose(result) - }) - - it('keeps the static title when the agent has no model', async () => { - const adapter = new TitleAdapter('unused') - const result = await setup({ - config: { autoTitle: true }, - agentOptions: { provider: 'mock' }, - configureContext: withLlm(adapter), - }) - appendUser(result.session, 'the model is missing') - await tick() - expect(adapter.lastOptions).toBeUndefined() - expect(result.terminal.title).toBe('DeepSeek Harness') - await dispose(result) - }) - - it('aborts an in-flight title request on shutdown', async () => { - const seen: { aborted: boolean } = { aborted: false } - class HangingAdapter extends LlmAdapter { - async *stream(options: GenerateOptions): AsyncIterable<StreamChunk> { - await new Promise<void>((_resolve, reject) => { - options.signal?.addEventListener('abort', () => { - seen.aborted = true - reject(new Error('aborted')) - }) - }) - yield { type: 'finish', reason: { kind: 'stop' } } - } - } - const result = await setup({ config: { autoTitle: true }, agentOptions, configureContext: withLlm(new HangingAdapter()) }) - appendUser(result.session, 'start a title request that never resolves') - await tick() - await dispose(result) - expect(seen.aborted).toBe(true) - expect(result.terminal.title).toBe('DeepSeek Harness') - }) - - it('does not set the title when the UI is torn down before the stream completes', async () => { - let release: () => void = () => {} - const gate = new Promise<void>((resolve) => { release = resolve }) - class GatedAdapter extends LlmAdapter { - // Yields a full reply, then blocks on the gate so the post-stream title - // apply runs only after the test has torn the UI down. Ignores `signal`, - // so shutdown's abort cannot cut the stream short. - async *stream(_options: GenerateOptions): AsyncIterable<StreamChunk> { - yield { type: 'block-start', index: 0, blockType: 'text' } - yield { type: 'text-delta', index: 0, text: 'title that arrives too late' } - yield { type: 'block-end', index: 0, block: { type: 'text', text: 'title that arrives too late' } } - yield { type: 'finish', reason: { kind: 'stop' } } - await gate - } - } - const result = await setup({ config: { autoTitle: true }, agentOptions, configureContext: withLlm(new GatedAdapter()) }) - appendUser(result.session, 'start a title that finishes after teardown') - await tick() - await dispose(result) - release() - await tick() - expect(result.terminal.title).toBe('DeepSeek Harness') - }) -}) From 3db0b71f91e1c90b73c36fddae74c169627b6966 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Wed, 22 Jul 2026 15:04:24 +0800 Subject: [PATCH 59/90] ci: consolidate gates on large runners --- .github/workflows/ci.yml | 190 ++++++++++++++++++++++++++++++++++++--- package.json | 1 + scripts/run-gates.ts | 14 ++- 3 files changed, 192 insertions(+), 13 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 719e2f8e9b..0e1135f447 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,6 +15,7 @@ on: - serial-reference - larger-runner-benchmark - consolidated-runner-benchmark + - sharded-reference - optimized-larger-runners concurrency: @@ -28,8 +29,8 @@ env: PRIMARY_NODE_VERSION: '24' jobs: - node-24: - if: github.event_name != 'workflow_dispatch' || inputs.suite == 'optimized-larger-runners' + node-24-sharded: + if: github.event_name == 'workflow_dispatch' && inputs.suite == 'sharded-reference' runs-on: ${{ startsWith(matrix.lane, 'snapshot-') && 'dsh-ubuntu-24-04-8core' || 'dsh-ubuntu-24-04-4core' }} name: node 24 / ${{ matrix.lane }} env: @@ -317,6 +318,75 @@ jobs: - name: Run gates run: ${{ matrix.command }} + # One large runner pays hosted setup once, then the repository scheduler + # overlaps the complete unsharded primary gate inventory inside that box. + node-24: + if: github.event_name != 'workflow_dispatch' || inputs.suite == 'optimized-larger-runners' + runs-on: dsh-ubuntu-24-04-96core + name: node 24 / complete + env: + DSH_COVERAGE_MAX_WORKERS: '32' + DSH_ESLINT_CACHE: '1' + DSH_ESLINT_CONCURRENCY: '32' + DSH_GATE_CONCURRENCY: '32' + DSH_PUBLINT_CONCURRENCY: '32' + DSH_SNAPSHOT_MAX_CONCURRENCY: '32' + steps: + - uses: actions/checkout@v6 + + - uses: actions/setup-node@v6 + with: + node-version: ${{ env.PRIMARY_NODE_VERSION }} + + - name: Enable corepack and resolve pnpm store path + id: pnpm-store + run: | + corepack enable + echo "path=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT" + + - uses: actions/cache@v4 + with: + path: ${{ steps.pnpm-store.outputs.path }} + key: ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm-${{ hashFiles('pnpm-lock.yaml') }} + restore-keys: | + ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm- + + - uses: actions/cache@v4 + with: + path: .cache/eslint + key: ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-eslint-full-${{ hashFiles('pnpm-lock.yaml', 'eslint.config.mjs', 'tsconfig.json', 'packages/*/*/tsconfig.json', 'examples/*/tsconfig.json') }} + restore-keys: | + ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-eslint-full- + + - name: Install, build, and prepare bubblewrap + run: | + pnpm install --frozen-lockfile & + install_pid=$! + ( + if ! sudo apt-get install -yq --no-install-recommends bubblewrap; then + echo "initial bubblewrap install failed; refreshing APT indexes and retrying" + sudo apt-get update -q + sudo apt-get install -yq --no-install-recommends bubblewrap + fi + sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 \ + || echo "apparmor userns knob absent — the functional probe decides" + ) & + sandbox_pid=$! + install_status=0 + wait "$install_pid" || install_status=$? + build_status=0 + if (( install_status == 0 )); then + pnpm run build || build_status=$? + fi + sandbox_status=0 + wait "$sandbox_pid" || sandbox_status=$? + if (( install_status != 0 )); then exit "$install_status"; fi + if (( build_status != 0 )); then exit "$build_status"; fi + exit "$sandbox_status" + + - name: Run complete unsharded primary Node CI concurrently + run: pnpm run check:ci:prebuilt + node-compat: if: github.event_name != 'workflow_dispatch' || inputs.suite == 'optimized-larger-runners' runs-on: ubuntu-latest @@ -378,12 +448,13 @@ jobs: - name: Run complete keyless Python suite run: uv run --python 3.10 --group test --project python/sdk pytest - # Blocking Windows build and production-site lanes keep the already-green + # Manual sharded Windows reference matching the former production topology. + # Blocking build and production-site lanes keep the already-green # native outputs protected without putting both critical paths in one job. # The broader observational gate matrix below exposes the remaining # portability work without blocking mainline merges. - windows-build: - if: github.event_name != 'workflow_dispatch' || inputs.suite == 'optimized-larger-runners' + windows-build-sharded: + if: github.event_name == 'workflow_dispatch' && inputs.suite == 'sharded-reference' runs-on: dsh-windows-2025-4core name: windows / build steps: @@ -401,8 +472,8 @@ jobs: - name: Build (tsc -b + tsdown) run: pnpm run build - windows-site: - if: github.event_name != 'workflow_dispatch' || inputs.suite == 'optimized-larger-runners' + windows-site-sharded: + if: github.event_name == 'workflow_dispatch' && inputs.suite == 'sharded-reference' runs-on: dsh-windows-2025-4core name: windows / production site steps: @@ -425,8 +496,8 @@ jobs: # have dedicated support. Run the gates from native PowerShell: an MSYS parent # would change the environment being measured. This job intentionally stays # out of all-checks-passed.needs. - windows-gates: - if: github.event_name != 'workflow_dispatch' || inputs.suite == 'optimized-larger-runners' + windows-gates-sharded: + if: github.event_name == 'workflow_dispatch' && inputs.suite == 'sharded-reference' continue-on-error: true runs-on: dsh-windows-2025-4core name: windows node 24 / ${{ matrix.lane }} @@ -516,7 +587,97 @@ jobs: shell: pwsh run: ${{ matrix.command }} - # Manual reference runs deliberately avoid the optimized matrices above. + # The two blocking Windows build surfaces share one setup and run concurrently. + windows-build: + if: github.event_name != 'workflow_dispatch' || inputs.suite == 'optimized-larger-runners' + runs-on: dsh-windows-2025-16core + name: windows / blocking builds + steps: + - uses: actions/checkout@v6 + + - uses: actions/setup-node@v6 + with: + node-version: ${{ env.PRIMARY_NODE_VERSION }} + + - name: Enable corepack and resolve pnpm store path + id: pnpm-store + shell: pwsh + run: | + corepack enable + "path=$(pnpm store path --silent)" >> $env:GITHUB_OUTPUT + + - uses: actions/cache@v4 + with: + path: ${{ steps.pnpm-store.outputs.path }} + key: ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm-${{ hashFiles('pnpm-lock.yaml') }} + restore-keys: | + ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm- + + - name: Install (immutable) + shell: pwsh + run: pnpm install --frozen-lockfile + + - name: Run blocking Windows builds concurrently + shell: pwsh + env: + DSH_GATE_CONCURRENCY: '2' + run: pnpm run check:ci:windows-blocking + + # Wider Windows portability gates remain observational, but one larger box + # now runs their complete unsharded inventory with in-runner concurrency. + windows-gates: + if: github.event_name != 'workflow_dispatch' || inputs.suite == 'optimized-larger-runners' + continue-on-error: true + runs-on: dsh-windows-2025-16core + name: windows node 24 / observational + env: + DSH_ESLINT_CACHE: '1' + DSH_ESLINT_CONCURRENCY: '16' + DSH_GATE_CONCURRENCY: '16' + DSH_PUBLINT_CONCURRENCY: '16' + steps: + - uses: actions/checkout@v6 + + - name: Enable Developer Mode (symlink support) + shell: pwsh + run: >- + reg add "HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\AppModelUnlock" + /t REG_DWORD /f /v "AllowDevelopmentWithoutDevLicense" /d "1" + + - uses: actions/setup-node@v6 + with: + node-version: ${{ env.PRIMARY_NODE_VERSION }} + + - name: Enable corepack and resolve pnpm store path + id: pnpm-store + shell: pwsh + run: | + corepack enable + "path=$(pnpm store path --silent)" >> $env:GITHUB_OUTPUT + + - uses: actions/cache@v4 + with: + path: ${{ steps.pnpm-store.outputs.path }} + key: ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm-${{ hashFiles('pnpm-lock.yaml') }} + restore-keys: | + ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm- + + - uses: actions/cache@v4 + with: + path: .cache/eslint + key: ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-eslint-full-${{ hashFiles('pnpm-lock.yaml', 'eslint.config.mjs', 'tsconfig.json', 'packages/*/*/tsconfig.json', 'examples/*/tsconfig.json') }} + restore-keys: | + ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-eslint-full- + + - name: Install (immutable) + shell: pwsh + run: pnpm install --frozen-lockfile + + - name: Run observational Windows gates concurrently + shell: pwsh + run: pnpm run check:ci:windows-observational + + # Manual reference runs deliberately avoid the optimized jobs above. # Each host executes the complete, unsharded primary Node aggregate with one # gate worker, giving reviewers a simple cross-platform oracle for completeness # and timing. @@ -834,9 +995,14 @@ jobs: sandbox_pid=$! install_status=0 wait "$install_pid" || install_status=$? + build_status=0 + if (( install_status == 0 )); then + pnpm run build || build_status=$? + fi sandbox_status=0 wait "$sandbox_pid" || sandbox_status=$? if (( install_status != 0 )); then exit "$install_status"; fi + if (( build_status != 0 )); then exit "$build_status"; fi exit "$sandbox_status" - name: Install (immutable) @@ -853,7 +1019,7 @@ jobs: DSH_GATE_CONCURRENCY: ${{ matrix.workers }} DSH_PUBLINT_CONCURRENCY: ${{ matrix.workers }} DSH_SNAPSHOT_MAX_CONCURRENCY: ${{ matrix.workers }} - run: pnpm run check:ci + run: pnpm run check:ci:prebuilt - name: Run blocking Windows builds concurrently if: matrix.platform == 'windows' @@ -874,7 +1040,7 @@ jobs: all-checks-passed: name: all checks passed runs-on: ubuntu-latest - needs: [node-24, node-compat, python-sdk, windows-build, windows-site] + needs: [node-24, node-compat, python-sdk, windows-build] if: always() && (github.event_name != 'workflow_dispatch' || inputs.suite == 'optimized-larger-runners') steps: - name: Fail if any needed job did not succeed diff --git a/package.json b/package.json index b758974ef7..a8ee301da3 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ "test:snapshot:record": "DSH_SNAPSHOT=record vitest run --config vitest.snapshot.config.ts --update", "test:snapshot:refresh": "DSH_SNAPSHOT=refresh vitest run --config vitest.snapshot.config.ts", "check:ci": "tsx scripts/run-gates.ts ci-primary", + "check:ci:prebuilt": "tsx scripts/run-gates.ts ci-primary-prebuilt", "check:ci:static": "tsx scripts/run-gates.ts ci-static", "check:ci:lint": "tsx scripts/run-gates.ts ci-lint", "check:ci:coverage": "tsx scripts/run-gates.ts ci-coverage", diff --git a/scripts/run-gates.ts b/scripts/run-gates.ts index 98326d6db9..c9d058518b 100644 --- a/scripts/run-gates.ts +++ b/scripts/run-gates.ts @@ -15,6 +15,7 @@ import { selectStaticGates } from './static-shards.ts' type Mode = | 'ci-primary' + | 'ci-primary-prebuilt' | 'ci-static' | 'ci-lint' | 'ci-coverage' @@ -87,6 +88,7 @@ if (results.some(result => result.status === 'failed' || result.status === 'skip function parseMode(raw: string | undefined): Mode { switch (raw) { case 'ci-primary': + case 'ci-primary-prebuilt': case 'ci-static': case 'ci-lint': case 'ci-coverage': @@ -100,7 +102,7 @@ function parseMode(raw: string | undefined): Mode { return raw default: throw new Error( - `run-gates: expected mode ci-primary | ci-static | ci-lint | ci-coverage | ci-snapshot | ci-artifacts | ci-windows-blocking | ci-windows-observational | node-compat | pre-push | doc-sync, got ${JSON.stringify(raw)}.`, + `run-gates: expected mode ci-primary | ci-primary-prebuilt | ci-static | ci-lint | ci-coverage | ci-snapshot | ci-artifacts | ci-windows-blocking | ci-windows-observational | node-compat | pre-push | doc-sync, got ${JSON.stringify(raw)}.`, ) } } @@ -166,6 +168,8 @@ function gatesForMode(selected: Mode): Gate[] { switch (selected) { case 'ci-primary': return ciPrimaryGates() + case 'ci-primary-prebuilt': + return ciPrimaryPrebuiltGates() case 'ci-static': return ciStaticGates() case 'ci-lint': { @@ -246,6 +250,14 @@ function ciPrimaryGates(): Gate[] { ] } +function ciPrimaryPrebuiltGates(): Gate[] { + return ciPrimaryGates() + .filter(gate => gate.id !== 'build') + .map(gate => gate.needs?.includes('build') === true + ? { ...gate, needs: gate.needs.filter(id => id !== 'build') } + : gate) +} + function ciStaticGates(): Gate[] { const gates = [ pnpmScript('runtime-closure', 'verify-runtime-closure', { label: 'runtime closure' }), From 990f82b85cf748638c04272f8cada333ca2dee39 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Wed, 22 Jul 2026 15:17:17 +0800 Subject: [PATCH 60/90] ci: tune consolidated runner concurrency --- .github/workflows/ci.yml | 28 ++++++++++------------------ package.json | 2 +- scripts/run-gates.ts | 24 ++++++++++++++---------- 3 files changed, 25 insertions(+), 29 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0e1135f447..121d82d701 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -325,7 +325,8 @@ jobs: runs-on: dsh-ubuntu-24-04-96core name: node 24 / complete env: - DSH_COVERAGE_MAX_WORKERS: '32' + # Thirty-two Vitest forks intermittently crash Node's CJS lexer on this image. + DSH_COVERAGE_MAX_WORKERS: '16' DSH_ESLINT_CACHE: '1' DSH_ESLINT_CONCURRENCY: '32' DSH_GATE_CONCURRENCY: '32' @@ -358,7 +359,7 @@ jobs: restore-keys: | ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-eslint-full- - - name: Install, build, and prepare bubblewrap + - name: Install and prepare bubblewrap run: | pnpm install --frozen-lockfile & install_pid=$! @@ -374,18 +375,13 @@ jobs: sandbox_pid=$! install_status=0 wait "$install_pid" || install_status=$? - build_status=0 - if (( install_status == 0 )); then - pnpm run build || build_status=$? - fi sandbox_status=0 wait "$sandbox_pid" || sandbox_status=$? if (( install_status != 0 )); then exit "$install_status"; fi - if (( build_status != 0 )); then exit "$build_status"; fi exit "$sandbox_status" - name: Run complete unsharded primary Node CI concurrently - run: pnpm run check:ci:prebuilt + run: pnpm run check:ci:large-runner node-compat: if: github.event_name != 'workflow_dispatch' || inputs.suite == 'optimized-larger-runners' @@ -628,13 +624,14 @@ jobs: windows-gates: if: github.event_name != 'workflow_dispatch' || inputs.suite == 'optimized-larger-runners' continue-on-error: true - runs-on: dsh-windows-2025-16core + runs-on: dsh-windows-2025-32core name: windows node 24 / observational env: + # Keep ESLint itself single-threaded: 16 ESLint workers took 174 seconds on + # this image. The outer scheduler still overlaps lint with the other gates. DSH_ESLINT_CACHE: '1' - DSH_ESLINT_CONCURRENCY: '16' - DSH_GATE_CONCURRENCY: '16' - DSH_PUBLINT_CONCURRENCY: '16' + DSH_GATE_CONCURRENCY: '32' + DSH_PUBLINT_CONCURRENCY: '32' steps: - uses: actions/checkout@v6 @@ -995,14 +992,9 @@ jobs: sandbox_pid=$! install_status=0 wait "$install_pid" || install_status=$? - build_status=0 - if (( install_status == 0 )); then - pnpm run build || build_status=$? - fi sandbox_status=0 wait "$sandbox_pid" || sandbox_status=$? if (( install_status != 0 )); then exit "$install_status"; fi - if (( build_status != 0 )); then exit "$build_status"; fi exit "$sandbox_status" - name: Install (immutable) @@ -1019,7 +1011,7 @@ jobs: DSH_GATE_CONCURRENCY: ${{ matrix.workers }} DSH_PUBLINT_CONCURRENCY: ${{ matrix.workers }} DSH_SNAPSHOT_MAX_CONCURRENCY: ${{ matrix.workers }} - run: pnpm run check:ci:prebuilt + run: pnpm run check:ci:large-runner - name: Run blocking Windows builds concurrently if: matrix.platform == 'windows' diff --git a/package.json b/package.json index a8ee301da3..c3aaa3c29d 100644 --- a/package.json +++ b/package.json @@ -26,7 +26,7 @@ "test:snapshot:record": "DSH_SNAPSHOT=record vitest run --config vitest.snapshot.config.ts --update", "test:snapshot:refresh": "DSH_SNAPSHOT=refresh vitest run --config vitest.snapshot.config.ts", "check:ci": "tsx scripts/run-gates.ts ci-primary", - "check:ci:prebuilt": "tsx scripts/run-gates.ts ci-primary-prebuilt", + "check:ci:large-runner": "tsx scripts/run-gates.ts ci-primary-large-runner", "check:ci:static": "tsx scripts/run-gates.ts ci-static", "check:ci:lint": "tsx scripts/run-gates.ts ci-lint", "check:ci:coverage": "tsx scripts/run-gates.ts ci-coverage", diff --git a/scripts/run-gates.ts b/scripts/run-gates.ts index c9d058518b..80c9c9bc5c 100644 --- a/scripts/run-gates.ts +++ b/scripts/run-gates.ts @@ -15,7 +15,7 @@ import { selectStaticGates } from './static-shards.ts' type Mode = | 'ci-primary' - | 'ci-primary-prebuilt' + | 'ci-primary-large-runner' | 'ci-static' | 'ci-lint' | 'ci-coverage' @@ -88,7 +88,7 @@ if (results.some(result => result.status === 'failed' || result.status === 'skip function parseMode(raw: string | undefined): Mode { switch (raw) { case 'ci-primary': - case 'ci-primary-prebuilt': + case 'ci-primary-large-runner': case 'ci-static': case 'ci-lint': case 'ci-coverage': @@ -102,7 +102,7 @@ function parseMode(raw: string | undefined): Mode { return raw default: throw new Error( - `run-gates: expected mode ci-primary | ci-primary-prebuilt | ci-static | ci-lint | ci-coverage | ci-snapshot | ci-artifacts | ci-windows-blocking | ci-windows-observational | node-compat | pre-push | doc-sync, got ${JSON.stringify(raw)}.`, + `run-gates: expected mode ci-primary | ci-primary-large-runner | ci-static | ci-lint | ci-coverage | ci-snapshot | ci-artifacts | ci-windows-blocking | ci-windows-observational | node-compat | pre-push | doc-sync, got ${JSON.stringify(raw)}.`, ) } } @@ -168,8 +168,8 @@ function gatesForMode(selected: Mode): Gate[] { switch (selected) { case 'ci-primary': return ciPrimaryGates() - case 'ci-primary-prebuilt': - return ciPrimaryPrebuiltGates() + case 'ci-primary-large-runner': + return ciPrimaryLargeRunnerGates() case 'ci-static': return ciStaticGates() case 'ci-lint': { @@ -250,12 +250,16 @@ function ciPrimaryGates(): Gate[] { ] } -function ciPrimaryPrebuiltGates(): Gate[] { +function ciPrimaryLargeRunnerGates(): Gate[] { + // Typecheck does not consume build output, so a large runner can start both + // together while snapshot and artifact consumers still wait for the build. return ciPrimaryGates() - .filter(gate => gate.id !== 'build') - .map(gate => gate.needs?.includes('build') === true - ? { ...gate, needs: gate.needs.filter(id => id !== 'build') } - : gate) + .map((gate) => { + if (gate.id !== 'build') return gate + const eagerBuild = { ...gate } + delete eagerBuild.needs + return eagerBuild + }) } function ciStaticGates(): Gate[] { From cc22492ddd1c40973b903db950a5b897a9e9fb88 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Wed, 22 Jul 2026 15:30:02 +0800 Subject: [PATCH 61/90] docs: record consolidated runner decision --- .../2026-07-06-parallel-github-ci-gates.md | 2 + ...ence-based-larger-hosted-runners.i18n.yaml | 4 +- ...22-evidence-based-larger-hosted-runners.md | 54 ++++++++++++++----- ...evidence-based-larger-hosted-runners.zh.md | 54 ++++++++++++++----- 4 files changed, 86 insertions(+), 28 deletions(-) diff --git a/.agents/notes/implemented/process/2026-07-06-parallel-github-ci-gates.md b/.agents/notes/implemented/process/2026-07-06-parallel-github-ci-gates.md index 156e3691d1..c7904ae378 100644 --- a/.agents/notes/implemented/process/2026-07-06-parallel-github-ci-gates.md +++ b/.agents/notes/implemented/process/2026-07-06-parallel-github-ci-gates.md @@ -12,6 +12,8 @@ The artifact boundary remains load-bearing. `publint`, `verify-node-next-types`, ## Decision +The production topology below was superseded by [Evidence-based larger hosted runners](2026-07-22-evidence-based-larger-hosted-runners.md). Its shard selectors and former job layout remain available only through manual `suite=sharded-reference`; this note preserves why that earlier topology was implemented. + [CI](../../../../.github/workflows/ci.yml) treats one minute for non-Windows jobs and three minutes for Windows jobs as observed performance targets, not cancellation deadlines. Hosted-runner variance should leave complete timing evidence and useful failure logs instead of cancelling an otherwise-correct gate. The [serial cross-platform CI reference](2026-07-21-serial-cross-platform-ci-reference.md) independently runs the complete unsharded primary Node aggregate on Linux, macOS, and Windows so the optimized lane inventory is not its own completeness oracle. [scripts/run-gates.ts](../../../../scripts/run-gates.ts) remains the common bounded scheduler, but GitHub supplies explicit shard names for the expensive gate families. [scripts/static-shards.ts](../../../../scripts/static-shards.ts) partitions static gates into foundation, documentation-type, API-contract, catalog, prose, documentation-projection, and documentation-build ownership and rejects a missing or duplicate gate assignment; low-cost ownership groups may share one hosted lane to stay below the runner-provisioning concurrency wave. Linux lint uses disjoint A-C, D-M, N-S, and T-Z package-source and package-test lanes, while the longer Windows target uses one complete package-source lane and one complete package-test lane; both topologies include a repository complement that still starts from `.` so a new top-level lint target cannot disappear between shards and owns the single cross-file duplication run. [scripts/coverage-shards.ts](../../../../scripts/coverage-shards.ts) assigns every workspace package to exactly one source-coverage lane; its test expands the live package tree, so a new package makes CI red until it has an owner. Directory filters retain a trailing separator because Vitest positional filters match substrings and would otherwise admit prefix-named sibling packages. Each coverage lane includes only its owned source files, repeats the exhaustive companion topology test, and runs without a preceding build because the complete coverage suite passes from a tree with every generated `lib/` removed. diff --git a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.i18n.yaml b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.i18n.yaml index 303a021a38..da19d5558b 100644 --- a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.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 -2026-07-22-evidence-based-larger-hosted-runners.md: 992a622f0d7a2662cc65353d6c4cd1350e1f3f5f -2026-07-22-evidence-based-larger-hosted-runners.zh.md: 622911af8400fbe159038b69b440c0e97fb1f2cc +2026-07-22-evidence-based-larger-hosted-runners.md: 8d3852c56e2289bd222a260f2685f6a941a0cb19 +2026-07-22-evidence-based-larger-hosted-runners.zh.md: 4e7f846db1be2d68a080381943012583636ed2c4 diff --git a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.md b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.md index 992a622f0d..8d3852c56e 100644 --- a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.md +++ b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.md @@ -6,39 +6,67 @@ English | [中文](2026-07-22-evidence-based-larger-hosted-runners.zh.md) ## Problem -The sharded primary CI fits its latency targets on standard GitHub-hosted runners, but the margin depends on cold setup and install variance. Larger runners may add useful headroom, yet their per-minute price rises much faster than these short lanes can use extra cores. Selecting a size from machine specifications or a synthetic benchmark would spend more without proving that repository CI becomes faster. +The shard-heavy CI topology met its latency targets by spreading primary Node work across 40 Linux jobs and Windows work across nine jobs. Most gates were shorter than checkout, runner setup, cache restore, and dependency installation, so repeated setup waves created both cost and latency variance. One hosted run finished its slowest Linux job in 49 seconds yet took 231 seconds for a Windows lint shard whose checkout, cache restore, and install alone consumed 158 seconds. + +Larger runners make it possible to pay setup once and parallelize inside the repository scheduler, but the useful size cannot be selected from core counts alone. Critical-lane benchmarks did not scale monotonically, and a whole-repository aggregate exposed different bottlenecks from isolated typecheck or site builds. ## Decision -The organization keeps twelve x64 larger-runner pools in the repo-restricted `dsh-larger-ci` group: Ubuntu 24.04 and Windows 2025 at 4, 8, 16, 32, 64, and 96 cores. Public IPs are disabled. Each pool has an autoscaling ceiling of 256, while the repository bounds actual demand through its workflow matrices; an idle ceiling does not allocate machines. +The organization keeps twelve x64 larger-runner pools in the repo-restricted `dsh-larger-ci` group: Ubuntu 24.04 and Windows 2025 at 4, 8, 16, 32, 64, and 96 cores. Public IPs are disabled. Each pool has an autoscaling ceiling of 256; the ceiling does not allocate idle machines or remove the need to bound workflow demand. -The `CI` workflow exposes `suite=larger-runner-benchmark` only through manual dispatch. Its six Linux legs run the critical typecheck lane, and its six Windows legs run the critical production-site lane. Every leg reports the observed CPU and memory, has a 15-minute timeout, and uses the same setup and caching policy as the production lane it represents. Push and pull-request events skip this benchmark. +Production CI uses three larger-runner jobs and keeps Node compatibility, Python, and the final aggregator on standard runners: -The [twelve-size benchmark](https://github.com/deepseek-harness/deepseek-harness/actions/runs/29895295659) used a workflow-only commit on top of the standard-runner [baseline](https://github.com/deepseek-harness/deepseek-harness/actions/runs/29850033610), so the code, lockfile, and critical commands were identical: +- `node 24 / complete` uses the 96-core Linux pool. One checkout, setup, cache restore, and install feeds the complete unsharded 40-gate primary inventory. `run-gates` starts up to 32 independent gates, ESLint uses 32 workers, snapshots use up to 32 subprocesses, and coverage uses 16 forks. Build starts beside typecheck; snapshot and artifact consumers still wait for emitted output. Coverage stays below 32 forks because that setting twice caused Node 24's CJS lexer to terminate a Vitest worker and invalidate coverage. +- `windows / blocking builds` uses the 16-core Windows pool. Build and the production VitePress site run concurrently after one setup. +- `windows node 24 / observational` uses the 32-core Windows pool. Its complete unsharded 37-gate static, lint, and artifact inventory runs with 32 outer scheduler slots and remains non-blocking. ESLint itself stays single-threaded because 16 ESLint worker threads increased full-lint time to 174.54 seconds; with outer concurrency and no ESLint workers, the same full lint took 31.67 seconds. + +The two Windows jobs deliberately use different pools. A first candidate put both on the 16-core pool and GitHub took 93 seconds to provision the second same-label runner despite the configured autoscaling ceiling. Using the 16-core and 32-core pools let every production job begin within two seconds in the final validation run. + +The workflow retains four manual diagnostics. `suite=larger-runner-benchmark` compares isolated critical lanes across every size, `suite=consolidated-runner-benchmark` compares whole aggregates, `suite=sharded-reference` preserves the former production shard topology, and `suite=serial-reference` remains the unsharded cross-platform completeness oracle. `suite=optimized-larger-runners` runs the exact production topology against a branch ref when a pull request cannot form a merge commit. + +The first [twelve-size critical-lane benchmark](https://github.com/deepseek-harness/deepseek-harness/actions/runs/29895295659) used a workflow-only commit on top of the standard-runner [baseline](https://github.com/deepseek-harness/deepseek-harness/actions/runs/29850033610), so the code, lockfile, and commands were identical: | Critical job | Standard | 4 cores | 8 cores | 16 cores | 32 cores | 64 cores | 96 cores | |---|---:|---:|---:|---:|---:|---:|---:| | Linux typecheck | 56 s | 38 s | 35 s | 40 s | 35 s | 44 s | 40 s | | Windows production site | 160 s | 117 s | 103 s | 113 s | 75 s | 105 s | 108 s | -The repository therefore uses the 4-core pools for the primary Node matrix and all Windows jobs. Linux 4-core finished within three seconds of the fastest measured size at the lowest larger-runner rate. Windows 4-core stayed below two minutes; the isolated 32-core result was faster, but adjacent larger sizes regressed and the production-site command itself varied only from 28 to 36 seconds, so setup and install noise—not scalable compute—created most of the spread. Node compatibility, Python, and the final aggregator remain on standard runners because their baseline jobs already finish well below one minute. +Those isolated results showed that setup dominated but did not identify the production size. A [whole-aggregate benchmark without native ESLint concurrency](https://github.com/deepseek-harness/deepseek-harness/actions/runs/29897826082) found a 69-second single-threaded Linux lint gate. After enabling native Linux ESLint concurrency, the [second whole-aggregate benchmark](https://github.com/deepseek-harness/deepseek-harness/actions/runs/29898331705) produced these active job times: -The workflow also exposes `suite=optimized-larger-runners` through manual dispatch. That path runs the production matrices against the branch ref itself, providing an exact-head timing check when a pull request cannot form a merge commit. +| Aggregate job | 4 cores | 8 cores | 16 cores | 32 cores | 64 cores | 96 cores | +|---|---:|---:|---:|---:|---:|---:| +| Linux complete primary | 147 s | 104 s | 95 s | failed at 57 s | 66 s | 60 s | +| Windows blocking builds | 137 s | 127 s | 113 s | 107 s | 105 s | 131 s | + +The Linux 32-core failure was the first CJS-lexer worker crash. The 96-core aggregate was the only successful all-size result at the one-minute boundary. Windows gained little above 16 cores, so the blocking job uses 16 cores; the observational job uses a separate 32-core pool to avoid same-label provisioning delay and to start all outer gates together. + +The exact production [validation run](https://github.com/deepseek-harness/deepseek-harness/actions/runs/29899733584) passed every job at the tested branch head: + +| Production job | Active time | Repository work | Result | +|---|---:|---:|---:| +| Linux complete primary | 50 s | 40 gates in 23.23 s | passed | +| Slowest standard non-Windows job | 40 s | Node 26 compatibility | passed | +| Windows blocking builds | 91 s | 2 gates in 28.69 s | passed | +| Windows observational | 153 s | 37 gates in 31.83 s | passed | + +The Windows observational job spent 57 seconds restoring its pnpm cache, so its remaining margin measures hosted setup variance as well as repository work. The final run still stays below one minute for every non-Windows job and below three minutes for both Windows jobs. ## Alternatives considered -**Keep every job on standard runners.** This meets the threshold but gives the critical lanes no cold-run margin and leaves the larger-runner suggestion untested. +**Keep the former shard topology in production.** The shards can be fast when provisioned together, but 49 larger-runner jobs repeat setup and create more chances for a cold outlier. The 231-second Windows control demonstrated that a short lint shard does not protect the end-to-end job target. -**Select 8 or 32 cores from the fastest individual result.** The small differences were not monotonic, while billing grows sharply with size. Treating one noisy minimum as scaling evidence would make recurring CI substantially more expensive. +**Select a production size from the critical-lane benchmark.** Four cores looked cost-effective for isolated typecheck and site builds, but the full aggregate found repository-wide lint and dependent artifact work that those commands did not represent. -**Move every job to a larger runner.** Compatibility, Python, and aggregation were already short; paying the larger-runner premium there cannot improve the critical path enough to justify the dependency or cost. +**Prebuild before starting the Linux aggregate.** This moved build onto the setup path and produced a 66-second candidate. Starting build eagerly inside `run-gates` preserves artifact dependencies while overlapping it with unrelated checks; the final aggregate completed in 23.23 seconds. -**Use a synthetic CPU benchmark.** A microbenchmark would not include checkout, action startup, package installation, cache restore, or the repository command mix that dominates these jobs. +**Use native ESLint worker concurrency on Windows.** Sixteen workers made lint more than five times slower than the final single-threaded result. Outer gate parallelism uses the 32-core runner without multiplying ESLint's Windows worker startup and TypeScript project loading. + +**Move compatibility, Python, and aggregation to larger runners.** These standard-runner jobs all completed in 40 seconds or less. Paid capacity would not shorten the critical path. ## Consequences -The benchmark incurred $2.936 across dedicated larger-runner SKUs, as recorded by organization billing immediately after the run. The existing zero-dollar Actions budget did not block those jobs, so the repo-only runner group, manual benchmark trigger, matrix width, and timeout are the observed cost controls; the budget is not treated as an execution guard. +The final production validation consumed one billed 96-core Linux minute, two billed 16-core Windows minutes, and three billed 32-core Windows minutes. At the configured larger-runner rates, its larger-runner cost was $0.902. The all-size critical benchmark cost $2.936. GitHub rounds each larger-runner job up to a whole minute, so reducing paid job count from 49 to three matters as much as shortening repository work. -Production CI now depends on the organization-owned runner names in this note and in `.github/workflows/ci.yml`. Missing or renamed pools leave jobs queued instead of silently falling back to standard capacity. The 256 autoscaling ceiling permits future concurrency but does not override the bounded matrices in the current workflow. +The existing zero-dollar Actions budget did not block larger-runner jobs. The repo-only runner group, bounded workflow topology, manual benchmark triggers, and job timeouts are the observed cost controls; the budget is not treated as an execution guard. -The selected pools buy latency headroom at a recurring per-job premium. The manual benchmark retains all sizes so a future image, dependency graph, or workload change can be measured before changing the production labels again. +Production CI depends on the organization-owned runner names in this note and in `.github/workflows/ci.yml`. Missing or renamed pools leave jobs queued instead of falling back to standard capacity. Manual all-size and former-shard suites remain available so image, dependency, scheduler, or pricing changes can be remeasured before changing production labels. diff --git a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.zh.md b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.zh.md index 622911af84..4e7f846db1 100644 --- a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.zh.md +++ b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.zh.md @@ -6,39 +6,67 @@ Status: implemented ## 问题 -分片后的主 CI 在标准 GitHub 托管运行器上能够达到延迟目标,但余量会受冷启动设置和安装耗时波动影响。大型运行器或许能提供更多有效余量,但随着核心数增加,其每分钟单价增长得远比这些短通道能够利用额外核心的速度快。仅根据机器规格或合成基准测试选择配置,只会增加支出,无法证明仓库 CI 确实变快。 +高度分片的 CI 拓扑通过把主 Node 工作分散到 40 个 Linux 作业、把 Windows 工作分散到 9 个作业来达到延迟目标。大多数门禁本身的耗时短于代码检出、运行器设置、缓存恢复和依赖安装这些准备阶段,因此反复执行多轮设置既增加成本,也带来延迟波动。一次托管运行中最慢的 Linux 作业用时 49 秒,而一个 Windows lint 分片却耗时 231 秒,其中仅代码检出、缓存恢复和安装就占了 158 秒。 + +大型运行器可以让 CI 只承担一次设置开销,再由仓库调度器在内部并行执行,但无法仅凭核心数选出有实际价值的规格。关键通道基准测试的性能提升不呈单调变化,完整仓库聚合流程暴露出的瓶颈也不同于单独运行类型检查或网站构建时的瓶颈。 ## 决策 -组织在仅限本仓库使用的 `dsh-larger-ci` 运行器组中保留 12 个 x64 大型运行器池:Ubuntu 24.04 和 Windows 2025 各设 4、8、16、32、64、96 核规格。公网 IP 已禁用。每个池的自动扩缩容上限为 256,仓库则通过工作流矩阵限制实际需求;空闲状态下的上限不会分配机器。 +组织在仅限本仓库使用的 `dsh-larger-ci` 运行器组中保留 12 个 x64 大型运行器池:Ubuntu 24.04 和 Windows 2025 各设 4、8、16、32、64、96 核规格。公网 IP 已禁用。每个池的自动扩缩容上限为 256;该上限既不会分配闲置机器,也不能免除限制工作流需求的必要性。 -`CI` 工作流仅在手动触发时提供 `suite=larger-runner-benchmark`。其中 6 个 Linux 矩阵分支运行关键类型检查通道,6 个 Windows 矩阵分支运行生产网站关键通道。每个矩阵分支都会报告实测 CPU 和内存,超时限制为 15 分钟,并采用与其所代表的生产通道相同的设置和缓存策略。push 和拉取请求事件会跳过这项基准测试。 +生产 CI 使用 3 个大型运行器作业,并让 Node 兼容性、Python 和最终聚合作业继续使用标准运行器: -这项[涵盖 12 种规格的基准测试](https://github.com/deepseek-harness/deepseek-harness/actions/runs/29895295659)以标准运行器[基线](https://github.com/deepseek-harness/deepseek-harness/actions/runs/29850033610)为基础,只叠加了一个仅修改工作流的提交,因此代码、锁文件和关键命令完全相同: +- `node 24 / complete` 使用 96 核 Linux 池。只需执行一次代码检出、设置、缓存恢复和安装,即可供完整且未分片的 40 项主门禁清单使用。`run-gates` 最多同时启动 32 项相互独立的门禁,ESLint 使用 32 个工作线程,快照最多使用 32 个子进程,覆盖率运行使用 16 个 fork。构建与类型检查同时启动;快照和产物消费方仍会等待生成的输出。覆盖率运行的 fork 数保持低于 32,因为将其设为 32 曾两次导致 Node 24 的 CJS 词法分析器终止一个 Vitest 工作进程,使覆盖率结果失效。 +- `windows / blocking builds` 使用 16 核 Windows 池。一次设置完成后,构建任务与生产版 VitePress 网站任务并发运行。 +- `windows node 24 / observational` 使用 32 核 Windows 池。其完整且未分片的 37 项静态、lint 和产物门禁清单使用 32 个外层调度器槽位运行,并保持非阻塞。ESLint 本身仍采用单线程,因为启用 16 个 ESLint 工作线程会把完整 lint 的耗时增加至 174.54 秒;在启用外层并发且不使用 ESLint 工作线程时,同一项完整 lint 只需 31.67 秒。 + +两个 Windows 作业有意使用不同的运行器池。首个候选方案让二者都使用 16 核池;尽管已配置自动扩缩容上限,GitHub 仍花费 93 秒才预配好第二台同标签运行器。分别使用 16 核池和 32 核池后,最终验证运行中的每个生产作业都在 2 秒内开始运行。 + +工作流保留 4 项手动诊断。`suite=larger-runner-benchmark` 比较所有规格下相互独立的关键通道,`suite=consolidated-runner-benchmark` 比较完整聚合流程,`suite=sharded-reference` 保留原生产分片拓扑,`suite=serial-reference` 则继续作为未分片的跨平台完整性判定基准。当拉取请求无法生成合并提交时,`suite=optimized-larger-runners` 会直接针对分支引用运行与生产环境完全相同的拓扑。 + +首次[涵盖 12 种规格的关键通道基准测试](https://github.com/deepseek-harness/deepseek-harness/actions/runs/29895295659)以标准运行器[基线](https://github.com/deepseek-harness/deepseek-harness/actions/runs/29850033610)为基础,只叠加了一个仅修改工作流的提交,因此代码、锁文件和命令完全相同: | 关键作业 | 标准 | 4 核 | 8 核 | 16 核 | 32 核 | 64 核 | 96 核 | |---|---:|---:|---:|---:|---:|---:|---:| | Linux 类型检查 | 56 秒 | 38 秒 | 35 秒 | 40 秒 | 35 秒 | 44 秒 | 40 秒 | | Windows 生产网站 | 160 秒 | 117 秒 | 103 秒 | 113 秒 | 75 秒 | 105 秒 | 108 秒 | -因此,仓库让主 Node 矩阵和所有 Windows 作业使用 4 核池。Linux 4 核规格比实测最快规格至多慢 3 秒,同时采用大型运行器中的最低费率。Windows 4 核规格保持在 2 分钟以内;单次测得的 32 核结果虽然更快,但相邻的大型运行器规格反而耗时回升,而生产网站命令本身的耗时仅在 28 至 36 秒之间波动,因此大部分差异来自设置和安装噪声,而非可随核心数扩展的计算负载。Node 兼容性、Python 和最终聚合作业仍使用标准运行器,因为它们的基线耗时本就远低于 1 分钟。 +这些单项结果表明设置开销占主导地位,却无法确定生产环境应选用的规格。一项[未启用 ESLint 原生并发的完整聚合基准测试](https://github.com/deepseek-harness/deepseek-harness/actions/runs/29897826082)发现,Linux 单线程 lint 门禁耗时 69 秒。启用 Linux ESLint 原生并发后,[第二次完整聚合基准测试](https://github.com/deepseek-harness/deepseek-harness/actions/runs/29898331705)得到了以下作业活动耗时: -工作流还在手动触发时提供 `suite=optimized-larger-runners`。这一路径直接以分支引用本身运行生产矩阵;当拉取请求无法生成合并提交时,它仍能针对该分支实际的头部提交提供精确计时检查。 +| 聚合作业 | 4 核 | 8 核 | 16 核 | 32 核 | 64 核 | 96 核 | +|---|---:|---:|---:|---:|---:|---:| +| Linux 完整主流程 | 147 秒 | 104 秒 | 95 秒 | 57 秒时失败 | 66 秒 | 60 秒 | +| Windows 阻塞性构建 | 137 秒 | 127 秒 | 113 秒 | 107 秒 | 105 秒 | 131 秒 | + +Linux 32 核作业的失败是首次发生的 CJS 词法分析器工作进程崩溃。在所有规格的结果中,96 核聚合作业是唯一成功达到 1 分钟边界的结果。Windows 超过 16 核后的收益很小,因此阻塞性作业使用 16 核;观测作业则使用单独的 32 核池,以避免同标签运行器的预配延迟,并让全部外层门禁同时启动。 + +生产环境的精确[验证运行](https://github.com/deepseek-harness/deepseek-harness/actions/runs/29899733584)在受测分支头通过了所有作业: + +| 生产作业 | 活动耗时 | 仓库工作 | 结果 | +|---|---:|---:|---:| +| Linux 完整主流程 | 50 秒 | 40 项门禁耗时 23.23 秒 | 通过 | +| 最慢的标准非 Windows 作业 | 40 秒 | Node 26 兼容性 | 通过 | +| Windows 阻塞性构建 | 91 秒 | 2 项门禁耗时 28.69 秒 | 通过 | +| Windows 观测作业 | 153 秒 | 37 项门禁耗时 31.83 秒 | 通过 | + +Windows 观测作业花费 57 秒恢复 pnpm 缓存,因此其剩余余量既反映托管环境的设置波动,也反映仓库工作耗时。最终运行中每个非 Windows 作业仍低于 1 分钟,两个 Windows 作业也都低于 3 分钟。 ## 曾考虑的替代方案 -**所有作业均保留在标准运行器上。** 此方案能达到时长目标,但关键通道面对冷启动时没有余量,也无法验证大型运行器方案。 +**在生产环境中保留原分片拓扑。** 各分片在一同完成预配时可以很快,但 49 个大型运行器作业会重复设置,也增加了出现冷启动异常值的机会。耗时 231 秒的 Windows 对照结果表明,短小的 lint 分片并不能保障端到端作业达到时长目标。 -**根据单次最快结果选择 8 核或 32 核。** 各规格间的微小差异并不呈单调变化,计费却随规格急剧增长。把一次带有噪声的最低值当作扩展性证据,会大幅提高 CI 的经常性成本。 +**根据关键通道基准测试选择生产规格。** 对单独的类型检查和网站构建而言,4 核看起来具备成本效益,但完整聚合流程发现了这些命令未覆盖的全仓库 lint 和存在依赖关系的产物工作。 -**将所有作业迁移到大型运行器。** 兼容性、Python 和聚合作业原本就很短;为这些作业支付大型运行器溢价,无法充分缩短关键路径,因而不足以证明这种依赖或成本合理。 +**在启动 Linux 聚合流程前预先构建。** 此方案让构建成为设置路径的一部分,并产生了一个耗时 66 秒的候选结果。在 `run-gates` 内尽早启动构建,既能保留产物依赖关系,又能让构建与无关检查重叠执行;最终聚合流程耗时 23.23 秒。 -**使用合成 CPU 基准测试。** 微基准测试不会包含代码检出、操作启动、包安装、缓存恢复或主导这些作业耗时的仓库命令组合。 +**在 Windows 上使用 ESLint 原生工作线程并发。** 16 个工作线程让 lint 比最终的单线程结果慢 5 倍以上。外层门禁并发能够利用 32 核运行器,同时不会成倍增加 ESLint 在 Windows 上启动工作线程和加载 TypeScript 项目的开销。 + +**将兼容性、Python 和聚合作业迁移到大型运行器。** 这些标准运行器作业都在 40 秒以内完成。付费容量不会缩短关键路径。 ## 后果 -这次基准测试使用专用大型运行器 SKU,共产生 $2.936 费用,组织计费系统在运行结束后立即记录了这笔费用。现有的零美元 Actions 预算并未阻止这些作业,因此经实测有效的成本控制机制是仅限本仓库的运行器组、手动基准测试触发、矩阵宽度和超时限制;该预算不被视为执行防护措施。 +最终生产验证产生的计费时长为:96 核 Linux 1 分钟、16 核 Windows 2 分钟和 32 核 Windows 3 分钟。按已配置的大型运行器费率计算,其大型运行器成本为 $0.902。全规格关键通道基准测试的成本为 $2.936。GitHub 会把每个大型运行器作业向上取整到整分钟计费,因此把付费作业数从 49 个减少到 3 个,与缩短仓库工作耗时同样重要。 -生产 CI 目前依赖本 Agent Note 和 `.github/workflows/ci.yml` 中由组织持有的运行器名称。池缺失或改名会让作业一直排队,不会静默回退到标准容量。256 的自动扩缩容上限允许未来提高并发量,但不会突破当前工作流对矩阵规模的限制。 +现有的零美元 Actions 预算并未阻止大型运行器作业。仅限本仓库的运行器组、有界的工作流拓扑、手动基准测试触发和作业超时限制才是经实测有效的成本控制机制;该预算不被视为执行防护措施。 -选定的运行器池以每次作业都需支付的溢价换取延迟余量。手动基准测试保留了所有规格,以便未来映像、依赖图或工作负载发生变化时,先完成测量,再次调整生产标签。 +生产 CI 依赖本 Agent Note 和 `.github/workflows/ci.yml` 中由组织持有的运行器名称。池缺失或改名会让作业一直排队,不会回退到标准容量。手动全规格套件和原分片套件均予以保留,以便在映像、依赖、调度器或定价发生变化后重新测量,再调整生产标签。 From 06827eb8686a6acd36d99a246bb6efd52efcd0a9 Mon Sep 17 00:00:00 2001 From: Turtle <turtle1999@deepseek.com> Date: Wed, 22 Jul 2026 15:19:35 +0800 Subject: [PATCH 62/90] test(e2e): drive the TUI keyless smoke through the cross-platform PTY harness MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The smoke's inline Python pty driver only ran on POSIX (no termios on Windows). Rebuild every scenario — banner sweep, scripted conversation with model switch, /skill:, Code Mode overlay, resume failure, and the dsh CLI suite (default boot, personal overlay, invalid overlay, --resume flag, source-path prompt) — as marker-gated action lists on pty-harness.ts, which drives ConPTY via node-pty on Windows and the Python driver elsewhere. The harness gains configArgs (bins with built-in default configs), prepare (workspace seeding), and inspect (post-run log assertions); examples/ declares the session-title provider the shipped cordis.yml now mounts. --- examples/package.json | 1 + .../tui-agent/tests/tui-keyless-smoke.e2e.ts | 410 +++++++----------- .../tests/workspace-context.spec.ts | 202 ++++----- packages/ui/tui/tests/harness.ts | 6 +- pnpm-lock.yaml | 3 + 5 files changed, 260 insertions(+), 362 deletions(-) diff --git a/examples/package.json b/examples/package.json index ec35ac39e6..ae22d0513d 100644 --- a/examples/package.json +++ b/examples/package.json @@ -37,6 +37,7 @@ "@deepseek-ai/dsh-spill-local": "workspace:*", "@deepseek-ai/dsh-spill-policy": "workspace:*", "@deepseek-ai/dsh-tui-demo": "workspace:*", + "@deepseek-ai/dsh-session-title-first-message-llm": "workspace:*", "@deepseek-ai/dsh-subagent": "workspace:*", "@deepseek-ai/dsh-subagent-acp": "workspace:*", "@deepseek-ai/dsh-subagent-fork": "workspace:*", diff --git a/examples/tui-agent/tests/tui-keyless-smoke.e2e.ts b/examples/tui-agent/tests/tui-keyless-smoke.e2e.ts index c069e72ae9..2fd1dd1467 100644 --- a/examples/tui-agent/tests/tui-keyless-smoke.e2e.ts +++ b/examples/tui-agent/tests/tui-keyless-smoke.e2e.ts @@ -1,10 +1,9 @@ -import { spawn } from 'node:child_process' -import { mkdir, mkdtemp, readdir, readFile, rm, writeFile } from 'node:fs/promises' -import { tmpdir } from 'node:os' +import { mkdir, readdir, readFile, writeFile } from 'node:fs/promises' import { dirname, join } from 'node:path' import { fileURLToPath } from 'node:url' import { describe, expect, it } from 'vitest' -import { LOADER_SMOKE_TEST_TIMEOUT_MS, resolveExampleLaunch } from '@deepseek-ai/dsh-loader-smoke' +import { LOADER_SMOKE_TEST_TIMEOUT_MS } from '@deepseek-ai/dsh-loader-smoke' +import { runTuiPtySmoke, type TuiPtySmokeOptions } from './pty-harness.ts' const binScript = fileURLToPath(new URL('../../../packages/examples/tui-demo/src/bin.ts', import.meta.url)) const dshBinScript = fileURLToPath(new URL('../../../apps/cli/src/bin.ts', import.meta.url)) @@ -13,204 +12,25 @@ const codeModeConfigPath = fileURLToPath(new URL('../code-mode.cordis.yml', impo const scriptedConfigPath = fileURLToPath(new URL('./fixtures/tui-scripted.cordis.yml', import.meta.url)) const tsconfigPath = fileURLToPath(new URL('../../../tsconfig.json', import.meta.url)) -const PTY_DRIVER = String.raw` -import errno, json, os, pty, select, signal, sys, time -node, launch_args_json, launch_env_json, cwd, resume_session_id, scenario, boot_marker = sys.argv[1:] -env = os.environ.copy() -env.update(json.loads(launch_env_json)) -env.update({ - "COLUMNS": "100", - "LINES": "30", -}) -# Deterministic banner: a developer shell's COLORTERM=truecolor would switch the -# banner to the per-letter gradient (one SGR per letter), breaking the literal -# DEEPSEEK assertions. The gradient path has its own unit and snapshot coverage. -env.pop("COLORTERM", None) -if resume_session_id: - env["RESUME_SESSION_ID"] = resume_session_id -pid, fd = pty.fork() -if pid == 0: - os.chdir(cwd) - os.execvpe(node, [node, *json.loads(launch_args_json)], env) - -output = bytearray() -answered_question = False -opened_selector = False -selected_model = False -sent_prompt = False -sent_exit = False -deadline = time.monotonic() + 25 -status = None -while time.monotonic() < deadline: - ready, _, _ = select.select([fd], [], [], 0.05) - if ready: - try: - chunk = os.read(fd, 65536) - except OSError as error: - if error.errno != errno.EIO: - raise - chunk = b"" - if chunk: - output.extend(chunk) - if scenario == "conversation" and not opened_selector and b"scripted TUI ready." in output: - os.write(fd, b"/model\r") - opened_selector = True - if scenario == "conversation" and opened_selector and not selected_model and b"Select model" in output: - os.write(fd, b"\x1b[B\r") - selected_model = True - if scenario == "conversation" and selected_model and not sent_prompt and b"Model selected: tui-scripted/tui-scripted-model-pro." in output: - os.write(fd, b"exercise the TUI\r") - sent_prompt = True - if scenario == "conversation" and sent_prompt and not answered_question and b"How should the scripted run proceed?" in output: - os.write(fd, b"\r") - answered_question = True - if scenario == "conversation" and answered_question and not sent_exit and b"Decision received. Scripted TUI run complete." in output: - os.write(fd, b"/exit\r") - sent_exit = True - if scenario == "skill" and not selected_model and b"scripted TUI ready." in output: - os.write(fd, b"/model tui-scripted/tui-scripted-model-pro\r") - selected_model = True - if scenario == "skill" and selected_model and not sent_prompt and b"Model selected: tui-scripted/tui-scripted-model-pro." in output: - os.write(fd, b"/skill:scripted-skill\r") - sent_prompt = True - if scenario == "skill" and sent_prompt and not sent_exit and b"Scripted skill body received." in output: - os.write(fd, b"/exit\r") - sent_exit = True - if scenario == "boot" and not sent_exit and boot_marker.encode() in output: - os.write(fd, b"/exit\r") - sent_exit = True - waited, candidate = os.waitpid(pid, os.WNOHANG) - if waited == pid: - status = candidate - break - -if status is None: - os.kill(pid, signal.SIGKILL) - _, status = os.waitpid(pid, 0) -sys.stdout.buffer.write(output) -if scenario == "resume-failure": - if b'ui-tui: session "missing-session" failed to start:' not in output: - sys.stderr.write("TUI did not render the startup failure before timeout\n") - sys.exit(126) - if not os.WIFEXITED(status) or os.WEXITSTATUS(status) != 1: - sys.stderr.write("TUI startup failure did not exit with status 1\n") - sys.exit(127) -elif scenario == "conversation": - if not sent_prompt: - sys.stderr.write("TUI did not render the scripted welcome marker before timeout\n") - sys.exit(128) - if not answered_question: - sys.stderr.write("TUI did not render the user-question dialog before timeout\n") - sys.exit(129) - if not sent_exit: - sys.stderr.write("TUI did not finish the scripted tool round-trip before timeout\n") - sys.exit(130) - if not os.WIFEXITED(status) or os.WEXITSTATUS(status) != 0: - sys.stderr.write("TUI scripted conversation did not exit cleanly\n") - sys.exit(131) -elif scenario == "skill": - if not sent_prompt: - sys.stderr.write("TUI did not render the scripted welcome marker before typing /skill:\n") - sys.exit(132) - if b"Scripted skill body received." not in output: - sys.stderr.write("TUI did not deliver the loaded skill body to the model before timeout\n") - sys.exit(133) - if not sent_exit: - sys.stderr.write("TUI did not reach idle to accept /exit after the skill turn\n") - sys.exit(134) - if not os.WIFEXITED(status) or os.WEXITSTATUS(status) != 0: - sys.stderr.write("TUI skill scenario did not exit cleanly\n") - sys.exit(135) -else: - if not sent_exit: - sys.stderr.write("TUI did not render its welcome marker before timeout\n") - sys.exit(124) - if not os.WIFEXITED(status) or os.WEXITSTATUS(status) != 0: - sys.stderr.write("TUI child did not exit cleanly\n") - sys.exit(125) -` - -interface TuiLoaderSmokeOptions { - config?: string - resumeSessionId?: string - scenario?: 'boot' | 'conversation' | 'resume-failure' | 'skill' - /** Welcome text the boot scenario waits for before sending `/exit`. */ - bootMarker?: string - /** Bin to boot; defaults to the tui-demo bin (the dsh CLI tests override). */ - srcBin?: string - /** Argument vector for the bin; defaults to `[config]`. */ - configArgs?: string[] - /** Files written into the isolated Harness home (`$DSH_HOME`) before launch. */ - personalFiles?: Record<string, string> - /** Skill bundles written under the isolated agents home (`.agents/skills/`) before launch, keyed by path below that root. */ - skillFiles?: Record<string, string> - /** Runs against the workspace `cwd` after a clean exit, before it is removed. */ - inspect?: (cwd: string) => Promise<void> -} - -async function runTuiLoaderSmoke(options: TuiLoaderSmokeOptions = {}): Promise<string> { - const cwd = await mkdtemp(join(tmpdir(), 'tui-agent-smoke-')) - try { - // Personal config is always isolated from the developer's real ~/.dsh; - // a test opts into an overlay by supplying files under the Harness home. - const dshHome = join(cwd, '.dsh') - for (const [name, content] of Object.entries(options.personalFiles ?? {})) { - await mkdir(dshHome, { recursive: true }) - await writeFile(join(dshHome, name), content) - } - // The child chdirs to this cwd and the scripted config roots fs-local here, - // so a skill dropped under DSH_AGENTS_HOME's `skills/` root is discoverable - // and its body readable through the same tree the model-facing stack uses. - const skillsRoot = join(cwd, '.agents', 'skills') - for (const [name, content] of Object.entries(options.skillFiles ?? {})) { - const file = join(skillsRoot, name) +/** + * Seed the harness workspace: personal files land in the isolated Harness home + * (`.dsh`), skill bundles under the agents home's `skills/` root — the same + * trees `$DSH_HOME` / `$DSH_AGENTS_HOME` point the child at. + */ +function seedWorkspace( + files: { personal?: Record<string, string>; skills?: Record<string, string> }, +): (cwd: string) => Promise<void> { + return async (cwd) => { + for (const [name, content] of Object.entries(files.personal ?? {})) { + const file = join(cwd, '.dsh', name) + await mkdir(dirname(file), { recursive: true }) + await writeFile(file, content) + } + for (const [name, content] of Object.entries(files.skills ?? {})) { + const file = join(cwd, '.agents', 'skills', name) await mkdir(dirname(file), { recursive: true }) await writeFile(file, content) } - const launch = resolveExampleLaunch({ - srcBin: options.srcBin ?? binScript, - configArgs: options.configArgs ?? [options.config ?? configPath], - tsconfigPath, - exposeInternals: true, - env: { - DEEPSEEK_API_KEY: 'keyless-tui-no-call', - DSH_HOME: dshHome, - DSH_AGENTS_HOME: join(cwd, '.agents'), - }, - }) - return await new Promise((resolve, reject) => { - const child = spawn('python3', [ - '-c', - PTY_DRIVER, - launch.command, - JSON.stringify(launch.args), - JSON.stringify(launch.env), - cwd, - options.resumeSessionId ?? '', - options.scenario ?? 'boot', - // With no configured welcome the borderless banner sweeps in; its - // detail line's session id (`main-session-<uuid>`) renders only once - // the sweep reaches it, so it marks a settled banner. - options.bootMarker ?? 'main-session-', - ], { stdio: ['ignore', 'pipe', 'pipe'] }) - let stdout = '' - let stderr = '' - child.stdout.setEncoding('utf8') - child.stdout.on('data', (chunk: string) => { stdout += chunk }) - child.stderr.setEncoding('utf8') - child.stderr.on('data', (chunk: string) => { stderr += chunk }) - child.once('error', reject) - child.once('exit', (code) => { - if (code !== 0) { - reject(new Error(`TUI PTY smoke exited ${String(code)}. stdout:\n${stdout}\nstderr:\n${stderr}`)) - return - } - // Inspect the workspace before `finally` removes it (e.g. the session log). - void (options.inspect?.(cwd) ?? Promise.resolve()).then(() => { resolve(stdout) }, reject) - }) - }) - } finally { - await rm(cwd, { recursive: true, force: true }) } } @@ -229,12 +49,35 @@ async function readLoggedSystemPrompt(cwd: string): Promise<string> { throw new Error(`session log ${logRelPath} has no request/header event`) } +/** Shared defaults: the keyless key, the tui-demo bin, and the live cordis.yml. */ +function smoke(overrides: Partial<TuiPtySmokeOptions> & { label: string }): Promise<string> { + return runTuiPtySmoke({ + tempDirPrefix: 'tui-agent-smoke-', + binScript, + configPath, + tsconfigPath, + env: { DEEPSEEK_API_KEY: 'keyless-tui-no-call' }, + ...overrides, + }) +} + +// The scripted conversation switches to the pro model first: the scripted +// adapter proves routing + prompt variables by rejecting tool-ful calls on any +// other route (see fixtures/tui-scripted-llm.ts). +const SELECT_PRO_MODEL = [ + { waitFor: 'scripted TUI ready.', send: '/model\r' }, + { waitFor: 'Select model', send: '\x1b[B\r' }, +] as const + describe('tui-agent keyless smoke (real Loader tree in a PTY)', () => { it('boots pi-tui, sweeps the borderless banner in, accepts /exit, and restores the terminal', async () => { - const output = await runTuiLoaderSmoke() // With no configured welcome the borderless banner sweeps in left-to-right; - // the boot scenario waits for the detail line's session id, which renders - // only once the sweep reaches it. + // the detail line's session id (`main-session-<uuid>`) renders only once + // the sweep reaches it, so it marks a settled banner. + const output = await smoke({ + label: 'tui-agent boot', + actions: [{ waitFor: 'main-session-', send: '/exit\r' }], + }) expect(output).toContain('DEEPSEEK') expect(output).toContain('HARNESS') expect(output).toContain('main-session-') @@ -244,8 +87,24 @@ describe('tui-agent keyless smoke (real Loader tree in a PTY)', () => { expect(output).toContain('\u001B[?2004l') }, LOADER_SMOKE_TEST_TIMEOUT_MS) - it('streams a response, answers a user-question dialog, completes the tool round-trip, and exits cleanly', async () => { - const output = await runTuiLoaderSmoke({ config: scriptedConfigPath, scenario: 'conversation' }) + it('switches models, streams a response, answers a user-question dialog, and exits cleanly', async () => { + const output = await smoke({ + label: 'tui-agent conversation', + tempDirPrefix: 'tui-agent-conversation-', + configPath: scriptedConfigPath, + actions: [ + ...SELECT_PRO_MODEL, + { waitFor: 'Model selected: tui-scripted/tui-scripted-model-pro.', send: 'exercise the TUI\r' }, + { waitFor: 'How should the scripted run proceed?', send: '\r' }, + { waitFor: 'Decision received. Scripted TUI run complete.', send: '' }, + // Session title: the first user message drives the first-message-llm + // provider's tool-less title call; the scripted adapter answers it, the + // accepted title lands in the log, and the TUI renders the terminal + // window title as `<session title> — <configured title>` via OSC 0. + // Gating /exit on it keeps the assertion race-free. + { waitFor: 'scripted session title — DeepSeek Harness', send: '/exit\r' }, + ], + }) expect(output).toContain('I need one decision before I continue.') expect(output).toContain(String.raw`\x1b]2;MODEL_CONTROLLED\x07`) expect(output).toContain(String.raw`\x1b[999CMODEL_CURSOR`) @@ -253,13 +112,7 @@ describe('tui-agent keyless smoke (real Loader tree in a PTY)', () => { expect(output).not.toContain('\u001B]2;MODEL_CONTROLLED\u0007') expect(output).not.toContain('\u001B[999CMODEL_CURSOR') expect(output).not.toContain('\u009B31mMODEL_C1') - expect(output).toContain('How should the scripted run proceed?') expect(output).toContain('Safe') - expect(output).toContain('Decision received. Scripted TUI run complete.') - // Session title: the first user message drives the first-message-llm - // provider's tool-less title call; the scripted adapter answers it, the - // accepted title lands in the log, and the TUI renders the terminal window - // title as `<session title> — <configured title>` via OSC 0. expect(output).toContain('\u001B]0;scripted session title — DeepSeek Harness\u0007') expect(output).toContain('\u001B[?2004l') }, LOADER_SMOKE_TEST_TIMEOUT_MS) @@ -270,20 +123,28 @@ describe('tui-agent keyless smoke (real Loader tree in a PTY)', () => { // the local provider loads `scripted-skill` from the agents home, and the // rendered `<skill name="…">` block reaches the model — proven by the // scripted adapter echoing the fixture's body marker only when it arrives. - const output = await runTuiLoaderSmoke({ - config: scriptedConfigPath, - scenario: 'skill', - skillFiles: { - 'scripted-skill/SKILL.md': [ - '---', - 'name: scripted-skill', - 'description: Keyless PTY proof that the skill command loads a local skill into the conversation.', - '---', - '', - 'SCRIPTED SKILL BODY MARKER', - '', - ].join('\n'), - }, + const output = await smoke({ + label: 'tui-agent skill', + tempDirPrefix: 'tui-agent-skill-', + configPath: scriptedConfigPath, + prepare: seedWorkspace({ + skills: { + 'scripted-skill/SKILL.md': [ + '---', + 'name: scripted-skill', + 'description: Keyless PTY proof that the skill command loads a local skill into the conversation.', + '---', + '', + 'SCRIPTED SKILL BODY MARKER', + '', + ].join('\n'), + }, + }), + actions: [ + ...SELECT_PRO_MODEL, + { waitFor: 'Model selected: tui-scripted/tui-scripted-model-pro.', send: '/skill:scripted-skill\r' }, + { waitFor: 'Scripted skill body received.', send: '/exit\r' }, + ], }) expect(output).toContain('Scripted skill body received.') expect(output).toContain('\u001B[?2004l') @@ -292,23 +153,39 @@ describe('tui-agent keyless smoke (real Loader tree in a PTY)', () => { it('boots the Code Mode overlay tree, renders its banner, and exits cleanly', async () => { // The overlay's only keyless composition proof: the include+patch tree, // worker code runtime, and one-tool registry all mount before the banner. - const output = await runTuiLoaderSmoke({ - config: codeModeConfigPath, - bootMarker: 'TUI Code Mode ready.', + const output = await smoke({ + label: 'tui-agent code mode', + tempDirPrefix: 'tui-agent-code-mode-', + configPath: codeModeConfigPath, + actions: [{ waitFor: 'TUI Code Mode ready.', send: '/exit\r' }], }) expect(output).toContain('TUI Code Mode ready.') expect(output).toContain('\u001B[?2004l') }, LOADER_SMOKE_TEST_TIMEOUT_MS) it('prints a config-resume failure and exits instead of leaving a blank terminal', async () => { - const output = await runTuiLoaderSmoke({ resumeSessionId: 'missing-session', scenario: 'resume-failure' }) + const output = await smoke({ + label: 'tui-agent resume failure', + tempDirPrefix: 'tui-agent-resume-', + env: { + DEEPSEEK_API_KEY: 'keyless-tui-no-call', + RESUME_SESSION_ID: 'missing-session', + }, + expectedExitCode: 1, + }) expect(output).toContain('ui-tui: session "missing-session" failed to start:') }, LOADER_SMOKE_TEST_TIMEOUT_MS) }) describe('dsh CLI keyless smoke (apps/cli through the same PTY)', () => { it('boots the shipped default config with no arguments and no personal overlay', async () => { - const output = await runTuiLoaderSmoke({ srcBin: dshBinScript, configArgs: [] }) + const output = await smoke({ + label: 'dsh default boot', + tempDirPrefix: 'dsh-default-boot-', + binScript: dshBinScript, + configArgs: [], + actions: [{ waitFor: 'main-session-', send: '/exit\r' }], + }) expect(output).toContain('DEEPSEEK') expect(output).toContain('main-session-') expect(output).not.toContain('╭') @@ -320,45 +197,55 @@ describe('dsh CLI keyless smoke (apps/cli through the same PTY)', () => { // The whole personal-config chain in one boot: the personal .env supplies // the variable, config.yaml patches the tui-agent entry with a `!!js` // reference to it, and the banner renders the patched welcome verbatim. - const output = await runTuiLoaderSmoke({ - srcBin: dshBinScript, + const output = await smoke({ + label: 'dsh personal overlay', + tempDirPrefix: 'dsh-personal-overlay-', + binScript: dshBinScript, configArgs: [], - bootMarker: 'PERSONAL OVERLAY READY.', - personalFiles: { - '.env': 'DSH_PERSONAL_WELCOME=PERSONAL OVERLAY READY.\n', - 'config.yaml': [ - '- id: tui-agent', - " name: '@deepseek-ai/dsh-tui-demo'", - ' config:', - ' provider: deepseek', - ' model: deepseek-v4-flash', - ' workspaceContext: false', - ' welcome: !!js process.env.DSH_PERSONAL_WELCOME', - '', - ].join('\n'), - }, + prepare: seedWorkspace({ + personal: { + '.env': 'DSH_PERSONAL_WELCOME=PERSONAL OVERLAY READY.\n', + 'config.yaml': [ + '- id: tui-agent', + " name: '@deepseek-ai/dsh-tui-demo'", + ' config:', + ' provider: deepseek', + ' model: deepseek-v4-flash', + ' workspaceContext: false', + ' welcome: !!js process.env.DSH_PERSONAL_WELCOME', + '', + ].join('\n'), + }, + }), + actions: [{ waitFor: 'PERSONAL OVERLAY READY.', send: '/exit\r' }], }) expect(output).toContain('PERSONAL OVERLAY READY.') expect(output).toContain('\u001B[?2004l') }, LOADER_SMOKE_TEST_TIMEOUT_MS) it('fails loud instead of booting when the personal config.yaml is invalid', async () => { - await expect(runTuiLoaderSmoke({ - srcBin: dshBinScript, + const output = await smoke({ + label: 'dsh invalid personal config', + tempDirPrefix: 'dsh-invalid-personal-', + binScript: dshBinScript, configArgs: [], - personalFiles: { 'config.yaml': 'id: not-a-list\n' }, - })).rejects.toThrow('must be a top-level YAML array of loader patch entries') + prepare: seedWorkspace({ personal: { 'config.yaml': 'id: not-a-list\n' } }), + expectedExitCode: 1, + }) + expect(output).toContain('must be a top-level YAML array of loader patch entries') }, LOADER_SMOKE_TEST_TIMEOUT_MS) it('routes the --resume flag into the config resume intake, failing loud on a missing id', async () => { // The flag path end to end: apps/cli parses `--resume missing-session` and - // sets RESUME_SESSION_ID (the PTY driver does NOT here), the shipped - // config's `!!js` reads it, and the resume fails loud — proving the printed - // `dsh --resume <id>` hint reaches the same intake as the env var. - const output = await runTuiLoaderSmoke({ - srcBin: dshBinScript, + // sets RESUME_SESSION_ID, the shipped config's `!!js` reads it, and the + // resume fails loud — proving the printed `dsh --resume <id>` hint reaches + // the same intake as the env var. + const output = await smoke({ + label: 'dsh resume flag failure', + tempDirPrefix: 'dsh-resume-flag-', + binScript: dshBinScript, configArgs: ['--resume', 'missing-session'], - scenario: 'resume-failure', + expectedExitCode: 1, }) expect(output).toContain('ui-tui: session "missing-session" failed to start:') }, LOADER_SMOKE_TEST_TIMEOUT_MS) @@ -368,10 +255,17 @@ describe('dsh CLI keyless smoke (apps/cli through the same PTY)', () => { // this test file sits an equal depth under the same root, so the same hop applies. const sourceRoot = fileURLToPath(new URL('../../..', import.meta.url)) let loggedSystem = '' - await runTuiLoaderSmoke({ - srcBin: dshBinScript, + await smoke({ + label: 'dsh source-path prompt', + tempDirPrefix: 'dsh-source-path-', + binScript: dshBinScript, configArgs: [scriptedConfigPath], - scenario: 'conversation', + actions: [ + ...SELECT_PRO_MODEL, + { waitFor: 'Model selected: tui-scripted/tui-scripted-model-pro.', send: 'exercise the TUI\r' }, + { waitFor: 'How should the scripted run proceed?', send: '\r' }, + { waitFor: 'Decision received. Scripted TUI run complete.', send: '/exit\r' }, + ], inspect: async (cwd) => { loggedSystem = await readLoggedSystemPrompt(cwd) }, }) expect(loggedSystem).toContain(`Your own source code is the checkout at ${sourceRoot}; you can read it there to learn how dsh works and how to extend it.`) diff --git a/packages/context/workspace-context/tests/workspace-context.spec.ts b/packages/context/workspace-context/tests/workspace-context.spec.ts index f902bdaef0..e71147c693 100644 --- a/packages/context/workspace-context/tests/workspace-context.spec.ts +++ b/packages/context/workspace-context/tests/workspace-context.spec.ts @@ -278,8 +278,8 @@ describe('workspace context instruction discovery', () => { '$DSH_HOME/AGENTS.md', 'AGENTS.md', 'CLAUDE.md', - 'packages/CLAUDE.md', - 'packages/app/AGENTS.md', + join('packages', 'CLAUDE.md'), + join('packages', 'app', 'AGENTS.md'), ]) expect(files.map(file => file.absolutePath)).toContain(join(root, 'CLAUDE.md')) } finally { @@ -304,8 +304,8 @@ describe('workspace context instruction discovery', () => { expect(files.map(file => file.displayPath)).toEqual([ 'AGENTS.md', 'AGENTS.local.md', - 'pkg/CLAUDE.md', - 'pkg/CLAUDE.local.md', + join('pkg', 'CLAUDE.md'), + join('pkg', 'CLAUDE.local.md'), ]) } finally { await rm(root, { recursive: true, force: true }) @@ -855,7 +855,7 @@ describe('workspace context request injection', () => { signal: testToolSignal, callId: CallId('no-fs-post-execute'), name: 'read', - arguments: { file_path: 'pkg/file.txt' }, + arguments: { file_path: join('pkg', 'file.txt') }, agent: stubAgent('/virtual/repo'), }), { isError: false, @@ -891,7 +891,7 @@ describe('workspace context request injection', () => { signal: testToolSignal, callId: CallId('read-blocked-post-execute'), name: 'read', - arguments: { file_path: 'pkg/file.txt' }, + arguments: { file_path: join('pkg', 'file.txt') }, agent, }) const result = { @@ -989,7 +989,7 @@ describe('workspace context request injection', () => { await composeBaselinePrefix(ctx, agent) expect(derivedText(agent)).toContain('omitted AGENTS.md') - expect(derivedText(agent)).toContain('Instructions from: pkg/AGENTS.md\n\npackage rule') + expect(derivedText(agent)).toContain(`Instructions from: ${join('pkg', 'AGENTS.md')}\n\npackage rule`) } finally { await rm(root, { recursive: true, force: true }) await rm(home, { recursive: true, force: true }) @@ -1479,7 +1479,7 @@ describe('workspace context request injection', () => { await composeBaselinePrefix(ctx, agent) expect(derivedText(agent)).toContain('Instructions from: AGENTS.md\n\nroot schema default rule') - expect(derivedText(agent)).toContain('Instructions from: child/AGENTS.md\n\nchild schema default rule') + expect(derivedText(agent)).toContain(`Instructions from: ${join('child', 'AGENTS.md')}\n\nchild schema default rule`) await ctx.fiber.dispose() } finally { await rm(root, { recursive: true, force: true }) @@ -1680,7 +1680,7 @@ describe('dynamic nested workspace context injection', () => { { type: 'block-end', index: 1, block: { type: 'tool-call', id: CallId('abort-after-read'), name: 'abort_step', arguments: '{}' } }, { type: 'finish', reason: { kind: 'tool-calls' } }, ] satisfies StreamChunk[], - toolCallResponse('read-after-abort', 'read', { file_path: 'pkg/deep/file.txt' }), + toolCallResponse('read-after-abort', 'read', { file_path: join('pkg', 'deep', 'file.txt') }), textResponse('done'), ]) await ctx.plugin(LlmService) @@ -1757,7 +1757,7 @@ describe('dynamic nested workspace context injection', () => { const exec = stubToolExecution({ callId: CallId('cancelled-dynamic-read'), name: 'read', - arguments: { file_path: 'pkg/file.txt' }, + arguments: { file_path: join('pkg', 'file.txt') }, agent: stubAgent(root), signal: controller.signal, }) @@ -1792,7 +1792,7 @@ describe('dynamic nested workspace context injection', () => { signal: testToolSignal, callId: CallId('read-nested'), name: 'read', - arguments: { file_path: 'pkg/deep/file.txt' }, + arguments: { file_path: join('pkg', 'deep', 'file.txt') }, agent, }) @@ -1804,7 +1804,7 @@ describe('dynamic nested workspace context injection', () => { changes: [{ action: 'set', scope: sk('pkg', 'AGENTS.md'), - path: 'pkg/AGENTS.md', + path: join('pkg', 'AGENTS.md'), }], }) const meta = workspaceContextOf(result)?.meta @@ -1852,16 +1852,16 @@ describe('dynamic nested workspace context injection', () => { signal: testToolSignal, callId: CallId('read-configured-nested-candidate'), name: 'read', - arguments: { file_path: 'pkg/deep/file.txt' }, + arguments: { file_path: join('pkg', 'deep', 'file.txt') }, agent: stubAgent(root), }) const text = blocksText(workspaceContextOf(result)?.content) - expect(text).toContain('Additional instructions from: pkg/CLAUDE.local.md') + expect(text).toContain(`Additional instructions from: ${join('pkg', 'CLAUDE.local.md')}`) expect(text).toContain('local package rule') - expect(text).toContain('Additional instructions from: pkg/AGENTS.md') + expect(text).toContain(`Additional instructions from: ${join('pkg', 'AGENTS.md')}`) expect(text).toContain('native package rule') - expect(text.indexOf('pkg/CLAUDE.local.md')).toBeLessThan(text.indexOf('pkg/AGENTS.md')) + expect(text.indexOf(join('pkg', 'CLAUDE.local.md'))).toBeLessThan(text.indexOf(join('pkg', 'AGENTS.md'))) } finally { await rm(root, { recursive: true, force: true }) await rm(home, { recursive: true, force: true }) @@ -1884,7 +1884,7 @@ describe('dynamic nested workspace context injection', () => { signal: testToolSignal, callId: CallId('read-nested-overlay'), name: 'read', - arguments: { file_path: 'pkg/deep/file.txt' }, + arguments: { file_path: join('pkg', 'deep', 'file.txt') }, agent: stubAgent(root), }) @@ -1893,13 +1893,13 @@ describe('dynamic nested workspace context injection', () => { ? meta.changes : [] expect(changes).toEqual(expect.arrayContaining([ - expect.objectContaining({ action: 'set', path: 'pkg/AGENTS.md' }), - expect.objectContaining({ action: 'set', path: 'pkg/AGENTS.local.md' }), + expect.objectContaining({ action: 'set', path: join('pkg', 'AGENTS.md') }), + expect.objectContaining({ action: 'set', path: join('pkg', 'AGENTS.local.md') }), ])) const text = blocksText(workspaceContextOf(result)?.content) - expect(text).toContain('Additional instructions from: pkg/AGENTS.md') + expect(text).toContain(`Additional instructions from: ${join('pkg', 'AGENTS.md')}`) expect(text).toContain('nested base rule') - expect(text).toContain('Additional instructions from: pkg/AGENTS.local.md') + expect(text).toContain(`Additional instructions from: ${join('pkg', 'AGENTS.local.md')}`) expect(text).toContain('nested local rule') } finally { await rm(root, { recursive: true, force: true }) @@ -1926,13 +1926,13 @@ describe('dynamic nested workspace context injection', () => { signal: testToolSignal, callId: CallId('read-nested-overlay-disabled'), name: 'read', - arguments: { file_path: 'pkg/deep/file.txt' }, + arguments: { file_path: join('pkg', 'deep', 'file.txt') }, agent: stubAgent(root), }) const text = blocksText(workspaceContextOf(result)?.content) - expect(text).toContain('Additional instructions from: pkg/AGENTS.md') - expect(text).not.toContain('pkg/AGENTS.local.md') + expect(text).toContain(`Additional instructions from: ${join('pkg', 'AGENTS.md')}`) + expect(text).not.toContain(join('pkg', 'AGENTS.local.md')) } finally { await rm(root, { recursive: true, force: true }) await rm(home, { recursive: true, force: true }) @@ -1954,14 +1954,14 @@ describe('dynamic nested workspace context injection', () => { signal: testToolSignal, callId: CallId('read-nested-1'), name: 'read', - arguments: { file_path: 'pkg/deep/file.txt' }, + arguments: { file_path: join('pkg', 'deep', 'file.txt') }, agent, }) const second = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('read-nested-2'), name: 'read', - arguments: { file_path: 'pkg/deep/file.txt' }, + arguments: { file_path: join('pkg', 'deep', 'file.txt') }, agent, }) @@ -1992,12 +1992,12 @@ describe('dynamic nested workspace context injection', () => { const first = await ctx.tools.execute({ signal: testToolSignal, - callId: CallId('read-before-version-fast-path'), name: 'read', arguments: { file_path: 'pkg/file.txt' }, agent, + callId: CallId('read-before-version-fast-path'), name: 'read', arguments: { file_path: join('pkg', 'file.txt') }, agent, }) appendAdditionalContexts(agent, first) const second = await ctx.tools.execute({ signal: testToolSignal, - callId: CallId('read-with-version-fast-path'), name: 'read', arguments: { file_path: 'pkg/file.txt' }, agent, + callId: CallId('read-with-version-fast-path'), name: 'read', arguments: { file_path: join('pkg', 'file.txt') }, agent, }) expect(first.additionalContexts).toBeDefined() @@ -2029,17 +2029,17 @@ describe('dynamic nested workspace context injection', () => { const first = await ctx.tools.execute({ signal: testToolSignal, - callId: CallId('read-before-same-digest-version-change'), name: 'read', arguments: { file_path: 'pkg/file.txt' }, agent, + callId: CallId('read-before-same-digest-version-change'), name: 'read', arguments: { file_path: join('pkg', 'file.txt') }, agent, }) appendAdditionalContexts(agent, first) fs.entries.set(instructionPath, { type: 'file', content: 'same package rule', version: FsVersion('revision-2') }) const afterVersionChange = await ctx.tools.execute({ signal: testToolSignal, - callId: CallId('read-after-same-digest-version-change'), name: 'read', arguments: { file_path: 'pkg/file.txt' }, agent, + callId: CallId('read-after-same-digest-version-change'), name: 'read', arguments: { file_path: join('pkg', 'file.txt') }, agent, }) const afterRefresh = await ctx.tools.execute({ signal: testToolSignal, - callId: CallId('read-after-version-cache-refresh'), name: 'read', arguments: { file_path: 'pkg/file.txt' }, agent, + callId: CallId('read-after-version-cache-refresh'), name: 'read', arguments: { file_path: join('pkg', 'file.txt') }, agent, }) expect(afterVersionChange.additionalContexts).toBeUndefined() @@ -2070,11 +2070,11 @@ describe('dynamic nested workspace context injection', () => { const first = await ctx.tools.execute({ signal: testToolSignal, - callId: CallId('read-from-first-session'), name: 'read', arguments: { file_path: 'pkg/file.txt' }, agent: stubAgent(root), + callId: CallId('read-from-first-session'), name: 'read', arguments: { file_path: join('pkg', 'file.txt') }, agent: stubAgent(root), }) const second = await ctx.tools.execute({ signal: testToolSignal, - callId: CallId('read-from-second-session'), name: 'read', arguments: { file_path: 'pkg/file.txt' }, agent: stubAgent(root), + callId: CallId('read-from-second-session'), name: 'read', arguments: { file_path: join('pkg', 'file.txt') }, agent: stubAgent(root), }) expect(first.additionalContexts).toBeDefined() @@ -2100,18 +2100,18 @@ describe('dynamic nested workspace context injection', () => { const first = await ctx.tools.execute({ signal: testToolSignal, - callId: CallId('read-before-change'), name: 'read', arguments: { file_path: 'pkg/file.txt' }, agent, + callId: CallId('read-before-change'), name: 'read', arguments: { file_path: join('pkg', 'file.txt') }, agent, }) appendAdditionalContexts(agent, first) await write(join(root, 'pkg/AGENTS.md'), 'new package rule with more detail') const changed = await ctx.tools.execute({ signal: testToolSignal, - callId: CallId('read-after-change'), name: 'read', arguments: { file_path: 'pkg/file.txt' }, agent, + callId: CallId('read-after-change'), name: 'read', arguments: { file_path: join('pkg', 'file.txt') }, agent, }) expect(workspaceContextOf(changed)?.meta).toMatchObject({ kind: 'workspace-instructions', - changes: [{ action: 'replace', scope: sk('pkg', 'AGENTS.md'), path: 'pkg/AGENTS.md' }], + changes: [{ action: 'replace', scope: sk('pkg', 'AGENTS.md'), path: join('pkg', 'AGENTS.md') }], }) expect(blocksText(workspaceContextOf(changed)?.content)).toBe([ '<system-reminder>', @@ -2142,7 +2142,7 @@ describe('dynamic nested workspace context injection', () => { const first = await ctx.tools.execute({ signal: testToolSignal, - callId: CallId('read-both-siblings'), name: 'read', arguments: { file_path: 'pkg/file.txt' }, agent, + callId: CallId('read-both-siblings'), name: 'read', arguments: { file_path: join('pkg', 'file.txt') }, agent, }) const firstText = blocksText(workspaceContextOf(first)?.content) expect(firstText).toContain('native package rule') @@ -2151,14 +2151,14 @@ describe('dynamic nested workspace context injection', () => { await rm(join(root, 'pkg/AGENTS.md')) const removed = await ctx.tools.execute({ signal: testToolSignal, - callId: CallId('read-after-one-sibling-removed'), name: 'read', arguments: { file_path: 'pkg/file.txt' }, agent, + callId: CallId('read-after-one-sibling-removed'), name: 'read', arguments: { file_path: join('pkg', 'file.txt') }, agent, }) // Removing one candidate only removes its own scope; the sibling scope is untouched. expect(workspaceContextOf(removed)?.meta).toMatchObject({ - changes: [{ action: 'remove', scope: sk('pkg', 'AGENTS.md'), path: 'pkg/AGENTS.md' }], + changes: [{ action: 'remove', scope: sk('pkg', 'AGENTS.md'), path: join('pkg', 'AGENTS.md') }], }) - expect(blocksText(workspaceContextOf(removed)?.content)).toContain('Instructions removed: pkg/AGENTS.md') + expect(blocksText(workspaceContextOf(removed)?.content)).toContain(`Instructions removed: ${join('pkg', 'AGENTS.md')}`) expect(blocksText(workspaceContextOf(removed)?.content)).not.toContain('sibling package rule') } finally { await rm(root, { recursive: true, force: true }) @@ -2180,16 +2180,16 @@ describe('dynamic nested workspace context injection', () => { const result = await ctx.tools.execute({ signal: testToolSignal, - callId: CallId('read-nested-dup-siblings'), name: 'read', arguments: { file_path: 'pkg/deep/file.txt' }, agent, + callId: CallId('read-nested-dup-siblings'), name: 'read', arguments: { file_path: join('pkg', 'deep', 'file.txt') }, agent, }) expect(workspaceContextOf(result)?.meta).toMatchObject({ - changes: [{ action: 'set', scope: sk('pkg', 'AGENTS.md'), path: 'pkg/AGENTS.md' }], + changes: [{ action: 'set', scope: sk('pkg', 'AGENTS.md'), path: join('pkg', 'AGENTS.md') }], }) const text = blocksText(workspaceContextOf(result)?.content) expect(text.match(/nested rule/g)).toHaveLength(1) - expect(text).toContain('Additional instructions from: pkg/AGENTS.md') - expect(text).not.toContain('pkg/CLAUDE.md') + expect(text).toContain(`Additional instructions from: ${join('pkg', 'AGENTS.md')}`) + expect(text).not.toContain(join('pkg', 'CLAUDE.md')) } finally { await rm(root, { recursive: true, force: true }) await rm(home, { recursive: true, force: true }) @@ -2210,7 +2210,7 @@ describe('dynamic nested workspace context injection', () => { const first = await ctx.tools.execute({ signal: testToolSignal, - callId: CallId('read-before-dup-convergence'), name: 'read', arguments: { file_path: 'pkg/file.txt' }, agent, + callId: CallId('read-before-dup-convergence'), name: 'read', arguments: { file_path: join('pkg', 'file.txt') }, agent, }) const firstText = blocksText(workspaceContextOf(first)?.content) expect(firstText).toContain('canonical nested rule') @@ -2219,13 +2219,13 @@ describe('dynamic nested workspace context injection', () => { await write(join(root, 'pkg/CLAUDE.md'), 'canonical nested rule') const converged = await ctx.tools.execute({ signal: testToolSignal, - callId: CallId('read-after-dup-convergence'), name: 'read', arguments: { file_path: 'pkg/file.txt' }, agent, + callId: CallId('read-after-dup-convergence'), name: 'read', arguments: { file_path: join('pkg', 'file.txt') }, agent, }) expect(workspaceContextOf(converged)?.meta).toMatchObject({ - changes: [{ action: 'remove', scope: sk('pkg', 'CLAUDE.md'), path: 'pkg/CLAUDE.md' }], + changes: [{ action: 'remove', scope: sk('pkg', 'CLAUDE.md'), path: join('pkg', 'CLAUDE.md') }], }) - expect(blocksText(workspaceContextOf(converged)?.content)).toContain('Instructions removed: pkg/CLAUDE.md') + expect(blocksText(workspaceContextOf(converged)?.content)).toContain(`Instructions removed: ${join('pkg', 'CLAUDE.md')}`) } finally { await rm(root, { recursive: true, force: true }) await rm(home, { recursive: true, force: true }) @@ -2246,25 +2246,25 @@ describe('dynamic nested workspace context injection', () => { const first = await ctx.tools.execute({ signal: testToolSignal, - callId: CallId('read-before-earlier-converges'), name: 'read', arguments: { file_path: 'pkg/file.txt' }, agent, + callId: CallId('read-before-earlier-converges'), name: 'read', arguments: { file_path: join('pkg', 'file.txt') }, agent, }) appendAdditionalContexts(agent, first) // Only the earlier candidate changes; the sibling stays byte-identical but now duplicates it. await write(join(root, 'pkg/AGENTS.md'), 'secondary nested rule') const converged = await ctx.tools.execute({ signal: testToolSignal, - callId: CallId('read-after-earlier-converges'), name: 'read', arguments: { file_path: 'pkg/file.txt' }, agent, + callId: CallId('read-after-earlier-converges'), name: 'read', arguments: { file_path: join('pkg', 'file.txt') }, agent, }) expect(workspaceContextOf(converged)?.meta).toMatchObject({ changes: [ - { action: 'replace', scope: sk('pkg', 'AGENTS.md'), path: 'pkg/AGENTS.md' }, - { action: 'remove', scope: sk('pkg', 'CLAUDE.md'), path: 'pkg/CLAUDE.md' }, + { action: 'replace', scope: sk('pkg', 'AGENTS.md'), path: join('pkg', 'AGENTS.md') }, + { action: 'remove', scope: sk('pkg', 'CLAUDE.md'), path: join('pkg', 'CLAUDE.md') }, ], }) const text = blocksText(workspaceContextOf(converged)?.content) - expect(text).toContain('Instructions removed: pkg/CLAUDE.md') - expect(text).toContain('Updated instructions from: pkg/AGENTS.md') + expect(text).toContain(`Instructions removed: ${join('pkg', 'CLAUDE.md')}`) + expect(text).toContain(`Updated instructions from: ${join('pkg', 'AGENTS.md')}`) } finally { await rm(root, { recursive: true, force: true }) await rm(home, { recursive: true, force: true }) @@ -2284,19 +2284,19 @@ describe('dynamic nested workspace context injection', () => { const first = await ctx.tools.execute({ signal: testToolSignal, - callId: CallId('read-before-remove'), name: 'read', arguments: { file_path: 'pkg/file.txt' }, agent, + callId: CallId('read-before-remove'), name: 'read', arguments: { file_path: join('pkg', 'file.txt') }, agent, }) appendAdditionalContexts(agent, first) await rm(join(root, 'pkg/AGENTS.md')) const removed = await ctx.tools.execute({ signal: testToolSignal, - callId: CallId('read-after-remove'), name: 'read', arguments: { file_path: 'pkg/file.txt' }, agent, + callId: CallId('read-after-remove'), name: 'read', arguments: { file_path: join('pkg', 'file.txt') }, agent, }) expect(workspaceContextOf(removed)?.meta).toEqual({ kind: 'workspace-instructions', version: 1, - changes: [{ action: 'remove', scope: sk('pkg', 'AGENTS.md'), path: 'pkg/AGENTS.md' }], + changes: [{ action: 'remove', scope: sk('pkg', 'AGENTS.md'), path: join('pkg', 'AGENTS.md') }], }) expect(blocksText(workspaceContextOf(removed)?.content)).toBe([ '<system-reminder>', @@ -2324,7 +2324,7 @@ describe('dynamic nested workspace context injection', () => { const first = await ctx.tools.execute({ signal: testToolSignal, - callId: CallId('read-before-symlink-dir'), name: 'read', arguments: { file_path: 'pkg/file.txt' }, agent, + callId: CallId('read-before-symlink-dir'), name: 'read', arguments: { file_path: join('pkg', 'file.txt') }, agent, }) appendAdditionalContexts(agent, first) expect(blocksText(workspaceContextOf(first)?.content)).toContain('package rule') @@ -2337,13 +2337,13 @@ describe('dynamic nested workspace context injection', () => { await symlink(join(root, 'pkg/elsewhere'), join(root, 'pkg/AGENTS.md')) const removed = await ctx.tools.execute({ signal: testToolSignal, - callId: CallId('read-after-symlink-dir'), name: 'read', arguments: { file_path: 'pkg/file.txt' }, agent, + callId: CallId('read-after-symlink-dir'), name: 'read', arguments: { file_path: join('pkg', 'file.txt') }, agent, }) expect(workspaceContextOf(removed)?.meta).toMatchObject({ - changes: [{ action: 'remove', scope: sk('pkg', 'AGENTS.md'), path: 'pkg/AGENTS.md' }], + changes: [{ action: 'remove', scope: sk('pkg', 'AGENTS.md'), path: join('pkg', 'AGENTS.md') }], }) - expect(blocksText(workspaceContextOf(removed)?.content)).toContain('Instructions removed: pkg/AGENTS.md') + expect(blocksText(workspaceContextOf(removed)?.content)).toContain(`Instructions removed: ${join('pkg', 'AGENTS.md')}`) } finally { await rm(root, { recursive: true, force: true }) await rm(home, { recursive: true, force: true }) @@ -2363,26 +2363,26 @@ describe('dynamic nested workspace context injection', () => { const first = await ctx.tools.execute({ signal: testToolSignal, - callId: CallId('read-before-tombstone'), name: 'read', arguments: { file_path: 'pkg/file.txt' }, agent, + callId: CallId('read-before-tombstone'), name: 'read', arguments: { file_path: join('pkg', 'file.txt') }, agent, }) appendAdditionalContexts(agent, first) await rm(join(root, 'pkg/AGENTS.md')) const removed = await ctx.tools.execute({ signal: testToolSignal, - callId: CallId('read-to-create-tombstone'), name: 'read', arguments: { file_path: 'pkg/file.txt' }, agent, + callId: CallId('read-to-create-tombstone'), name: 'read', arguments: { file_path: join('pkg', 'file.txt') }, agent, }) appendAdditionalContexts(agent, removed) await write(join(root, 'pkg/AGENTS.md'), 'restored package rule') const restored = await ctx.tools.execute({ signal: testToolSignal, - callId: CallId('read-after-tombstone'), name: 'read', arguments: { file_path: 'pkg/file.txt' }, agent, + callId: CallId('read-after-tombstone'), name: 'read', arguments: { file_path: join('pkg', 'file.txt') }, agent, }) expect(workspaceContextOf(restored)?.meta).toMatchObject({ - changes: [{ action: 'set', scope: sk('pkg', 'AGENTS.md'), path: 'pkg/AGENTS.md' }], + changes: [{ action: 'set', scope: sk('pkg', 'AGENTS.md'), path: join('pkg', 'AGENTS.md') }], }) - expect(blocksText(workspaceContextOf(restored)?.content)).toContain('Additional instructions from: pkg/AGENTS.md') + expect(blocksText(workspaceContextOf(restored)?.content)).toContain(`Additional instructions from: ${join('pkg', 'AGENTS.md')}`) expect(blocksText(workspaceContextOf(restored)?.content)).toContain('restored package rule') } finally { await rm(root, { recursive: true, force: true }) @@ -2408,13 +2408,13 @@ describe('dynamic nested workspace context injection', () => { const first = await ctx.tools.execute({ signal: testToolSignal, - callId: CallId('read-before-provider-failure'), name: 'read', arguments: { file_path: 'pkg/file.txt' }, agent, + callId: CallId('read-before-provider-failure'), name: 'read', arguments: { file_path: join('pkg', 'file.txt') }, agent, }) appendAdditionalContexts(agent, first) fs.throwOnStat.add(join(root, 'pkg/AGENTS.md')) const duringFailure = await ctx.tools.execute({ signal: testToolSignal, - callId: CallId('read-during-provider-failure'), name: 'read', arguments: { file_path: 'pkg/file.txt' }, agent, + callId: CallId('read-during-provider-failure'), name: 'read', arguments: { file_path: join('pkg', 'file.txt') }, agent, }) expect(first.additionalContexts).toBeDefined() @@ -2440,7 +2440,7 @@ describe('dynamic nested workspace context injection', () => { signal: testToolSignal, callId: CallId('read-before-resume'), name: 'read', - arguments: { file_path: 'pkg/deep/file.txt' }, + arguments: { file_path: join('pkg', 'deep', 'file.txt') }, agent, }) appendAdditionalContexts(agent, first) @@ -2453,7 +2453,7 @@ describe('dynamic nested workspace context injection', () => { signal: testToolSignal, callId: CallId('read-after-resume'), name: 'read', - arguments: { file_path: 'pkg/deep/file.txt' }, + arguments: { file_path: join('pkg', 'deep', 'file.txt') }, agent: resumed, }) @@ -2477,7 +2477,7 @@ describe('dynamic nested workspace context injection', () => { const original = stubAgent(root) const first = await ctx.tools.execute({ signal: testToolSignal, - callId: CallId('read-before-offline-change'), name: 'read', arguments: { file_path: 'pkg/file.txt' }, agent: original, + callId: CallId('read-before-offline-change'), name: 'read', arguments: { file_path: join('pkg', 'file.txt') }, agent: original, }) appendAdditionalContexts(original, first) await write(join(root, 'pkg/AGENTS.md'), 'new nested rule after resume') @@ -2487,7 +2487,7 @@ describe('dynamic nested workspace context injection', () => { const update = resumed.session.events.findLast(event => event.type === 'context/message') expect(update?.type === 'context/message' && update.data.meta).toMatchObject({ - changes: [{ action: 'replace', scope: sk('pkg', 'AGENTS.md'), path: 'pkg/AGENTS.md' }], + changes: [{ action: 'replace', scope: sk('pkg', 'AGENTS.md'), path: join('pkg', 'AGENTS.md') }], }) expect(update?.type === 'context/message' && blocksText(update.data.content)).toContain('new nested rule after resume') } finally { @@ -2510,7 +2510,7 @@ describe('dynamic nested workspace context injection', () => { signal: testToolSignal, callId: CallId('read-before-compact'), name: 'read', - arguments: { file_path: 'pkg/deep/file.txt' }, + arguments: { file_path: join('pkg', 'deep', 'file.txt') }, agent, }) const contextSeq = appendAdditionalContexts(agent, first)! @@ -2518,7 +2518,7 @@ describe('dynamic nested workspace context injection', () => { signal: testToolSignal, callId: CallId('read-while-visible'), name: 'read', - arguments: { file_path: 'pkg/deep/file.txt' }, + arguments: { file_path: join('pkg', 'deep', 'file.txt') }, agent, }) @@ -2534,7 +2534,7 @@ describe('dynamic nested workspace context injection', () => { signal: testToolSignal, callId: CallId('read-after-compact'), name: 'read', - arguments: { file_path: 'pkg/deep/file.txt' }, + arguments: { file_path: join('pkg', 'deep', 'file.txt') }, agent, }) @@ -2564,7 +2564,7 @@ describe('dynamic nested workspace context injection', () => { signal: testToolSignal, callId: CallId('read-package'), name: 'read', - arguments: { file_path: 'pkg/file.txt' }, + arguments: { file_path: join('pkg', 'file.txt') }, agent, }) appendAdditionalContexts(agent, first) @@ -2573,7 +2573,7 @@ describe('dynamic nested workspace context injection', () => { signal: testToolSignal, callId: CallId('read-subtree'), name: 'read', - arguments: { file_path: 'pkg/sub/file.txt' }, + arguments: { file_path: join('pkg', 'sub', 'file.txt') }, agent, }) @@ -2601,7 +2601,7 @@ describe('dynamic nested workspace context injection', () => { signal: testToolSignal, callId: CallId('read-subtree-omitting-parent'), name: 'read', - arguments: { file_path: 'pkg/sub/file.txt' }, + arguments: { file_path: join('pkg', 'sub', 'file.txt') }, agent, }) appendAdditionalContexts(agent, first) @@ -2610,13 +2610,13 @@ describe('dynamic nested workspace context injection', () => { signal: testToolSignal, callId: CallId('read-parent-after-omit'), name: 'read', - arguments: { file_path: 'pkg/other.txt' }, + arguments: { file_path: join('pkg', 'other.txt') }, agent, }) const firstText = blocksText(workspaceContextOf(first)?.content) - expect(firstText).toContain('omitted pkg/AGENTS.md') - expect(firstText).not.toContain('## pkg/AGENTS.md') + expect(firstText).toContain(`omitted ${join('pkg', 'AGENTS.md')}`) + expect(firstText).not.toContain(`## ${join('pkg', 'AGENTS.md')}`) expect(firstText).toContain('subtree rule') expect(blocksText(workspaceContextOf(second)?.content)).toContain('parent rule') } finally { @@ -2646,9 +2646,9 @@ describe('dynamic nested workspace context injection', () => { version: 1, changes: [ null, - { action: 'unknown', scope: 'pkg', path: 'pkg/AGENTS.md' }, + { action: 'unknown', scope: 'pkg', path: join('pkg', 'AGENTS.md') }, { action: 'set', scope: 'pkg', path: 42 }, - { action: 'set', scope: 'pkg', path: 'pkg/AGENTS.md', digest: 42 }, + { action: 'set', scope: 'pkg', path: join('pkg', 'AGENTS.md'), digest: 42 }, ], }, }, { surfaceOp: 'append' }) @@ -2663,7 +2663,7 @@ describe('dynamic nested workspace context injection', () => { meta: { kind: 'workspace-instructions', version: 1, - changes: [{ action: 'set', scope: 'pkg', path: 'pkg/AGENTS.md', digest: 'spoof' }], + changes: [{ action: 'set', scope: 'pkg', path: join('pkg', 'AGENTS.md'), digest: 'spoof' }], }, }, { surfaceOp: 'append' }) @@ -2671,7 +2671,7 @@ describe('dynamic nested workspace context injection', () => { signal: testToolSignal, callId: CallId('read-after-spoofed-state'), name: 'read', - arguments: { file_path: 'pkg/deep/file.txt' }, + arguments: { file_path: join('pkg', 'deep', 'file.txt') }, agent, }) @@ -2736,13 +2736,13 @@ describe('dynamic nested workspace context injection', () => { const failedStat = await ctx.waterfall('tools/post-execute', stubToolExecution({ signal: testToolSignal, - callId: CallId('provider-stat-failure'), name: 'read', arguments: { file_path: 'pkg/file.txt' }, agent, + callId: CallId('provider-stat-failure'), name: 'read', arguments: { file_path: join('pkg', 'file.txt') }, agent, }), result, async () => ({ kind: 'accept' as const })) fs.throwOnStat.clear() fs.entries.set(join(root, 'pkg/AGENTS.md'), { type: 'directory' }) const mismatchedStat = await ctx.waterfall('tools/post-execute', stubToolExecution({ signal: testToolSignal, - callId: CallId('provider-stat-mismatch'), name: 'read', arguments: { file_path: 'pkg/file.txt' }, agent, + callId: CallId('provider-stat-mismatch'), name: 'read', arguments: { file_path: join('pkg', 'file.txt') }, agent, }), result, async () => ({ kind: 'accept' as const })) expect(failedStat).toEqual({ kind: 'accept' }) @@ -2770,7 +2770,7 @@ describe('dynamic nested workspace context injection', () => { signal: testToolSignal, callId: CallId('read-with-unreadable-nested-instruction'), name: 'read', - arguments: { file_path: 'pkg/deep/file.txt' }, + arguments: { file_path: join('pkg', 'deep', 'file.txt') }, agent: stubAgent(root), }) @@ -2805,7 +2805,7 @@ describe('dynamic nested workspace context injection', () => { signal: testToolSignal, callId: CallId('read-with-downstream'), name: 'read', - arguments: { file_path: 'pkg/deep/file.txt' }, + arguments: { file_path: join('pkg', 'deep', 'file.txt') }, agent: stubAgent(root), }) @@ -2814,7 +2814,7 @@ describe('dynamic nested workspace context injection', () => { expect(workspaceContextOf(result)?.source).toEqual({ kind: 'plugin', plugin: 'workspace-context' }) expect(workspaceContextOf(result)?.meta).toMatchObject({ kind: 'workspace-instructions', - changes: [{ action: 'set', scope: sk('pkg', 'AGENTS.md'), path: 'pkg/AGENTS.md' }], + changes: [{ action: 'set', scope: sk('pkg', 'AGENTS.md'), path: join('pkg', 'AGENTS.md') }], }) expect(blocksText(workspaceContextOf(result)?.content)).toContain('nested package rule') expect(blocksText(workspaceContextOf(result)?.content)).not.toContain('downstream context') @@ -2850,7 +2850,7 @@ describe('dynamic nested workspace context injection', () => { signal: testToolSignal, callId: CallId('read-blocked-downstream'), name: 'read', - arguments: { file_path: 'pkg/deep/file.txt' }, + arguments: { file_path: join('pkg', 'deep', 'file.txt') }, agent: stubAgent(root), }) @@ -2891,7 +2891,7 @@ describe('dynamic nested workspace context injection', () => { signal: testToolSignal, callId: CallId('outer-block-first'), name: 'read', - arguments: { file_path: 'pkg/deep/file.txt' }, + arguments: { file_path: join('pkg', 'deep', 'file.txt') }, agent, }) shouldBlock = false @@ -2899,7 +2899,7 @@ describe('dynamic nested workspace context injection', () => { signal: testToolSignal, callId: CallId('outer-block-retry'), name: 'read', - arguments: { file_path: 'pkg/deep/file.txt' }, + arguments: { file_path: join('pkg', 'deep', 'file.txt') }, agent, }) @@ -2935,7 +2935,7 @@ describe('dynamic nested workspace context injection', () => { signal: testToolSignal, callId: CallId(`${exec.callId}:nested`), name: 'read', - arguments: { file_path: 'pkg/deep/file.txt' }, + arguments: { file_path: join('pkg', 'deep', 'file.txt') }, ...exec.agent === undefined ? {} : { agent: exec.agent }, parent: exec.token, ...exec.signal === undefined ? {} : { signal: exec.signal }, @@ -3026,8 +3026,8 @@ describe('dynamic nested workspace context injection', () => { isError: false, } const cases = [ - { name: 'read', arguments: { file_path: 'pkg/deep/file.txt' }, agent: undefined }, - { name: 'bash', arguments: { file_path: 'pkg/deep/file.txt' }, agent }, + { name: 'read', arguments: { file_path: join('pkg', 'deep', 'file.txt') }, agent: undefined }, + { name: 'bash', arguments: { file_path: join('pkg', 'deep', 'file.txt') }, agent }, { name: 'read', arguments: null, agent }, { name: 'read', arguments: {}, agent }, { name: 'read', arguments: { file_path: 1 }, agent }, @@ -3064,7 +3064,7 @@ describe('dynamic nested workspace context injection', () => { signal: testToolSignal, callId: CallId('read-with-disabled-budget'), name: 'read', - arguments: { file_path: 'pkg/deep/file.txt' }, + arguments: { file_path: join('pkg', 'deep', 'file.txt') }, agent: stubAgent(root), }) @@ -3089,7 +3089,7 @@ describe('dynamic nested workspace context injection', () => { signal: testToolSignal, callId: CallId('read-missing'), name: 'read', - arguments: { file_path: 'pkg/missing.txt' }, + arguments: { file_path: join('pkg', 'missing.txt') }, agent: stubAgent(root), }) @@ -3116,7 +3116,7 @@ describe('dynamic nested workspace context injection', () => { signal: testToolSignal, callId: CallId('read-after-dispose'), name: 'read', - arguments: { file_path: 'pkg/deep/file.txt' }, + arguments: { file_path: join('pkg', 'deep', 'file.txt') }, agent: stubAgent(root), }) @@ -3159,7 +3159,7 @@ describe('workspace context pending state', () => { const [change] = commitPendingInstructionContexts(agent, [workspaceChangeContext('pkg', 'one')], pending) expect(change).toBeDefined() versions.set(agent.session, new Map([['pkg', { - path: 'pkg/AGENTS.md', version: FsVersion('v1'), digest: 'one', trimmedDigest: 'one', + path: join('pkg', 'AGENTS.md'), version: FsVersion('v1'), digest: 'one', trimmedDigest: 'one', }]])) const unrelated = agent.session.append('context/message', { @@ -3196,7 +3196,7 @@ describe('workspace context pending state', () => { agent.session.append('step/start', { turn: 1, step: 1 }) commitPendingInstructionContexts(agent, [workspaceChangeContext('pkg', 'one')], pending) versions.set(agent.session, new Map([['pkg', { - path: 'pkg/AGENTS.md', version: FsVersion('v1'), digest: 'one', trimmedDigest: 'one', + path: join('pkg', 'AGENTS.md'), version: FsVersion('v1'), digest: 'one', trimmedDigest: 'one', }]])) const ended = agent.session.append('step/end', { turn: 1, step: 1 }) diff --git a/packages/ui/tui/tests/harness.ts b/packages/ui/tui/tests/harness.ts index 19e02502f2..8210a3a423 100644 --- a/packages/ui/tui/tests/harness.ts +++ b/packages/ui/tui/tests/harness.ts @@ -31,7 +31,7 @@ export interface TuiHarnessOptions { beforeMount?: (session: Session) => void cwd?: string | null formatCwd?: TuiRuntime['formatCwd'] - /** Fake-agent creation options; auto-title resolves its target from `provider`/`model`. */ + /** Fake-agent creation options (`provider`/`model` seed the model selector's initial target). */ agentOptions?: AgentOptions contextWindow?: number contextTokens?: number @@ -94,8 +94,8 @@ export async function createTuiTestHarness<TerminalType extends Terminal, Exit e } else { await options.configureContext(ctx) } - // A configureContext may mount the real LlmService (e.g. the auto-title - // suites); only fill the advisory-catalog stub when none was provided. + // A configureContext may mount the real LlmService; only fill the + // advisory-catalog stub when none was provided. if (ctx.get('llm') === undefined) { ctx.provide('llm', { listProviders() { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c0cec09d7e..320fd5b2ce 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -188,6 +188,9 @@ importers: '@deepseek-ai/dsh-session-persistence-jsonl': specifier: workspace:* version: link:../packages/session-persistence/session-persistence-jsonl + '@deepseek-ai/dsh-session-title-first-message-llm': + specifier: workspace:* + version: link:../packages/session-title/session-title-first-message-llm '@deepseek-ai/dsh-spill-local': specifier: workspace:* version: link:../packages/spill/spill-local From 0f57eafc364ca26b533d38279f2fa4c754ea9b27 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Wed, 22 Jul 2026 15:44:11 +0800 Subject: [PATCH 63/90] ci: collapse cold setup waves --- .github/workflows/ci.yml | 93 +++++++++++++++++++--------------------- package.json | 1 + scripts/run-gates.ts | 28 ++++++++++-- 3 files changed, 71 insertions(+), 51 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 121d82d701..45adb82b57 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -319,7 +319,7 @@ jobs: run: ${{ matrix.command }} # One large runner pays hosted setup once, then the repository scheduler - # overlaps the complete unsharded primary gate inventory inside that box. + # overlaps the unsharded primary inventory except the production site build. node-24: if: github.event_name != 'workflow_dispatch' || inputs.suite == 'optimized-larger-runners' runs-on: dsh-ubuntu-24-04-96core @@ -383,6 +383,38 @@ jobs: - name: Run complete unsharded primary Node CI concurrently run: pnpm run check:ci:large-runner + # Keep only the longest independent Linux gate on a second coarse-grained + # runner so cold install variance does not push the primary box over a minute. + node-24-site: + if: github.event_name != 'workflow_dispatch' || inputs.suite == 'optimized-larger-runners' + runs-on: dsh-ubuntu-24-04-16core + name: node 24 / production site + steps: + - uses: actions/checkout@v6 + + - uses: actions/setup-node@v6 + with: + node-version: ${{ env.PRIMARY_NODE_VERSION }} + + - name: Enable corepack and resolve pnpm store path + id: pnpm-store + run: | + corepack enable + echo "path=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT" + + - uses: actions/cache@v4 + with: + path: ${{ steps.pnpm-store.outputs.path }} + key: ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm-${{ hashFiles('pnpm-lock.yaml') }} + restore-keys: | + ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm- + + - name: Install (immutable) + run: pnpm install --frozen-lockfile + + - name: Build documentation site (production SPA) + run: pnpm run docs:build + node-compat: if: github.event_name != 'workflow_dispatch' || inputs.suite == 'optimized-larger-runners' runs-on: ubuntu-latest @@ -583,49 +615,13 @@ jobs: shell: pwsh run: ${{ matrix.command }} - # The two blocking Windows build surfaces share one setup and run concurrently. - windows-build: + # One Windows box shares setup across the required build/site checks and the + # complete observational portability inventory. run-gates reports failures + # from observational gates without allowing them to fail the required job. + windows: if: github.event_name != 'workflow_dispatch' || inputs.suite == 'optimized-larger-runners' - runs-on: dsh-windows-2025-16core - name: windows / blocking builds - steps: - - uses: actions/checkout@v6 - - - uses: actions/setup-node@v6 - with: - node-version: ${{ env.PRIMARY_NODE_VERSION }} - - - name: Enable corepack and resolve pnpm store path - id: pnpm-store - shell: pwsh - run: | - corepack enable - "path=$(pnpm store path --silent)" >> $env:GITHUB_OUTPUT - - - uses: actions/cache@v4 - with: - path: ${{ steps.pnpm-store.outputs.path }} - key: ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm-${{ hashFiles('pnpm-lock.yaml') }} - restore-keys: | - ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm- - - - name: Install (immutable) - shell: pwsh - run: pnpm install --frozen-lockfile - - - name: Run blocking Windows builds concurrently - shell: pwsh - env: - DSH_GATE_CONCURRENCY: '2' - run: pnpm run check:ci:windows-blocking - - # Wider Windows portability gates remain observational, but one larger box - # now runs their complete unsharded inventory with in-runner concurrency. - windows-gates: - if: github.event_name != 'workflow_dispatch' || inputs.suite == 'optimized-larger-runners' - continue-on-error: true runs-on: dsh-windows-2025-32core - name: windows node 24 / observational + name: windows node 24 / complete env: # Keep ESLint itself single-threaded: 16 ESLint workers took 174 seconds on # this image. The outer scheduler still overlaps lint with the other gates. @@ -670,9 +666,9 @@ jobs: shell: pwsh run: pnpm install --frozen-lockfile - - name: Run observational Windows gates concurrently + - name: Run blocking and observational Windows gates concurrently shell: pwsh - run: pnpm run check:ci:windows-observational + run: pnpm run check:ci:windows-complete # Manual reference runs deliberately avoid the optimized jobs above. # Each host executes the complete, unsharded primary Node aggregate with one @@ -1011,7 +1007,7 @@ jobs: DSH_GATE_CONCURRENCY: ${{ matrix.workers }} DSH_PUBLINT_CONCURRENCY: ${{ matrix.workers }} DSH_SNAPSHOT_MAX_CONCURRENCY: ${{ matrix.workers }} - run: pnpm run check:ci:large-runner + run: pnpm run check:ci - name: Run blocking Windows builds concurrently if: matrix.platform == 'windows' @@ -1023,8 +1019,9 @@ jobs: # Single stable required check for branch protection: require "all checks # passed" instead of enumerating matrix legs whose names change as lanes and # node versions evolve. Every blocking job in THIS workflow must be listed in - # `needs`; explicitly observational jobs such as windows-gates stay out - # (`needs` cannot reach across workflow files; e2e.yml stays its own check). + # `needs`; observational Windows gates share the required Windows job but are + # marked non-blocking inside run-gates. (`needs` cannot reach across workflow + # files; e2e.yml stays its own check.) # `if: always()` is load-bearing: without it a failed dependency # would SKIP this job, and GitHub counts a skipped required check as passing # — so this job always runs and fails on any non-success result, including @@ -1032,7 +1029,7 @@ jobs: all-checks-passed: name: all checks passed runs-on: ubuntu-latest - needs: [node-24, node-compat, python-sdk, windows-build] + needs: [node-24, node-24-site, node-compat, python-sdk, windows] if: always() && (github.event_name != 'workflow_dispatch' || inputs.suite == 'optimized-larger-runners') steps: - name: Fail if any needed job did not succeed diff --git a/package.json b/package.json index c3aaa3c29d..c9e5e01a71 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,7 @@ "check:ci:snapshot": "tsx scripts/run-gates.ts ci-snapshot", "check:ci:artifacts": "tsx scripts/run-gates.ts ci-artifacts", "check:ci:windows-blocking": "tsx scripts/run-gates.ts ci-windows-blocking", + "check:ci:windows-complete": "tsx scripts/run-gates.ts ci-windows-complete", "check:ci:windows-observational": "tsx scripts/run-gates.ts ci-windows-observational", "check:node-compat": "tsx scripts/run-gates.ts node-compat", "check:pre-push": "tsx scripts/run-gates.ts pre-push", diff --git a/scripts/run-gates.ts b/scripts/run-gates.ts index 80c9c9bc5c..11155551db 100644 --- a/scripts/run-gates.ts +++ b/scripts/run-gates.ts @@ -22,6 +22,7 @@ type Mode = | 'ci-snapshot' | 'ci-artifacts' | 'ci-windows-blocking' + | 'ci-windows-complete' | 'ci-windows-observational' | 'node-compat' | 'pre-push' @@ -38,6 +39,7 @@ interface Gate { env?: Record<string, string | undefined> input?: string verify?: (result: GateResult) => Promise<void> + allowFailure?: boolean } interface GateResult { @@ -83,7 +85,9 @@ console.log(`run-gates: ${mode} running ${gates.length} gate(s) with ${maxConcur const results = await runGates(gates, maxConcurrency) printSummary(results, performance.now() - startedAt) -if (results.some(result => result.status === 'failed' || result.status === 'skipped')) process.exit(1) +if (results.some(result => result.gate.allowFailure !== true && (result.status === 'failed' || result.status === 'skipped'))) { + process.exit(1) +} function parseMode(raw: string | undefined): Mode { switch (raw) { @@ -95,6 +99,7 @@ function parseMode(raw: string | undefined): Mode { case 'ci-snapshot': case 'ci-artifacts': case 'ci-windows-blocking': + case 'ci-windows-complete': case 'ci-windows-observational': case 'node-compat': case 'pre-push': @@ -102,7 +107,7 @@ function parseMode(raw: string | undefined): Mode { return raw default: throw new Error( - `run-gates: expected mode ci-primary | ci-primary-large-runner | ci-static | ci-lint | ci-coverage | ci-snapshot | ci-artifacts | ci-windows-blocking | ci-windows-observational | node-compat | pre-push | doc-sync, got ${JSON.stringify(raw)}.`, + `run-gates: expected mode ci-primary | ci-primary-large-runner | ci-static | ci-lint | ci-coverage | ci-snapshot | ci-artifacts | ci-windows-blocking | ci-windows-complete | ci-windows-observational | node-compat | pre-push | doc-sync, got ${JSON.stringify(raw)}.`, ) } } @@ -189,6 +194,8 @@ function gatesForMode(selected: Mode): Gate[] { return ciArtifactGates() case 'ci-windows-blocking': return ciWindowsBlockingGates() + case 'ci-windows-complete': + return ciWindowsCompleteGates() case 'ci-windows-observational': return ciWindowsObservationalGates() case 'node-compat': @@ -254,6 +261,7 @@ function ciPrimaryLargeRunnerGates(): Gate[] { // Typecheck does not consume build output, so a large runner can start both // together while snapshot and artifact consumers still wait for the build. return ciPrimaryGates() + .filter(gate => gate.id !== 'docs-site-build') .map((gate) => { if (gate.id !== 'build') return gate const eagerBuild = { ...gate } @@ -306,6 +314,19 @@ function ciWindowsBlockingGates(): Gate[] { ] } +function ciWindowsCompleteGates(): Gate[] { + const observational = ciWindowsObservationalGates() + // The required production site replaces the observational MPA build; both + // VitePress modes write the same output directory and cannot overlap. + .filter(gate => gate.id !== 'build' && gate.id !== 'docs-site-build') + .map(gate => ({ ...gate, allowFailure: true })) + return [ + pnpmScript('build', 'build'), + pnpmScript('windows-site', 'docs:build', { label: 'production site' }), + ...observational, + ] +} + function ciWindowsObservationalGates(): Gate[] { return [ ...ciStaticGates(), @@ -638,7 +659,8 @@ function printSummary(results: GateResult[], durationMs: number): void { for (const result of unsuccessful) { const duration = (result.durationMs / 1000).toFixed(2) const reason = result.error ?? (result.exitCode === null ? 'no exit code' : `exit ${result.exitCode}`) - console.error(` - ${result.status.toUpperCase()} ${result.gate.label} (${duration}s, ${reason})`) + const disposition = result.gate.allowFailure === true ? 'NON-BLOCKING ' : '' + console.error(` - ${disposition}${result.status.toUpperCase()} ${result.gate.label} (${duration}s, ${reason})`) console.error(` ${result.gate.displayCommand}`) } } From ab32d3ec986de1915aaad033e5e70512232c5b2c Mon Sep 17 00:00:00 2001 From: Turtle <turtle1999@deepseek.com> Date: Wed, 22 Jul 2026 15:54:01 +0800 Subject: [PATCH 64/90] test(workspace-context): make the merged suite's fixtures cross-platform Path expectations derive from join() per the cross-platform fixtures convention; the fake fs resolves against the host root so drive-letter targets match seeded keys; the unreadable-candidate cases use the provider throwOnRead fixture (host chmod 0 is a no-op for the owner on Windows), keeping the read-failure branch covered there, with a narrow win32 skip only for the host-chmod discovery case. --- .../tests/workspace-context.spec.ts | 37 +++++++++++++------ 1 file changed, 25 insertions(+), 12 deletions(-) diff --git a/packages/context/workspace-context/tests/workspace-context.spec.ts b/packages/context/workspace-context/tests/workspace-context.spec.ts index e71147c693..1565b50db5 100644 --- a/packages/context/workspace-context/tests/workspace-context.spec.ts +++ b/packages/context/workspace-context/tests/workspace-context.spec.ts @@ -58,6 +58,7 @@ async function write(path: string, content: string): Promise<void> { class RecordingFileSystem extends FileSystem { entries = new Map<string, { type: FsInfo['type']; content?: string; version?: FsVersion }>() throwOnStat = new Set<string>() + throwOnRead = new Set<string>() omitSizes = new Set<string>() readTargets: string[] = [] readTextTargets: string[] = [] @@ -66,7 +67,9 @@ class RecordingFileSystem extends FileSystem { override async resolve(path: string, opts?: { cwd?: string; signal?: AbortSignal }): Promise<FsTarget> { if (opts?.signal !== undefined) this.signals.push(opts.signal) opts?.signal?.throwIfAborted() - const absolute = join(opts?.cwd ?? '/', path) + // resolve(), not join(): entries are seeded with host join() keys, and on + // Windows a joined '/'-rooted prefix would not match a resolved drive path. + const absolute = resolve(opts?.cwd ?? '/', path) return { targetKey: FsTargetKey(absolute), displayPath: absolute } } @@ -108,6 +111,7 @@ class RecordingFileSystem extends FileSystem { if (signal !== undefined) this.signals.push(signal) signal?.throwIfAborted() this.readTargets.push(target.targetKey) + if (this.throwOnRead.has(target.targetKey)) throw new Error(`read failed: ${target.displayPath}`) const content = this.entries.get(target.targetKey)?.content ?? '' return (async function* () { const midpoint = Math.ceil(content.length / 2) @@ -383,7 +387,8 @@ describe('workspace context instruction discovery', () => { } }) - it('skips a file that becomes unreadable after discovery without failing the request', async () => { + // POSIX-only fixture: chmod 0 cannot make a file unreadable to its owner on Windows. + it.skipIf(process.platform === 'win32')('skips a file that becomes unreadable after discovery without failing the request', async () => { const root = await tempRepo() const home = await tempRepo() try { @@ -1818,7 +1823,7 @@ describe('dynamic nested workspace context injection', () => { const text = blocksText(workspaceContextOf(result)?.content) expect(text).toBe([ '<system-reminder>', - 'Additional instructions from: pkg/AGENTS.md', + `Additional instructions from: ${join('pkg', 'AGENTS.md')}`, '', 'These instructions apply to work under `pkg`. Use them as guidance when relevant; more specific instructions take precedence. They do not override system, developer, or direct user instructions.', '', @@ -2115,7 +2120,7 @@ describe('dynamic nested workspace context injection', () => { }) expect(blocksText(workspaceContextOf(changed)?.content)).toBe([ '<system-reminder>', - 'Updated instructions from: pkg/AGENTS.md', + `Updated instructions from: ${join('pkg', 'AGENTS.md')}`, '', 'This file changed after it was loaded. Use the following content instead of the previously loaded instructions from this file.', '', @@ -2300,7 +2305,7 @@ describe('dynamic nested workspace context injection', () => { }) expect(blocksText(workspaceContextOf(removed)?.content)).toBe([ '<system-reminder>', - 'Instructions removed: pkg/AGENTS.md', + `Instructions removed: ${join('pkg', 'AGENTS.md')}`, '', 'The previously loaded instructions from this file no longer apply.', '</system-reminder>', @@ -2755,16 +2760,23 @@ describe('dynamic nested workspace context injection', () => { }) it('skips unreadable nested instruction files without attaching empty context', async () => { + // Cross-platform unreadable fixture: the provider read throws (chmod 0 + // cannot make a file unreadable to its owner on Windows). const root = await tempRepo() const home = await tempRepo() + const ctx = new Context() try { - await mkdir(join(root, '.git'), { recursive: true }) const nested = join(root, 'pkg/AGENTS.md') - await write(nested, 'nested package rule') - await write(join(root, 'pkg/deep/file.txt'), 'hello') - await chmod(nested, 0) - const ctx = new Context() - await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + await ctx.plugin(RecordingFileSystem) + const fs = ctx.fs as RecordingFileSystem + fs.entries.set(join(root, '.git'), { type: 'directory' }) + fs.entries.set(nested, { type: 'file', content: 'nested package rule' }) + fs.entries.set(join(root, 'pkg/deep/file.txt'), { type: 'file', content: 'hello' }) + fs.throwOnRead.add(nested) + await ctx.plugin(ToolFs) + await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 }) const result = await ctx.tools.execute({ signal: testToolSignal, @@ -2776,8 +2788,9 @@ describe('dynamic nested workspace context injection', () => { expect(result.isError).toBe(false) expect(result.additionalContexts).toBeUndefined() - await chmod(nested, 0o600) + expect(fs.readTargets).toContain(nested) } finally { + await ctx.fiber.dispose() await rm(root, { recursive: true, force: true }) await rm(home, { recursive: true, force: true }) } From cd19f87c3f97ea4546eca003c8a450e89f75edaf Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Wed, 22 Jul 2026 15:57:32 +0800 Subject: [PATCH 65/90] ci: name split Linux lane precisely --- .github/workflows/ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 45adb82b57..09d61f845d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -323,7 +323,7 @@ jobs: node-24: if: github.event_name != 'workflow_dispatch' || inputs.suite == 'optimized-larger-runners' runs-on: dsh-ubuntu-24-04-96core - name: node 24 / complete + name: node 24 / core env: # Thirty-two Vitest forks intermittently crash Node's CJS lexer on this image. DSH_COVERAGE_MAX_WORKERS: '16' @@ -380,7 +380,7 @@ jobs: if (( install_status != 0 )); then exit "$install_status"; fi exit "$sandbox_status" - - name: Run complete unsharded primary Node CI concurrently + - name: Run unsharded primary Node core CI concurrently run: pnpm run check:ci:large-runner # Keep only the longest independent Linux gate on a second coarse-grained From d7945da13e2de2e527ceb06b4963bbac184424df Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Wed, 22 Jul 2026 15:57:39 +0800 Subject: [PATCH 66/90] docs: record reduced-fanout validation --- ...ence-based-larger-hosted-runners.i18n.yaml | 4 +- ...22-evidence-based-larger-hosted-runners.md | 38 ++++++++++--------- ...evidence-based-larger-hosted-runners.zh.md | 38 ++++++++++--------- 3 files changed, 44 insertions(+), 36 deletions(-) diff --git a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.i18n.yaml b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.i18n.yaml index da19d5558b..d5b558837f 100644 --- a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.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 -2026-07-22-evidence-based-larger-hosted-runners.md: 8d3852c56e2289bd222a260f2685f6a941a0cb19 -2026-07-22-evidence-based-larger-hosted-runners.zh.md: 4e7f846db1be2d68a080381943012583636ed2c4 +2026-07-22-evidence-based-larger-hosted-runners.md: 7d38552d23f7a3fb8abca45cbb4cf4e9c82fdd78 +2026-07-22-evidence-based-larger-hosted-runners.zh.md: f16db8eeb9000fe9a7671345efa9b1fd7fc744e1 diff --git a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.md b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.md index 8d3852c56e..7d38552d23 100644 --- a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.md +++ b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.md @@ -14,13 +14,13 @@ Larger runners make it possible to pay setup once and parallelize inside the rep The organization keeps twelve x64 larger-runner pools in the repo-restricted `dsh-larger-ci` group: Ubuntu 24.04 and Windows 2025 at 4, 8, 16, 32, 64, and 96 cores. Public IPs are disabled. Each pool has an autoscaling ceiling of 256; the ceiling does not allocate idle machines or remove the need to bound workflow demand. -Production CI uses three larger-runner jobs and keeps Node compatibility, Python, and the final aggregator on standard runners: +Production CI uses three coarse larger-runner jobs and keeps Node compatibility, Python, and the final aggregator on standard runners: -- `node 24 / complete` uses the 96-core Linux pool. One checkout, setup, cache restore, and install feeds the complete unsharded 40-gate primary inventory. `run-gates` starts up to 32 independent gates, ESLint uses 32 workers, snapshots use up to 32 subprocesses, and coverage uses 16 forks. Build starts beside typecheck; snapshot and artifact consumers still wait for emitted output. Coverage stays below 32 forks because that setting twice caused Node 24's CJS lexer to terminate a Vitest worker and invalidate coverage. -- `windows / blocking builds` uses the 16-core Windows pool. Build and the production VitePress site run concurrently after one setup. -- `windows node 24 / observational` uses the 32-core Windows pool. Its complete unsharded 37-gate static, lint, and artifact inventory runs with 32 outer scheduler slots and remains non-blocking. ESLint itself stays single-threaded because 16 ESLint worker threads increased full-lint time to 174.54 seconds; with outer concurrency and no ESLint workers, the same full lint took 31.67 seconds. +- `node 24 / core` uses the 96-core Linux pool. One checkout, setup, cache restore, and install feeds the unsharded 39-gate primary inventory other than the production site build. `run-gates` starts up to 32 independent gates, ESLint uses 32 workers, snapshots use up to 32 subprocesses, and coverage uses 16 forks. Build starts beside typecheck; snapshot and artifact consumers still wait for emitted output. Coverage stays below 32 forks because that setting twice caused Node 24's CJS lexer to terminate a Vitest worker and invalidate coverage. +- `node 24 / production site` uses the 16-core Linux pool for the longest independent primary gate. This is one coarse split, not a shard matrix: the job performs one setup and one production VitePress build. +- `windows node 24 / complete` uses the 32-core Windows pool. One setup feeds the required package build, the required production site build, and the complete observational portability inventory. The outer scheduler has 32 slots. Required failures fail the job; observational failures are printed as non-blocking and preserve their former advisory status. ESLint itself stays single-threaded because 16 ESLint worker threads increased full-lint time to 174.54 seconds; outer gate concurrency uses the runner without multiplying Windows worker startup and TypeScript project loading. -The two Windows jobs deliberately use different pools. A first candidate put both on the 16-core pool and GitHub took 93 seconds to provision the second same-label runner despite the configured autoscaling ceiling. Using the 16-core and 32-core pools let every production job begin within two seconds in the final validation run. +The final shape followed two cold-path observations. A first candidate used two 16-core Windows jobs, and GitHub took 93 seconds to provision the second same-label runner despite the configured autoscaling ceiling. A later [documentation-head validation](https://github.com/deepseek-harness/deepseek-harness/actions/runs/29900502413) took 64 seconds on the combined Linux job because its production site gate lasted 29.05 seconds, while a separate Windows blocking job took 266 seconds after spending 138 seconds restoring a 153 MB pnpm cache. Moving that independent Linux gate to one coarse 16-core lane and combining all Windows work on one 32-core box removed both duplicate setup waves. The workflow retains four manual diagnostics. `suite=larger-runner-benchmark` compares isolated critical lanes across every size, `suite=consolidated-runner-benchmark` compares whole aggregates, `suite=sharded-reference` preserves the former production shard topology, and `suite=serial-reference` remains the unsharded cross-platform completeness oracle. `suite=optimized-larger-runners` runs the exact production topology against a branch ref when a pull request cannot form a merge commit. @@ -33,23 +33,23 @@ The first [twelve-size critical-lane benchmark](https://github.com/deepseek-harn Those isolated results showed that setup dominated but did not identify the production size. A [whole-aggregate benchmark without native ESLint concurrency](https://github.com/deepseek-harness/deepseek-harness/actions/runs/29897826082) found a 69-second single-threaded Linux lint gate. After enabling native Linux ESLint concurrency, the [second whole-aggregate benchmark](https://github.com/deepseek-harness/deepseek-harness/actions/runs/29898331705) produced these active job times: -| Aggregate job | 4 cores | 8 cores | 16 cores | 32 cores | 64 cores | 96 cores | +| Job | 4 cores | 8 cores | 16 cores | 32 cores | 64 cores | 96 cores | |---|---:|---:|---:|---:|---:|---:| | Linux complete primary | 147 s | 104 s | 95 s | failed at 57 s | 66 s | 60 s | | Windows blocking builds | 137 s | 127 s | 113 s | 107 s | 105 s | 131 s | -The Linux 32-core failure was the first CJS-lexer worker crash. The 96-core aggregate was the only successful all-size result at the one-minute boundary. Windows gained little above 16 cores, so the blocking job uses 16 cores; the observational job uses a separate 32-core pool to avoid same-label provisioning delay and to start all outer gates together. +The Linux 32-core failure was the first CJS-lexer worker crash. The 96-core aggregate was the only successful all-size result at the one-minute boundary. Although Windows repository work gained little above 16 cores, the 32-core pool can start the complete outer inventory together and, more importantly, removes an entire paid setup from production. -The exact production [validation run](https://github.com/deepseek-harness/deepseek-harness/actions/runs/29899733584) passed every job at the tested branch head: +The exact [reduced-fanout validation run](https://github.com/deepseek-harness/deepseek-harness/actions/runs/29901539360) passed every job at the tested branch head: | Production job | Active time | Repository work | Result | |---|---:|---:|---:| -| Linux complete primary | 50 s | 40 gates in 23.23 s | passed | -| Slowest standard non-Windows job | 40 s | Node 26 compatibility | passed | -| Windows blocking builds | 91 s | 2 gates in 28.69 s | passed | -| Windows observational | 153 s | 37 gates in 31.83 s | passed | +| Linux core | 52 s | 39 gates in 25.84 s | passed | +| Linux production site | 44 s | VitePress in 22.42 s | passed | +| Slowest standard non-Windows job | 43 s | Node 26 compatibility | passed | +| Windows complete | 96 s | 37 gates in 31.13 s | passed | -The Windows observational job spent 57 seconds restoring its pnpm cache, so its remaining margin measures hosted setup variance as well as repository work. The final run still stays below one minute for every non-Windows job and below three minutes for both Windows jobs. +All three paid jobs began together. The Windows job spent 21 seconds restoring its pnpm cache and 25 seconds installing dependencies, so its margin measures hosted setup variance as well as repository work. Every non-Windows job stays below one minute and the sole Windows job stays below three minutes. ## Alternatives considered @@ -57,16 +57,20 @@ The Windows observational job spent 57 seconds restoring its pnpm cache, so its **Select a production size from the critical-lane benchmark.** Four cores looked cost-effective for isolated typecheck and site builds, but the full aggregate found repository-wide lint and dependent artifact work that those commands did not represent. -**Prebuild before starting the Linux aggregate.** This moved build onto the setup path and produced a 66-second candidate. Starting build eagerly inside `run-gates` preserves artifact dependencies while overlapping it with unrelated checks; the final aggregate completed in 23.23 seconds. +**Run the production site inside the Linux core aggregate.** This reached 50 seconds with warm hosted setup, then crossed the threshold at 64 seconds when the site gate took 29.05 seconds. One coarse independent site job protects the target without returning to gate-level sharding. + +**Keep required and observational Windows checks in separate jobs.** The split preserved status semantics at the workflow level but paid setup twice, and a cold cache pushed the required job to 266 seconds. `run-gates` now preserves those semantics inside one process: build and production site are required, while the remaining inventory is explicitly non-blocking. + +**Prebuild before starting the Linux aggregate.** This moved build onto the setup path and produced a 66-second candidate. Starting build eagerly inside `run-gates` preserves artifact dependencies while overlapping it with unrelated checks. **Use native ESLint worker concurrency on Windows.** Sixteen workers made lint more than five times slower than the final single-threaded result. Outer gate parallelism uses the 32-core runner without multiplying ESLint's Windows worker startup and TypeScript project loading. -**Move compatibility, Python, and aggregation to larger runners.** These standard-runner jobs all completed in 40 seconds or less. Paid capacity would not shorten the critical path. +**Move compatibility, Python, and aggregation to larger runners.** These standard-runner jobs completed in 43 seconds or less. Paid capacity would not shorten the critical path. ## Consequences -The final production validation consumed one billed 96-core Linux minute, two billed 16-core Windows minutes, and three billed 32-core Windows minutes. At the configured larger-runner rates, its larger-runner cost was $0.902. The all-size critical benchmark cost $2.936. GitHub rounds each larger-runner job up to a whole minute, so reducing paid job count from 49 to three matters as much as shortening repository work. +The reduced-fanout validation consumed one billed 96-core Linux minute, one billed 16-core Linux minute, and two billed 32-core Windows minutes. At the configured larger-runner rates, its larger-runner cost was $0.618. The all-size critical benchmark cost $2.936. GitHub rounds each larger-runner job up to a whole minute, so reducing paid job count from 49 to three matters as much as shortening repository work. The existing zero-dollar Actions budget did not block larger-runner jobs. The repo-only runner group, bounded workflow topology, manual benchmark triggers, and job timeouts are the observed cost controls; the budget is not treated as an execution guard. -Production CI depends on the organization-owned runner names in this note and in `.github/workflows/ci.yml`. Missing or renamed pools leave jobs queued instead of falling back to standard capacity. Manual all-size and former-shard suites remain available so image, dependency, scheduler, or pricing changes can be remeasured before changing production labels. +Production CI depends on the organization-owned runner names in this note and in `.github/workflows/ci.yml`. Missing or renamed pools leave jobs queued instead of falling back to standard capacity. Manual all-size, consolidated, former-shard, and serial suites remain available so image, dependency, scheduler, or pricing changes can be remeasured before changing production labels. diff --git a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.zh.md b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.zh.md index 4e7f846db1..f16db8eeb9 100644 --- a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.zh.md +++ b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.zh.md @@ -14,13 +14,13 @@ Status: implemented 组织在仅限本仓库使用的 `dsh-larger-ci` 运行器组中保留 12 个 x64 大型运行器池:Ubuntu 24.04 和 Windows 2025 各设 4、8、16、32、64、96 核规格。公网 IP 已禁用。每个池的自动扩缩容上限为 256;该上限既不会分配闲置机器,也不能免除限制工作流需求的必要性。 -生产 CI 使用 3 个大型运行器作业,并让 Node 兼容性、Python 和最终聚合作业继续使用标准运行器: +生产 CI 使用 3 个粗粒度大型运行器作业,并让 Node 兼容性、Python 和最终聚合作业继续使用标准运行器: -- `node 24 / complete` 使用 96 核 Linux 池。只需执行一次代码检出、设置、缓存恢复和安装,即可供完整且未分片的 40 项主门禁清单使用。`run-gates` 最多同时启动 32 项相互独立的门禁,ESLint 使用 32 个工作线程,快照最多使用 32 个子进程,覆盖率运行使用 16 个 fork。构建与类型检查同时启动;快照和产物消费方仍会等待生成的输出。覆盖率运行的 fork 数保持低于 32,因为将其设为 32 曾两次导致 Node 24 的 CJS 词法分析器终止一个 Vitest 工作进程,使覆盖率结果失效。 -- `windows / blocking builds` 使用 16 核 Windows 池。一次设置完成后,构建任务与生产版 VitePress 网站任务并发运行。 -- `windows node 24 / observational` 使用 32 核 Windows 池。其完整且未分片的 37 项静态、lint 和产物门禁清单使用 32 个外层调度器槽位运行,并保持非阻塞。ESLint 本身仍采用单线程,因为启用 16 个 ESLint 工作线程会把完整 lint 的耗时增加至 174.54 秒;在启用外层并发且不使用 ESLint 工作线程时,同一项完整 lint 只需 31.67 秒。 +- `node 24 / core` 使用 96 核 Linux 池。只需执行一次代码检出、设置、缓存恢复和安装,即可供生产网站构建以外、未分片的 39 项主门禁清单使用。`run-gates` 最多同时启动 32 项相互独立的门禁,ESLint 使用 32 个工作线程,快照最多使用 32 个子进程,覆盖率运行使用 16 个 fork。构建与类型检查同时启动;快照和产物消费方仍会等待生成的输出。覆盖率运行的 fork 数保持低于 32,因为将其设为 32 曾两次导致 Node 24 的 CJS 词法分析器终止一个 Vitest 工作进程,使覆盖率结果失效。 +- `node 24 / production site` 使用 16 核 Linux 池运行耗时最长的独立主门禁。这是一项粗粒度拆分,而非分片矩阵:该作业执行一次设置和一次生产版 VitePress 构建。 +- `windows node 24 / complete` 使用 32 核 Windows 池。一次设置供必需的包构建、必需的生产网站构建以及完整的观测性可移植性清单共用。外层调度器设有 32 个槽位。任何必需项失败都会使作业失败;观测项失败会明确打印为非阻塞,并保留其原有的建议性状态。ESLint 本身仍采用单线程,因为启用 16 个 ESLint 工作线程会把完整 lint 的耗时增加至 174.54 秒;外层门禁并发能够利用该运行器,同时不会成倍增加 Windows 工作线程的启动和 TypeScript 项目加载开销。 -两个 Windows 作业有意使用不同的运行器池。首个候选方案让二者都使用 16 核池;尽管已配置自动扩缩容上限,GitHub 仍花费 93 秒才预配好第二台同标签运行器。分别使用 16 核池和 32 核池后,最终验证运行中的每个生产作业都在 2 秒内开始运行。 +最终结构由两项冷路径观测结果确定。首个候选方案使用两个 16 核 Windows 作业;尽管已配置自动扩缩容上限,GitHub 仍花费 93 秒才预配好第二台同标签运行器。随后一次[文档分支头验证](https://github.com/deepseek-harness/deepseek-harness/actions/runs/29900502413)中,合并后的 Linux 作业耗时 64 秒,因为其中的生产网站门禁耗时 29.05 秒;另一个独立的 Windows 阻塞性作业则耗时 266 秒,其中恢复 153 MB pnpm 缓存就用了 138 秒。把这项独立的 Linux 门禁移入一个粗粒度 16 核通道,并把所有 Windows 工作合并到一台 32 核机器上,消除了这两轮重复设置。 工作流保留 4 项手动诊断。`suite=larger-runner-benchmark` 比较所有规格下相互独立的关键通道,`suite=consolidated-runner-benchmark` 比较完整聚合流程,`suite=sharded-reference` 保留原生产分片拓扑,`suite=serial-reference` 则继续作为未分片的跨平台完整性判定基准。当拉取请求无法生成合并提交时,`suite=optimized-larger-runners` 会直接针对分支引用运行与生产环境完全相同的拓扑。 @@ -33,23 +33,23 @@ Status: implemented 这些单项结果表明设置开销占主导地位,却无法确定生产环境应选用的规格。一项[未启用 ESLint 原生并发的完整聚合基准测试](https://github.com/deepseek-harness/deepseek-harness/actions/runs/29897826082)发现,Linux 单线程 lint 门禁耗时 69 秒。启用 Linux ESLint 原生并发后,[第二次完整聚合基准测试](https://github.com/deepseek-harness/deepseek-harness/actions/runs/29898331705)得到了以下作业活动耗时: -| 聚合作业 | 4 核 | 8 核 | 16 核 | 32 核 | 64 核 | 96 核 | +| 作业 | 4 核 | 8 核 | 16 核 | 32 核 | 64 核 | 96 核 | |---|---:|---:|---:|---:|---:|---:| | Linux 完整主流程 | 147 秒 | 104 秒 | 95 秒 | 57 秒时失败 | 66 秒 | 60 秒 | | Windows 阻塞性构建 | 137 秒 | 127 秒 | 113 秒 | 107 秒 | 105 秒 | 131 秒 | -Linux 32 核作业的失败是首次发生的 CJS 词法分析器工作进程崩溃。在所有规格的结果中,96 核聚合作业是唯一成功达到 1 分钟边界的结果。Windows 超过 16 核后的收益很小,因此阻塞性作业使用 16 核;观测作业则使用单独的 32 核池,以避免同标签运行器的预配延迟,并让全部外层门禁同时启动。 +Linux 32 核作业的失败是首次发生的 CJS 词法分析器工作进程崩溃。在所有规格的结果中,96 核聚合作业是唯一成功达到 1 分钟边界的结果。尽管 Windows 仓库工作在超过 16 核后收益很小,32 核池可以让完整的外层清单同时启动;更重要的是,它能从生产环境中消除整整一次付费设置。 -生产环境的精确[验证运行](https://github.com/deepseek-harness/deepseek-harness/actions/runs/29899733584)在受测分支头通过了所有作业: +精确的[低扇出验证运行](https://github.com/deepseek-harness/deepseek-harness/actions/runs/29901539360)在受测分支头通过了所有作业: | 生产作业 | 活动耗时 | 仓库工作 | 结果 | |---|---:|---:|---:| -| Linux 完整主流程 | 50 秒 | 40 项门禁耗时 23.23 秒 | 通过 | -| 最慢的标准非 Windows 作业 | 40 秒 | Node 26 兼容性 | 通过 | -| Windows 阻塞性构建 | 91 秒 | 2 项门禁耗时 28.69 秒 | 通过 | -| Windows 观测作业 | 153 秒 | 37 项门禁耗时 31.83 秒 | 通过 | +| Linux 核心 | 52 秒 | 39 项门禁耗时 25.84 秒 | 通过 | +| Linux 生产网站 | 44 秒 | VitePress 耗时 22.42 秒 | 通过 | +| 最慢的标准非 Windows 作业 | 43 秒 | Node 26 兼容性 | 通过 | +| Windows 完整流程 | 96 秒 | 37 项门禁耗时 31.13 秒 | 通过 | -Windows 观测作业花费 57 秒恢复 pnpm 缓存,因此其剩余余量既反映托管环境的设置波动,也反映仓库工作耗时。最终运行中每个非 Windows 作业仍低于 1 分钟,两个 Windows 作业也都低于 3 分钟。 +3 个付费作业同时开始运行。Windows 作业花费 21 秒恢复 pnpm 缓存、25 秒安装依赖,因此其余量既反映托管环境的设置波动,也反映仓库工作耗时。每个非 Windows 作业均低于 1 分钟,唯一的 Windows 作业也低于 3 分钟。 ## 曾考虑的替代方案 @@ -57,16 +57,20 @@ Windows 观测作业花费 57 秒恢复 pnpm 缓存,因此其剩余余量既 **根据关键通道基准测试选择生产规格。** 对单独的类型检查和网站构建而言,4 核看起来具备成本效益,但完整聚合流程发现了这些命令未覆盖的全仓库 lint 和存在依赖关系的产物工作。 -**在启动 Linux 聚合流程前预先构建。** 此方案让构建成为设置路径的一部分,并产生了一个耗时 66 秒的候选结果。在 `run-gates` 内尽早启动构建,既能保留产物依赖关系,又能让构建与无关检查重叠执行;最终聚合流程耗时 23.23 秒。 +**在 Linux 核心聚合流程内运行生产网站。** 托管环境设置处于热状态时,此方案用时 50 秒;当网站门禁耗时 29.05 秒时,总耗时增至 64 秒,超过了目标。一项独立的粗粒度网站作业能够保障目标,而无需重新采用门禁级分片。 + +**将必需的 Windows 检查和观测性 Windows 检查保留在不同作业中。** 这种拆分在工作流层保留了状态语义,却要支付两次设置开销,而且冷缓存让必需作业的耗时达到 266 秒。`run-gates` 目前在一个进程内保留这些语义:构建和生产网站属于必需项,其余清单则明确为非阻塞。 + +**在启动 Linux 聚合流程前预先构建。** 此方案让构建成为设置路径的一部分,并产生了一个耗时 66 秒的候选结果。在 `run-gates` 内尽早启动构建,既能保留产物依赖关系,又能让构建与无关检查重叠执行。 **在 Windows 上使用 ESLint 原生工作线程并发。** 16 个工作线程让 lint 比最终的单线程结果慢 5 倍以上。外层门禁并发能够利用 32 核运行器,同时不会成倍增加 ESLint 在 Windows 上启动工作线程和加载 TypeScript 项目的开销。 -**将兼容性、Python 和聚合作业迁移到大型运行器。** 这些标准运行器作业都在 40 秒以内完成。付费容量不会缩短关键路径。 +**将兼容性、Python 和聚合作业迁移到大型运行器。** 这些标准运行器作业都在 43 秒以内完成。付费容量不会缩短关键路径。 ## 后果 -最终生产验证产生的计费时长为:96 核 Linux 1 分钟、16 核 Windows 2 分钟和 32 核 Windows 3 分钟。按已配置的大型运行器费率计算,其大型运行器成本为 $0.902。全规格关键通道基准测试的成本为 $2.936。GitHub 会把每个大型运行器作业向上取整到整分钟计费,因此把付费作业数从 49 个减少到 3 个,与缩短仓库工作耗时同样重要。 +低扇出验证产生的计费时长为:96 核 Linux 1 分钟、16 核 Linux 1 分钟和 32 核 Windows 2 分钟。按已配置的大型运行器费率计算,其大型运行器成本为 $0.618。全规格关键通道基准测试的成本为 $2.936。GitHub 会把每个大型运行器作业向上取整到整分钟计费,因此把付费作业数从 49 个减少到 3 个,与缩短仓库工作耗时同样重要。 现有的零美元 Actions 预算并未阻止大型运行器作业。仅限本仓库的运行器组、有界的工作流拓扑、手动基准测试触发和作业超时限制才是经实测有效的成本控制机制;该预算不被视为执行防护措施。 -生产 CI 依赖本 Agent Note 和 `.github/workflows/ci.yml` 中由组织持有的运行器名称。池缺失或改名会让作业一直排队,不会回退到标准容量。手动全规格套件和原分片套件均予以保留,以便在映像、依赖、调度器或定价发生变化后重新测量,再调整生产标签。 +生产 CI 依赖本 Agent Note 和 `.github/workflows/ci.yml` 中由组织持有的运行器名称。池缺失或改名会让作业一直排队,不会回退到标准容量。手动全规格、完整聚合、原分片和串行套件均予以保留,以便在映像、依赖、调度器或定价发生变化后重新测量,再调整生产标签。 From c67ae11f78fffd4a36ae524b6246a708920d9a07 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Wed, 22 Jul 2026 16:02:52 +0800 Subject: [PATCH 67/90] ci: stabilize compatibility runner setup --- .github/workflows/ci.yml | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 09d61f845d..41a49c4269 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -417,7 +417,9 @@ jobs: node-compat: if: github.event_name != 'workflow_dispatch' || inputs.suite == 'optimized-larger-runners' - runs-on: ubuntu-latest + # Distinct larger-runner pools avoid both standard-runner setup outliers and + # the delayed second allocation observed when two jobs shared one pool. + runs-on: ${{ matrix.runner }} name: node ${{ matrix.node }} env: DSH_GATE_CONCURRENCY: '2' @@ -427,10 +429,13 @@ jobs: matrix: include: - node: '22.19' + runner: dsh-ubuntu-24-04-4core skip_typecheck: '1' - node: 24 + runner: dsh-ubuntu-24-04-8core skip_typecheck: '1' - node: 26 + runner: dsh-ubuntu-24-04-32core skip_typecheck: '1' steps: - uses: actions/checkout@v6 @@ -460,7 +465,7 @@ jobs: python-sdk: if: github.event_name != 'workflow_dispatch' || inputs.suite == 'optimized-larger-runners' - runs-on: ubuntu-latest + runs-on: dsh-ubuntu-24-04-64core name: python 3.10 / keyless SDK steps: - uses: actions/checkout@v6 From 9d81078f2f8476f60a22bde645f4a11c69b16bbc Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Wed, 22 Jul 2026 16:10:52 +0800 Subject: [PATCH 68/90] ci: split primary cold-path budget --- .github/workflows/ci.yml | 61 +++++++++++++++++++++++++++++++--------- package.json | 1 + scripts/run-gates.ts | 59 +++++++++++++++++++++++++++----------- 3 files changed, 91 insertions(+), 30 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 41a49c4269..17cd71ffde 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -319,19 +319,16 @@ jobs: run: ${{ matrix.command }} # One large runner pays hosted setup once, then the repository scheduler - # overlaps the unsharded primary inventory except the production site build. + # overlaps the unsharded core inventory. CPU and site chains use coarse lanes. node-24: if: github.event_name != 'workflow_dispatch' || inputs.suite == 'optimized-larger-runners' runs-on: dsh-ubuntu-24-04-96core name: node 24 / core env: - # Thirty-two Vitest forks intermittently crash Node's CJS lexer on this image. - DSH_COVERAGE_MAX_WORKERS: '16' DSH_ESLINT_CACHE: '1' DSH_ESLINT_CONCURRENCY: '32' DSH_GATE_CONCURRENCY: '32' DSH_PUBLINT_CONCURRENCY: '32' - DSH_SNAPSHOT_MAX_CONCURRENCY: '32' steps: - uses: actions/checkout@v6 @@ -383,8 +380,8 @@ jobs: - name: Run unsharded primary Node core CI concurrently run: pnpm run check:ci:large-runner - # Keep only the longest independent Linux gate on a second coarse-grained - # runner so cold install variance does not push the primary box over a minute. + # Keep the longest independent Linux gate on its own coarse-grained runner so + # cold install variance does not push either primary aggregate over a minute. node-24-site: if: github.event_name != 'workflow_dispatch' || inputs.suite == 'optimized-larger-runners' runs-on: dsh-ubuntu-24-04-16core @@ -418,24 +415,39 @@ jobs: node-compat: if: github.event_name != 'workflow_dispatch' || inputs.suite == 'optimized-larger-runners' # Distinct larger-runner pools avoid both standard-runner setup outliers and - # the delayed second allocation observed when two jobs shared one pool. + # the delayed second allocation observed when two jobs shared one pool. The + # primary Node row also owns the CPU/dependency-critical primary gate chain. runs-on: ${{ matrix.runner }} - name: node ${{ matrix.node }} + name: ${{ matrix.name }} env: - DSH_GATE_CONCURRENCY: '2' + DSH_COVERAGE_MAX_WORKERS: ${{ matrix.coverage_workers }} + DSH_GATE_CONCURRENCY: ${{ matrix.gate_concurrency }} DSH_NODE_COMPAT_SKIP_TYPECHECK: ${{ matrix.skip_typecheck }} + DSH_SNAPSHOT_MAX_CONCURRENCY: ${{ matrix.snapshot_workers }} strategy: fail-fast: false matrix: include: - node: '22.19' + name: node 22.19 runner: dsh-ubuntu-24-04-4core + command: pnpm run check:node-compat + gate_concurrency: '2' skip_typecheck: '1' - node: 24 - runner: dsh-ubuntu-24-04-8core + name: node 24 / cpu + runner: dsh-ubuntu-24-04-64core + command: pnpm run check:ci:primary-cpu + coverage_workers: '16' + gate_concurrency: '6' + primary_cpu: true + snapshot_workers: '16' skip_typecheck: '1' - node: 26 + name: node 26 runner: dsh-ubuntu-24-04-32core + command: pnpm run check:node-compat + gate_concurrency: '2' skip_typecheck: '1' steps: - uses: actions/checkout@v6 @@ -458,14 +470,37 @@ jobs: ${{ runner.os }}-node-${{ matrix.node }}-pnpm- - name: Install (immutable) + if: matrix.primary_cpu != true run: pnpm install --frozen-lockfile - - name: Run compatibility gates - run: pnpm run check:node-compat + - name: Install and prepare bubblewrap + if: matrix.primary_cpu == true + run: | + pnpm install --frozen-lockfile & + install_pid=$! + ( + if ! sudo apt-get install -yq --no-install-recommends bubblewrap; then + echo "initial bubblewrap install failed; refreshing APT indexes and retrying" + sudo apt-get update -q + sudo apt-get install -yq --no-install-recommends bubblewrap + fi + sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 \ + || echo "apparmor userns knob absent — the functional probe decides" + ) & + sandbox_pid=$! + install_status=0 + wait "$install_pid" || install_status=$? + sandbox_status=0 + wait "$sandbox_pid" || sandbox_status=$? + if (( install_status != 0 )); then exit "$install_status"; fi + exit "$sandbox_status" + + - name: Run assigned gates + run: ${{ matrix.command }} python-sdk: if: github.event_name != 'workflow_dispatch' || inputs.suite == 'optimized-larger-runners' - runs-on: dsh-ubuntu-24-04-64core + runs-on: dsh-ubuntu-24-04-8core name: python 3.10 / keyless SDK steps: - uses: actions/checkout@v6 diff --git a/package.json b/package.json index c9e5e01a71..c318a3a94d 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ "test:snapshot:record": "DSH_SNAPSHOT=record vitest run --config vitest.snapshot.config.ts --update", "test:snapshot:refresh": "DSH_SNAPSHOT=refresh vitest run --config vitest.snapshot.config.ts", "check:ci": "tsx scripts/run-gates.ts ci-primary", + "check:ci:primary-cpu": "tsx scripts/run-gates.ts ci-primary-cpu", "check:ci:large-runner": "tsx scripts/run-gates.ts ci-primary-large-runner", "check:ci:static": "tsx scripts/run-gates.ts ci-static", "check:ci:lint": "tsx scripts/run-gates.ts ci-lint", diff --git a/scripts/run-gates.ts b/scripts/run-gates.ts index 11155551db..ff4b1aab2d 100644 --- a/scripts/run-gates.ts +++ b/scripts/run-gates.ts @@ -15,6 +15,7 @@ import { selectStaticGates } from './static-shards.ts' type Mode = | 'ci-primary' + | 'ci-primary-cpu' | 'ci-primary-large-runner' | 'ci-static' | 'ci-lint' @@ -92,6 +93,7 @@ if (results.some(result => result.gate.allowFailure !== true && (result.status = function parseMode(raw: string | undefined): Mode { switch (raw) { case 'ci-primary': + case 'ci-primary-cpu': case 'ci-primary-large-runner': case 'ci-static': case 'ci-lint': @@ -107,7 +109,7 @@ function parseMode(raw: string | undefined): Mode { return raw default: throw new Error( - `run-gates: expected mode ci-primary | ci-primary-large-runner | ci-static | ci-lint | ci-coverage | ci-snapshot | ci-artifacts | ci-windows-blocking | ci-windows-complete | ci-windows-observational | node-compat | pre-push | doc-sync, got ${JSON.stringify(raw)}.`, + `run-gates: expected mode ci-primary | ci-primary-cpu | ci-primary-large-runner | ci-static | ci-lint | ci-coverage | ci-snapshot | ci-artifacts | ci-windows-blocking | ci-windows-complete | ci-windows-observational | node-compat | pre-push | doc-sync, got ${JSON.stringify(raw)}.`, ) } } @@ -173,6 +175,8 @@ function gatesForMode(selected: Mode): Gate[] { switch (selected) { case 'ci-primary': return ciPrimaryGates() + case 'ci-primary-cpu': + return ciPrimaryCpuGates() case 'ci-primary-large-runner': return ciPrimaryLargeRunnerGates() case 'ci-static': @@ -199,19 +203,7 @@ function gatesForMode(selected: Mode): Gate[] { case 'ci-windows-observational': return ciWindowsObservationalGates() case 'node-compat': - return [ - ...flagEnabled('DSH_NODE_COMPAT_SKIP_TYPECHECK') ? [] : [pnpmScript('typecheck', 'typecheck')], - pnpmExec('source-worker-smoke', [ - 'vitest', - 'run', - 'packages/workflow/workflow-workerthread/tests/source-worker.compat.spec.ts', - ], { label: 'source worker smoke' }), - pnpmExec('jsonl-zstd-smoke', [ - 'vitest', - 'run', - 'packages/session-persistence/session-persistence-jsonl/tests/zstd.compat.spec.ts', - ], { label: 'JSONL Zstandard smoke' }), - ] + return nodeCompatGates() case 'pre-push': return [ pnpmScript('runtime-closure', 'verify-runtime-closure', { label: 'runtime closure' }), @@ -258,10 +250,10 @@ function ciPrimaryGates(): Gate[] { } function ciPrimaryLargeRunnerGates(): Gate[] { - // Typecheck does not consume build output, so a large runner can start both - // together while snapshot and artifact consumers still wait for the build. + // The CPU lane owns typecheck, coverage, and the build-to-snapshot chain. + // This core lane starts its own build eagerly for the remaining artifact consumers. return ciPrimaryGates() - .filter(gate => gate.id !== 'docs-site-build') + .filter(gate => !['coverage', 'docs-site-build', 'snapshot', 'typecheck'].includes(gate.id)) .map((gate) => { if (gate.id !== 'build') return gate const eagerBuild = { ...gate } @@ -270,6 +262,39 @@ function ciPrimaryLargeRunnerGates(): Gate[] { }) } +function ciPrimaryCpuGates(): Gate[] { + // Build and snapshot stay together so the dependent replay consumes this lane's output. + return [ + pnpmScript('typecheck', 'typecheck'), + coverageGate(), + pnpmScript('build', 'build'), + snapshotGate(), + ...nodeCompatSmokeGates(), + ] +} + +function nodeCompatGates(): Gate[] { + return [ + ...flagEnabled('DSH_NODE_COMPAT_SKIP_TYPECHECK') ? [] : [pnpmScript('typecheck', 'typecheck')], + ...nodeCompatSmokeGates(), + ] +} + +function nodeCompatSmokeGates(): Gate[] { + return [ + pnpmExec('source-worker-smoke', [ + 'vitest', + 'run', + 'packages/workflow/workflow-workerthread/tests/source-worker.compat.spec.ts', + ], { label: 'source worker smoke' }), + pnpmExec('jsonl-zstd-smoke', [ + 'vitest', + 'run', + 'packages/session-persistence/session-persistence-jsonl/tests/zstd.compat.spec.ts', + ], { label: 'JSONL Zstandard smoke' }), + ] +} + function ciStaticGates(): Gate[] { const gates = [ pnpmScript('runtime-closure', 'verify-runtime-closure', { label: 'runtime closure' }), From 2bd466a4a68acf0cd78db03a1256ed3f7f69a83f Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Wed, 22 Jul 2026 16:21:17 +0800 Subject: [PATCH 69/90] docs: record all-pool validation --- ...ence-based-larger-hosted-runners.i18n.yaml | 4 +-- ...22-evidence-based-larger-hosted-runners.md | 33 ++++++++++++------- ...evidence-based-larger-hosted-runners.zh.md | 33 ++++++++++++------- 3 files changed, 44 insertions(+), 26 deletions(-) diff --git a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.i18n.yaml b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.i18n.yaml index d5b558837f..5e7792d08d 100644 --- a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.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 -2026-07-22-evidence-based-larger-hosted-runners.md: 7d38552d23f7a3fb8abca45cbb4cf4e9c82fdd78 -2026-07-22-evidence-based-larger-hosted-runners.zh.md: f16db8eeb9000fe9a7671345efa9b1fd7fc744e1 +2026-07-22-evidence-based-larger-hosted-runners.md: 5390cce001f5d493fc4fb3eaee9203c34af1175d +2026-07-22-evidence-based-larger-hosted-runners.zh.md: 04a32f9ac1e0b131a695695b8f6cece3a8ffd3fa diff --git a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.md b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.md index 7d38552d23..5390cce001 100644 --- a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.md +++ b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.md @@ -14,13 +14,17 @@ Larger runners make it possible to pay setup once and parallelize inside the rep The organization keeps twelve x64 larger-runner pools in the repo-restricted `dsh-larger-ci` group: Ubuntu 24.04 and Windows 2025 at 4, 8, 16, 32, 64, and 96 cores. Public IPs are disabled. Each pool has an autoscaling ceiling of 256; the ceiling does not allocate idle machines or remove the need to bound workflow demand. -Production CI uses three coarse larger-runner jobs and keeps Node compatibility, Python, and the final aggregator on standard runners: +Production CI assigns each of the six Linux pool sizes exactly once, assigns one 32-core Windows pool, and keeps only the final aggregator on a standard runner. The version and language jobs are environment contracts rather than slices of one gate inventory; the primary Node work has three coarse lanes instead of a gate-level shard matrix: -- `node 24 / core` uses the 96-core Linux pool. One checkout, setup, cache restore, and install feeds the unsharded 39-gate primary inventory other than the production site build. `run-gates` starts up to 32 independent gates, ESLint uses 32 workers, snapshots use up to 32 subprocesses, and coverage uses 16 forks. Build starts beside typecheck; snapshot and artifact consumers still wait for emitted output. Coverage stays below 32 forks because that setting twice caused Node 24's CJS lexer to terminate a Vitest worker and invalidate coverage. +- `node 24 / core` uses the 96-core Linux pool. One checkout, setup, cache restore, and install feeds 36 unsharded static, lint, documentation, hygiene, build, and artifact gates. `run-gates` starts up to 32 independent gates and ESLint uses 32 workers. Build starts eagerly; its artifact consumers still wait for emitted output. +- `node 24 / cpu` uses the 64-core Linux pool for six CPU- or dependency-critical gates: typecheck, coverage, build followed by snapshot replay, and two Node 24 compatibility smokes. Coverage and snapshot each use at most 16 workers. This lane builds separately so snapshot replay consumes same-lane output. Coverage stays below 32 forks because that setting twice caused Node 24's CJS lexer to terminate a Vitest worker and invalidate coverage. - `node 24 / production site` uses the 16-core Linux pool for the longest independent primary gate. This is one coarse split, not a shard matrix: the job performs one setup and one production VitePress build. +- Node 22.19 compatibility, Python 3.10, and Node 26 compatibility use the 4-, 8-, and 32-core Linux pools respectively. Distinct labels avoid both standard-runner setup outliers and the delayed second allocation observed when two jobs shared one pool. - `windows node 24 / complete` uses the 32-core Windows pool. One setup feeds the required package build, the required production site build, and the complete observational portability inventory. The outer scheduler has 32 slots. Required failures fail the job; observational failures are printed as non-blocking and preserve their former advisory status. ESLint itself stays single-threaded because 16 ESLint worker threads increased full-lint time to 174.54 seconds; outer gate concurrency uses the runner without multiplying Windows worker startup and TypeScript project loading. -The final shape followed two cold-path observations. A first candidate used two 16-core Windows jobs, and GitHub took 93 seconds to provision the second same-label runner despite the configured autoscaling ceiling. A later [documentation-head validation](https://github.com/deepseek-harness/deepseek-harness/actions/runs/29900502413) took 64 seconds on the combined Linux job because its production site gate lasted 29.05 seconds, while a separate Windows blocking job took 266 seconds after spending 138 seconds restoring a 153 MB pnpm cache. Moving that independent Linux gate to one coarse 16-core lane and combining all Windows work on one 32-core box removed both duplicate setup waves. +The Windows shape followed two cold-path observations. A first candidate used two 16-core Windows jobs, and GitHub took 93 seconds to provision the second same-label runner despite the configured autoscaling ceiling. A later [documentation-head validation](https://github.com/deepseek-harness/deepseek-harness/actions/runs/29900502413) took 266 seconds on a separate Windows blocking job after spending 138 seconds restoring a 153 MB pnpm cache. Combining all Windows work on one 32-core box removed the duplicate setup wave. + +Two later runs set the Linux boundaries. A [standard-runner validation](https://github.com/deepseek-harness/deepseek-harness/actions/runs/29902209492) took 67 seconds for Node 26 even though repository work took five seconds, because GitHub spent 36 seconds in `Set up job`. Moving the environment contracts to distinct larger pools removed that lottery. The next [all-larger-runner validation](https://github.com/deepseek-harness/deepseek-harness/actions/runs/29902541203) took 68 seconds on the 96-core primary job: repository work remained 26 seconds, but setup, cache, install, and finalization consumed 42 seconds. Moving typecheck, coverage, and the build-to-snapshot dependency chain to one coarse 64-core lane reduced the 96-core lane's repository critical path to 14.81 seconds without returning to per-gate shards. The workflow retains four manual diagnostics. `suite=larger-runner-benchmark` compares isolated critical lanes across every size, `suite=consolidated-runner-benchmark` compares whole aggregates, `suite=sharded-reference` preserves the former production shard topology, and `suite=serial-reference` remains the unsharded cross-platform completeness oracle. `suite=optimized-larger-runners` runs the exact production topology against a branch ref when a pull request cannot form a merge commit. @@ -40,16 +44,19 @@ Those isolated results showed that setup dominated but did not identify the prod The Linux 32-core failure was the first CJS-lexer worker crash. The 96-core aggregate was the only successful all-size result at the one-minute boundary. Although Windows repository work gained little above 16 cores, the 32-core pool can start the complete outer inventory together and, more importantly, removes an entire paid setup from production. -The exact [reduced-fanout validation run](https://github.com/deepseek-harness/deepseek-harness/actions/runs/29901539360) passed every job at the tested branch head: +The exact [all-pool validation run](https://github.com/deepseek-harness/deepseek-harness/actions/runs/29903067274) passed every job at the tested branch head: -| Production job | Active time | Repository work | Result | +| Production job (pool) | Active time | Repository work | Result | |---|---:|---:|---:| -| Linux core | 52 s | 39 gates in 25.84 s | passed | -| Linux production site | 44 s | VitePress in 22.42 s | passed | -| Slowest standard non-Windows job | 43 s | Node 26 compatibility | passed | -| Windows complete | 96 s | 37 gates in 31.13 s | passed | +| Node 22.19 compatibility (Linux 4) | 26 s | compatibility smokes | passed | +| Python 3.10 (Linux 8) | 22 s | complete keyless SDK suite | passed | +| Production site (Linux 16) | 39 s | VitePress in 22.57 s | passed | +| Node 26 compatibility (Linux 32) | 24 s | compatibility smokes | passed | +| Primary CPU (Linux 64) | 53 s | 6 gates in 23.70 s | passed | +| Primary core (Linux 96) | 47 s | 36 gates in 14.81 s | passed | +| Windows complete (Windows 32) | 109 s | 37 gates in 32.71 s | passed | -All three paid jobs began together. The Windows job spent 21 seconds restoring its pnpm cache and 25 seconds installing dependencies, so its margin measures hosted setup variance as well as repository work. Every non-Windows job stays below one minute and the sole Windows job stays below three minutes. +All seven paid jobs began in the same second. The slowest non-Windows job finished in 53 seconds. The Windows job spent 20 seconds restoring its pnpm cache and 15 seconds installing dependencies, so its 109-second active time measures hosted setup variance as well as repository work. Every non-Windows job stays below one minute and the sole Windows job stays below three minutes. ## Alternatives considered @@ -59,17 +66,19 @@ All three paid jobs began together. The Windows job spent 21 seconds restoring i **Run the production site inside the Linux core aggregate.** This reached 50 seconds with warm hosted setup, then crossed the threshold at 64 seconds when the site gate took 29.05 seconds. One coarse independent site job protects the target without returning to gate-level sharding. +**Keep every primary gate on the 96-core Linux runner.** Repository work completed in 26 seconds, but a 42-second cold path still pushed the job to 68 seconds. The 64-core CPU lane owns the three longest independent or dependency-critical paths; the remaining 36-gate core inventory completes its repository work in 14.81 seconds. + **Keep required and observational Windows checks in separate jobs.** The split preserved status semantics at the workflow level but paid setup twice, and a cold cache pushed the required job to 266 seconds. `run-gates` now preserves those semantics inside one process: build and production site are required, while the remaining inventory is explicitly non-blocking. **Prebuild before starting the Linux aggregate.** This moved build onto the setup path and produced a 66-second candidate. Starting build eagerly inside `run-gates` preserves artifact dependencies while overlapping it with unrelated checks. **Use native ESLint worker concurrency on Windows.** Sixteen workers made lint more than five times slower than the final single-threaded result. Outer gate parallelism uses the 32-core runner without multiplying ESLint's Windows worker startup and TypeScript project loading. -**Move compatibility, Python, and aggregation to larger runners.** These standard-runner jobs completed in 43 seconds or less. Paid capacity would not shorten the critical path. +**Keep compatibility and Python on standard runners.** Warm runs completed in 43 seconds or less, but one Node 26 job later spent 36 seconds in GitHub setup and crossed the target despite only five seconds of repository work. Distinct larger pools stabilize those environment contracts; the three-second final aggregator remains on a standard runner because it begins only after the paid jobs release capacity. ## Consequences -The reduced-fanout validation consumed one billed 96-core Linux minute, one billed 16-core Linux minute, and two billed 32-core Windows minutes. At the configured larger-runner rates, its larger-runner cost was $0.618. The all-size critical benchmark cost $2.936. GitHub rounds each larger-runner job up to a whole minute, so reducing paid job count from 49 to three matters as much as shortening repository work. +The all-pool validation consumed one billed minute at each Linux size and two billed 32-core Windows minutes. At the configured larger-runner rates, its larger-runner cost was $0.896. The all-size critical benchmark cost $2.936. GitHub rounds each larger-runner job up to a whole minute, so reducing paid job count from 49 to seven matters as much as shortening repository work. The existing zero-dollar Actions budget did not block larger-runner jobs. The repo-only runner group, bounded workflow topology, manual benchmark triggers, and job timeouts are the observed cost controls; the budget is not treated as an execution guard. diff --git a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.zh.md b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.zh.md index f16db8eeb9..04a32f9ac1 100644 --- a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.zh.md +++ b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.zh.md @@ -14,13 +14,17 @@ Status: implemented 组织在仅限本仓库使用的 `dsh-larger-ci` 运行器组中保留 12 个 x64 大型运行器池:Ubuntu 24.04 和 Windows 2025 各设 4、8、16、32、64、96 核规格。公网 IP 已禁用。每个池的自动扩缩容上限为 256;该上限既不会分配闲置机器,也不能免除限制工作流需求的必要性。 -生产 CI 使用 3 个粗粒度大型运行器作业,并让 Node 兼容性、Python 和最终聚合作业继续使用标准运行器: +生产 CI 对 6 种 Linux 池规格各使用一次,另使用一个 32 核 Windows 池,只有最终聚合作业继续使用标准运行器。版本和语言作业属于环境契约,并非从同一门禁清单切出的分片;主 Node 工作采用 3 个粗粒度通道,而非门禁级分片矩阵: -- `node 24 / core` 使用 96 核 Linux 池。只需执行一次代码检出、设置、缓存恢复和安装,即可供生产网站构建以外、未分片的 39 项主门禁清单使用。`run-gates` 最多同时启动 32 项相互独立的门禁,ESLint 使用 32 个工作线程,快照最多使用 32 个子进程,覆盖率运行使用 16 个 fork。构建与类型检查同时启动;快照和产物消费方仍会等待生成的输出。覆盖率运行的 fork 数保持低于 32,因为将其设为 32 曾两次导致 Node 24 的 CJS 词法分析器终止一个 Vitest 工作进程,使覆盖率结果失效。 +- `node 24 / core` 使用 96 核 Linux 池。只需执行一次代码检出、设置、缓存恢复和安装,即可供 36 项未分片的静态、lint、文档、hygiene、构建和产物门禁使用。`run-gates` 最多同时启动 32 项相互独立的门禁,ESLint 使用 32 个工作线程。构建会尽早启动;其产物消费方仍会等待生成的输出。 +- `node 24 / cpu` 使用 64 核 Linux 池运行 6 项 CPU 或依赖关键门禁:类型检查、覆盖率、构建后回放快照,以及两项 Node 24 兼容性冒烟测试。覆盖率和快照各最多使用 16 个工作进程。该通道单独构建,使快照回放使用同一通道生成的输出。覆盖率运行的 fork 数保持低于 32,因为将其设为 32 曾两次导致 Node 24 的 CJS 词法分析器终止一个 Vitest 工作进程,使覆盖率结果失效。 - `node 24 / production site` 使用 16 核 Linux 池运行耗时最长的独立主门禁。这是一项粗粒度拆分,而非分片矩阵:该作业执行一次设置和一次生产版 VitePress 构建。 +- Node 22.19 兼容性、Python 3.10 和 Node 26 兼容性分别使用 4 核、8 核和 32 核 Linux 池。不同标签既避免了标准运行器设置耗时的异常值,也避免了两个作业共享一个池时曾出现的第二次分配延迟。 - `windows node 24 / complete` 使用 32 核 Windows 池。一次设置供必需的包构建、必需的生产网站构建以及完整的观测性可移植性清单共用。外层调度器设有 32 个槽位。任何必需项失败都会使作业失败;观测项失败会明确打印为非阻塞,并保留其原有的建议性状态。ESLint 本身仍采用单线程,因为启用 16 个 ESLint 工作线程会把完整 lint 的耗时增加至 174.54 秒;外层门禁并发能够利用该运行器,同时不会成倍增加 Windows 工作线程的启动和 TypeScript 项目加载开销。 -最终结构由两项冷路径观测结果确定。首个候选方案使用两个 16 核 Windows 作业;尽管已配置自动扩缩容上限,GitHub 仍花费 93 秒才预配好第二台同标签运行器。随后一次[文档分支头验证](https://github.com/deepseek-harness/deepseek-harness/actions/runs/29900502413)中,合并后的 Linux 作业耗时 64 秒,因为其中的生产网站门禁耗时 29.05 秒;另一个独立的 Windows 阻塞性作业则耗时 266 秒,其中恢复 153 MB pnpm 缓存就用了 138 秒。把这项独立的 Linux 门禁移入一个粗粒度 16 核通道,并把所有 Windows 工作合并到一台 32 核机器上,消除了这两轮重复设置。 +Windows 结构由两项冷路径观测结果确定。首个候选方案使用两个 16 核 Windows 作业;尽管已配置自动扩缩容上限,GitHub 仍花费 93 秒才预配好第二台同标签运行器。随后一次[文档分支头验证](https://github.com/deepseek-harness/deepseek-harness/actions/runs/29900502413)中,一个独立的 Windows 阻塞性作业耗时 266 秒,其中恢复 153 MB pnpm 缓存就用了 138 秒。把所有 Windows 工作合并到一台 32 核机器上,消除了这轮重复设置。 + +随后两次运行确定了 Linux 的边界。一次[标准运行器验证](https://github.com/deepseek-harness/deepseek-harness/actions/runs/29902209492)中,Node 26 作业耗时 67 秒,而仓库工作仅耗时 5 秒,因为 GitHub 在 `Set up job` 上花费了 36 秒。把各项环境契约迁移到不同的大型运行器池后,这种随机波动随之消失。接下来的[全大型运行器验证](https://github.com/deepseek-harness/deepseek-harness/actions/runs/29902541203)中,96 核主作业耗时 68 秒:仓库工作仍为 26 秒,但设置、缓存、安装和收尾共占用 42 秒。把类型检查、覆盖率以及构建至快照的依赖链迁移到一个粗粒度 64 核通道后,96 核通道的仓库关键路径缩短至 14.81 秒,且无需恢复按门禁分片。 工作流保留 4 项手动诊断。`suite=larger-runner-benchmark` 比较所有规格下相互独立的关键通道,`suite=consolidated-runner-benchmark` 比较完整聚合流程,`suite=sharded-reference` 保留原生产分片拓扑,`suite=serial-reference` 则继续作为未分片的跨平台完整性判定基准。当拉取请求无法生成合并提交时,`suite=optimized-larger-runners` 会直接针对分支引用运行与生产环境完全相同的拓扑。 @@ -40,16 +44,19 @@ Status: implemented Linux 32 核作业的失败是首次发生的 CJS 词法分析器工作进程崩溃。在所有规格的结果中,96 核聚合作业是唯一成功达到 1 分钟边界的结果。尽管 Windows 仓库工作在超过 16 核后收益很小,32 核池可以让完整的外层清单同时启动;更重要的是,它能从生产环境中消除整整一次付费设置。 -精确的[低扇出验证运行](https://github.com/deepseek-harness/deepseek-harness/actions/runs/29901539360)在受测分支头通过了所有作业: +精确的[全运行器池验证运行](https://github.com/deepseek-harness/deepseek-harness/actions/runs/29903067274)在受测分支头通过了所有作业: -| 生产作业 | 活动耗时 | 仓库工作 | 结果 | +| 生产作业(运行器池) | 活动耗时 | 仓库工作 | 结果 | |---|---:|---:|---:| -| Linux 核心 | 52 秒 | 39 项门禁耗时 25.84 秒 | 通过 | -| Linux 生产网站 | 44 秒 | VitePress 耗时 22.42 秒 | 通过 | -| 最慢的标准非 Windows 作业 | 43 秒 | Node 26 兼容性 | 通过 | -| Windows 完整流程 | 96 秒 | 37 项门禁耗时 31.13 秒 | 通过 | +| Node 22.19 兼容性(Linux 4) | 26 秒 | 兼容性冒烟测试 | 通过 | +| Python 3.10(Linux 8) | 22 秒 | 完整无密钥 SDK 套件 | 通过 | +| 生产网站(Linux 16) | 39 秒 | VitePress 耗时 22.57 秒 | 通过 | +| Node 26 兼容性(Linux 32) | 24 秒 | 兼容性冒烟测试 | 通过 | +| 主流程 CPU(Linux 64) | 53 秒 | 6 项门禁耗时 23.70 秒 | 通过 | +| 主流程核心(Linux 96) | 47 秒 | 36 项门禁耗时 14.81 秒 | 通过 | +| Windows 完整流程(Windows 32) | 109 秒 | 37 项门禁耗时 32.71 秒 | 通过 | -3 个付费作业同时开始运行。Windows 作业花费 21 秒恢复 pnpm 缓存、25 秒安装依赖,因此其余量既反映托管环境的设置波动,也反映仓库工作耗时。每个非 Windows 作业均低于 1 分钟,唯一的 Windows 作业也低于 3 分钟。 +7 个付费作业均在同一秒开始运行。最慢的非 Windows 作业耗时 53 秒。Windows 作业花费 20 秒恢复 pnpm 缓存、15 秒安装依赖,因此其 109 秒的活动耗时既反映托管环境的设置波动,也反映仓库工作耗时。每个非 Windows 作业均低于 1 分钟,唯一的 Windows 作业也低于 3 分钟。 ## 曾考虑的替代方案 @@ -59,17 +66,19 @@ Linux 32 核作业的失败是首次发生的 CJS 词法分析器工作进程崩 **在 Linux 核心聚合流程内运行生产网站。** 托管环境设置处于热状态时,此方案用时 50 秒;当网站门禁耗时 29.05 秒时,总耗时增至 64 秒,超过了目标。一项独立的粗粒度网站作业能够保障目标,而无需重新采用门禁级分片。 +**将所有主门禁都保留在 96 核 Linux 运行器上。** 仓库工作在 26 秒内完成,但 42 秒的冷路径仍把作业总耗时推高至 68 秒。64 核 CPU 通道负责 3 条耗时最长的独立或依赖关键路径;其余 36 项核心门禁清单的仓库工作耗时为 14.81 秒。 + **将必需的 Windows 检查和观测性 Windows 检查保留在不同作业中。** 这种拆分在工作流层保留了状态语义,却要支付两次设置开销,而且冷缓存让必需作业的耗时达到 266 秒。`run-gates` 目前在一个进程内保留这些语义:构建和生产网站属于必需项,其余清单则明确为非阻塞。 **在启动 Linux 聚合流程前预先构建。** 此方案让构建成为设置路径的一部分,并产生了一个耗时 66 秒的候选结果。在 `run-gates` 内尽早启动构建,既能保留产物依赖关系,又能让构建与无关检查重叠执行。 **在 Windows 上使用 ESLint 原生工作线程并发。** 16 个工作线程让 lint 比最终的单线程结果慢 5 倍以上。外层门禁并发能够利用 32 核运行器,同时不会成倍增加 ESLint 在 Windows 上启动工作线程和加载 TypeScript 项目的开销。 -**将兼容性、Python 和聚合作业迁移到大型运行器。** 这些标准运行器作业都在 43 秒以内完成。付费容量不会缩短关键路径。 +**让兼容性和 Python 继续使用标准运行器。** 热运行均在 43 秒以内完成,但后来一个 Node 26 作业在 GitHub 设置上花费 36 秒,即使仓库工作仅耗时 5 秒,仍超过了目标。不同的大型运行器池能够稳定这些环境契约;最终聚合作业耗时 3 秒,仍使用标准运行器,因为它只会在付费作业释放容量后启动。 ## 后果 -低扇出验证产生的计费时长为:96 核 Linux 1 分钟、16 核 Linux 1 分钟和 32 核 Windows 2 分钟。按已配置的大型运行器费率计算,其大型运行器成本为 $0.618。全规格关键通道基准测试的成本为 $2.936。GitHub 会把每个大型运行器作业向上取整到整分钟计费,因此把付费作业数从 49 个减少到 3 个,与缩短仓库工作耗时同样重要。 +全运行器池验证中,每种 Linux 规格各计费 1 分钟,32 核 Windows 计费 2 分钟。按已配置的大型运行器费率计算,其大型运行器成本为 $0.896。全规格关键通道基准测试的成本为 $2.936。GitHub 会把每个大型运行器作业向上取整到整分钟计费,因此把付费作业数从 49 个减少到 7 个,与缩短仓库工作耗时同样重要。 现有的零美元 Actions 预算并未阻止大型运行器作业。仅限本仓库的运行器组、有界的工作流拓扑、手动基准测试触发和作业超时限制才是经实测有效的成本控制机制;该预算不被视为执行防护措施。 From b63e6ee72d4cb0453285e0f5f5e124b2388e34ff Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Wed, 22 Jul 2026 16:26:25 +0800 Subject: [PATCH 70/90] ci: bound coverage worker forks --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 17cd71ffde..80a158210e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -438,7 +438,7 @@ jobs: name: node 24 / cpu runner: dsh-ubuntu-24-04-64core command: pnpm run check:ci:primary-cpu - coverage_workers: '16' + coverage_workers: '12' gate_concurrency: '6' primary_cpu: true snapshot_workers: '16' From 5e4776500d4335c0de4ca7f71ca276c04af514a1 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Wed, 22 Jul 2026 16:33:47 +0800 Subject: [PATCH 71/90] docs: record stable coverage concurrency --- ...ence-based-larger-hosted-runners.i18n.yaml | 4 ++-- ...22-evidence-based-larger-hosted-runners.md | 22 ++++++++++--------- ...evidence-based-larger-hosted-runners.zh.md | 22 ++++++++++--------- 3 files changed, 26 insertions(+), 22 deletions(-) diff --git a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.i18n.yaml b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.i18n.yaml index 5e7792d08d..509cff58e2 100644 --- a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.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 -2026-07-22-evidence-based-larger-hosted-runners.md: 5390cce001f5d493fc4fb3eaee9203c34af1175d -2026-07-22-evidence-based-larger-hosted-runners.zh.md: 04a32f9ac1e0b131a695695b8f6cece3a8ffd3fa +2026-07-22-evidence-based-larger-hosted-runners.md: 81de62fa15623b96ac4913ee6c6fc15e886222b0 +2026-07-22-evidence-based-larger-hosted-runners.zh.md: 4e31937284b221d92552957ecc0434d9fc2e2914 diff --git a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.md b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.md index 5390cce001..81de62fa15 100644 --- a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.md +++ b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.md @@ -17,7 +17,7 @@ The organization keeps twelve x64 larger-runner pools in the repo-restricted `ds Production CI assigns each of the six Linux pool sizes exactly once, assigns one 32-core Windows pool, and keeps only the final aggregator on a standard runner. The version and language jobs are environment contracts rather than slices of one gate inventory; the primary Node work has three coarse lanes instead of a gate-level shard matrix: - `node 24 / core` uses the 96-core Linux pool. One checkout, setup, cache restore, and install feeds 36 unsharded static, lint, documentation, hygiene, build, and artifact gates. `run-gates` starts up to 32 independent gates and ESLint uses 32 workers. Build starts eagerly; its artifact consumers still wait for emitted output. -- `node 24 / cpu` uses the 64-core Linux pool for six CPU- or dependency-critical gates: typecheck, coverage, build followed by snapshot replay, and two Node 24 compatibility smokes. Coverage and snapshot each use at most 16 workers. This lane builds separately so snapshot replay consumes same-lane output. Coverage stays below 32 forks because that setting twice caused Node 24's CJS lexer to terminate a Vitest worker and invalidate coverage. +- `node 24 / cpu` uses the 64-core Linux pool for six CPU- or dependency-critical gates: typecheck, coverage, build followed by snapshot replay, and two Node 24 compatibility smokes. Coverage uses at most 12 workers and snapshot uses at most 16. This lane builds separately so snapshot replay consumes same-lane output. Coverage stays at 12 forks because 32 forks crashed Node 24's CJS lexer twice and a later 16-fork run reproduced the same worker loss and invalid coverage. - `node 24 / production site` uses the 16-core Linux pool for the longest independent primary gate. This is one coarse split, not a shard matrix: the job performs one setup and one production VitePress build. - Node 22.19 compatibility, Python 3.10, and Node 26 compatibility use the 4-, 8-, and 32-core Linux pools respectively. Distinct labels avoid both standard-runner setup outliers and the delayed second allocation observed when two jobs shared one pool. - `windows node 24 / complete` uses the 32-core Windows pool. One setup feeds the required package build, the required production site build, and the complete observational portability inventory. The outer scheduler has 32 slots. Required failures fail the job; observational failures are printed as non-blocking and preserve their former advisory status. ESLint itself stays single-threaded because 16 ESLint worker threads increased full-lint time to 174.54 seconds; outer gate concurrency uses the runner without multiplying Windows worker startup and TypeScript project loading. @@ -26,6 +26,8 @@ The Windows shape followed two cold-path observations. A first candidate used tw Two later runs set the Linux boundaries. A [standard-runner validation](https://github.com/deepseek-harness/deepseek-harness/actions/runs/29902209492) took 67 seconds for Node 26 even though repository work took five seconds, because GitHub spent 36 seconds in `Set up job`. Moving the environment contracts to distinct larger pools removed that lottery. The next [all-larger-runner validation](https://github.com/deepseek-harness/deepseek-harness/actions/runs/29902541203) took 68 seconds on the 96-core primary job: repository work remained 26 seconds, but setup, cache, install, and finalization consumed 42 seconds. Moving typecheck, coverage, and the build-to-snapshot dependency chain to one coarse 64-core lane reduced the 96-core lane's repository critical path to 14.81 seconds without returning to per-gate shards. +A [documentation-head repeat](https://github.com/deepseek-harness/deepseek-harness/actions/runs/29903735616) showed that 16 coverage forks still admitted the CJS-lexer crash. The coverage process completed its remaining tests in 23.73 seconds, but the dead worker left one file below threshold and correctly failed the lane. Twelve forks completed the same gate in 26.06 seconds in the final run, keeping the CPU lane below one minute while restoring process headroom. + The workflow retains four manual diagnostics. `suite=larger-runner-benchmark` compares isolated critical lanes across every size, `suite=consolidated-runner-benchmark` compares whole aggregates, `suite=sharded-reference` preserves the former production shard topology, and `suite=serial-reference` remains the unsharded cross-platform completeness oracle. `suite=optimized-larger-runners` runs the exact production topology against a branch ref when a pull request cannot form a merge commit. The first [twelve-size critical-lane benchmark](https://github.com/deepseek-harness/deepseek-harness/actions/runs/29895295659) used a workflow-only commit on top of the standard-runner [baseline](https://github.com/deepseek-harness/deepseek-harness/actions/runs/29850033610), so the code, lockfile, and commands were identical: @@ -44,19 +46,19 @@ Those isolated results showed that setup dominated but did not identify the prod The Linux 32-core failure was the first CJS-lexer worker crash. The 96-core aggregate was the only successful all-size result at the one-minute boundary. Although Windows repository work gained little above 16 cores, the 32-core pool can start the complete outer inventory together and, more importantly, removes an entire paid setup from production. -The exact [all-pool validation run](https://github.com/deepseek-harness/deepseek-harness/actions/runs/29903067274) passed every job at the tested branch head: +The exact [all-pool validation run](https://github.com/deepseek-harness/deepseek-harness/actions/runs/29904080103) passed every job at the tested branch head: | Production job (pool) | Active time | Repository work | Result | |---|---:|---:|---:| -| Node 22.19 compatibility (Linux 4) | 26 s | compatibility smokes | passed | -| Python 3.10 (Linux 8) | 22 s | complete keyless SDK suite | passed | -| Production site (Linux 16) | 39 s | VitePress in 22.57 s | passed | -| Node 26 compatibility (Linux 32) | 24 s | compatibility smokes | passed | -| Primary CPU (Linux 64) | 53 s | 6 gates in 23.70 s | passed | -| Primary core (Linux 96) | 47 s | 36 gates in 14.81 s | passed | -| Windows complete (Windows 32) | 109 s | 37 gates in 32.71 s | passed | +| Node 22.19 compatibility (Linux 4) | 30 s | compatibility smokes | passed | +| Python 3.10 (Linux 8) | 31 s | complete keyless SDK suite | passed | +| Production site (Linux 16) | 51 s | VitePress in 24.23 s | passed | +| Node 26 compatibility (Linux 32) | 27 s | compatibility smokes | passed | +| Primary CPU (Linux 64) | 56 s | 6 gates in 26.07 s | passed | +| Primary core (Linux 96) | 49 s | 36 gates in 15.58 s | passed | +| Windows complete (Windows 32) | 102 s | 37 gates in 29.98 s | passed | -All seven paid jobs began in the same second. The slowest non-Windows job finished in 53 seconds. The Windows job spent 20 seconds restoring its pnpm cache and 15 seconds installing dependencies, so its 109-second active time measures hosted setup variance as well as repository work. Every non-Windows job stays below one minute and the sole Windows job stays below three minutes. +All seven paid jobs began within one second. The slowest non-Windows job finished in 56 seconds. The Windows job spent 21 seconds restoring its pnpm cache and 14 seconds installing dependencies, so its 102-second active time measures hosted setup variance as well as repository work. Every non-Windows job stays below one minute and the sole Windows job stays below three minutes. ## Alternatives considered diff --git a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.zh.md b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.zh.md index 04a32f9ac1..4e31937284 100644 --- a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.zh.md +++ b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.zh.md @@ -17,7 +17,7 @@ Status: implemented 生产 CI 对 6 种 Linux 池规格各使用一次,另使用一个 32 核 Windows 池,只有最终聚合作业继续使用标准运行器。版本和语言作业属于环境契约,并非从同一门禁清单切出的分片;主 Node 工作采用 3 个粗粒度通道,而非门禁级分片矩阵: - `node 24 / core` 使用 96 核 Linux 池。只需执行一次代码检出、设置、缓存恢复和安装,即可供 36 项未分片的静态、lint、文档、hygiene、构建和产物门禁使用。`run-gates` 最多同时启动 32 项相互独立的门禁,ESLint 使用 32 个工作线程。构建会尽早启动;其产物消费方仍会等待生成的输出。 -- `node 24 / cpu` 使用 64 核 Linux 池运行 6 项 CPU 或依赖关键门禁:类型检查、覆盖率、构建后回放快照,以及两项 Node 24 兼容性冒烟测试。覆盖率和快照各最多使用 16 个工作进程。该通道单独构建,使快照回放使用同一通道生成的输出。覆盖率运行的 fork 数保持低于 32,因为将其设为 32 曾两次导致 Node 24 的 CJS 词法分析器终止一个 Vitest 工作进程,使覆盖率结果失效。 +- `node 24 / cpu` 使用 64 核 Linux 池运行 6 项 CPU 或依赖关键门禁:类型检查、覆盖率、构建后回放快照,以及两项 Node 24 兼容性冒烟测试。覆盖率最多使用 12 个工作进程,快照最多使用 16 个。该通道单独构建,使快照回放使用同一通道生成的输出。覆盖率运行固定使用 12 个 fork,因为 32 个 fork 曾两次导致 Node 24 的 CJS 词法分析器崩溃,而后续一次使用 16 个 fork 的运行又复现了相同的工作进程丢失,并使覆盖率结果无效。 - `node 24 / production site` 使用 16 核 Linux 池运行耗时最长的独立主门禁。这是一项粗粒度拆分,而非分片矩阵:该作业执行一次设置和一次生产版 VitePress 构建。 - Node 22.19 兼容性、Python 3.10 和 Node 26 兼容性分别使用 4 核、8 核和 32 核 Linux 池。不同标签既避免了标准运行器设置耗时的异常值,也避免了两个作业共享一个池时曾出现的第二次分配延迟。 - `windows node 24 / complete` 使用 32 核 Windows 池。一次设置供必需的包构建、必需的生产网站构建以及完整的观测性可移植性清单共用。外层调度器设有 32 个槽位。任何必需项失败都会使作业失败;观测项失败会明确打印为非阻塞,并保留其原有的建议性状态。ESLint 本身仍采用单线程,因为启用 16 个 ESLint 工作线程会把完整 lint 的耗时增加至 174.54 秒;外层门禁并发能够利用该运行器,同时不会成倍增加 Windows 工作线程的启动和 TypeScript 项目加载开销。 @@ -26,6 +26,8 @@ Windows 结构由两项冷路径观测结果确定。首个候选方案使用两 随后两次运行确定了 Linux 的边界。一次[标准运行器验证](https://github.com/deepseek-harness/deepseek-harness/actions/runs/29902209492)中,Node 26 作业耗时 67 秒,而仓库工作仅耗时 5 秒,因为 GitHub 在 `Set up job` 上花费了 36 秒。把各项环境契约迁移到不同的大型运行器池后,这种随机波动随之消失。接下来的[全大型运行器验证](https://github.com/deepseek-harness/deepseek-harness/actions/runs/29902541203)中,96 核主作业耗时 68 秒:仓库工作仍为 26 秒,但设置、缓存、安装和收尾共占用 42 秒。把类型检查、覆盖率以及构建至快照的依赖链迁移到一个粗粒度 64 核通道后,96 核通道的仓库关键路径缩短至 14.81 秒,且无需恢复按门禁分片。 +一次[文档分支头重复验证](https://github.com/deepseek-harness/deepseek-harness/actions/runs/29903735616)表明,覆盖率使用 16 个 fork 时仍未能阻止 CJS 词法分析器崩溃。覆盖率进程在 23.73 秒内完成了余下测试,但已终止的工作进程让一个文件的覆盖率低于阈值,因此该通道按预期失败。最终运行使用 12 个 fork,在 26.06 秒内完成同一门禁,既让 CPU 通道保持在 1 分钟以内,也恢复了进程余量。 + 工作流保留 4 项手动诊断。`suite=larger-runner-benchmark` 比较所有规格下相互独立的关键通道,`suite=consolidated-runner-benchmark` 比较完整聚合流程,`suite=sharded-reference` 保留原生产分片拓扑,`suite=serial-reference` 则继续作为未分片的跨平台完整性判定基准。当拉取请求无法生成合并提交时,`suite=optimized-larger-runners` 会直接针对分支引用运行与生产环境完全相同的拓扑。 首次[涵盖 12 种规格的关键通道基准测试](https://github.com/deepseek-harness/deepseek-harness/actions/runs/29895295659)以标准运行器[基线](https://github.com/deepseek-harness/deepseek-harness/actions/runs/29850033610)为基础,只叠加了一个仅修改工作流的提交,因此代码、锁文件和命令完全相同: @@ -44,19 +46,19 @@ Windows 结构由两项冷路径观测结果确定。首个候选方案使用两 Linux 32 核作业的失败是首次发生的 CJS 词法分析器工作进程崩溃。在所有规格的结果中,96 核聚合作业是唯一成功达到 1 分钟边界的结果。尽管 Windows 仓库工作在超过 16 核后收益很小,32 核池可以让完整的外层清单同时启动;更重要的是,它能从生产环境中消除整整一次付费设置。 -精确的[全运行器池验证运行](https://github.com/deepseek-harness/deepseek-harness/actions/runs/29903067274)在受测分支头通过了所有作业: +精确的[全运行器池验证运行](https://github.com/deepseek-harness/deepseek-harness/actions/runs/29904080103)在受测分支头通过了所有作业: | 生产作业(运行器池) | 活动耗时 | 仓库工作 | 结果 | |---|---:|---:|---:| -| Node 22.19 兼容性(Linux 4) | 26 秒 | 兼容性冒烟测试 | 通过 | -| Python 3.10(Linux 8) | 22 秒 | 完整无密钥 SDK 套件 | 通过 | -| 生产网站(Linux 16) | 39 秒 | VitePress 耗时 22.57 秒 | 通过 | -| Node 26 兼容性(Linux 32) | 24 秒 | 兼容性冒烟测试 | 通过 | -| 主流程 CPU(Linux 64) | 53 秒 | 6 项门禁耗时 23.70 秒 | 通过 | -| 主流程核心(Linux 96) | 47 秒 | 36 项门禁耗时 14.81 秒 | 通过 | -| Windows 完整流程(Windows 32) | 109 秒 | 37 项门禁耗时 32.71 秒 | 通过 | +| Node 22.19 兼容性(Linux 4) | 30 秒 | 兼容性冒烟测试 | 通过 | +| Python 3.10(Linux 8) | 31 秒 | 完整无密钥 SDK 套件 | 通过 | +| 生产网站(Linux 16) | 51 秒 | VitePress 耗时 24.23 秒 | 通过 | +| Node 26 兼容性(Linux 32) | 27 秒 | 兼容性冒烟测试 | 通过 | +| 主流程 CPU(Linux 64) | 56 秒 | 6 项门禁耗时 26.07 秒 | 通过 | +| 主流程核心(Linux 96) | 49 秒 | 36 项门禁耗时 15.58 秒 | 通过 | +| Windows 完整流程(Windows 32) | 102 秒 | 37 项门禁耗时 29.98 秒 | 通过 | -7 个付费作业均在同一秒开始运行。最慢的非 Windows 作业耗时 53 秒。Windows 作业花费 20 秒恢复 pnpm 缓存、15 秒安装依赖,因此其 109 秒的活动耗时既反映托管环境的设置波动,也反映仓库工作耗时。每个非 Windows 作业均低于 1 分钟,唯一的 Windows 作业也低于 3 分钟。 +7 个付费作业均在 1 秒内开始运行。最慢的非 Windows 作业耗时 56 秒。Windows 作业花费 21 秒恢复 pnpm 缓存、14 秒安装依赖,因此其 102 秒的活动耗时既反映托管环境的设置波动,也反映仓库工作耗时。每个非 Windows 作业均低于 1 分钟,唯一的 Windows 作业也低于 3 分钟。 ## 曾考虑的替代方案 From d7e0104a72e937757f1082a44bf240969c4e41eb Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Wed, 22 Jul 2026 16:45:59 +0800 Subject: [PATCH 72/90] ci: unpack bubblewrap without package transaction --- .github/workflows/ci.yml | 61 +++++++------------------------- scripts/prepare-ci-bubblewrap.sh | 30 ++++++++++++++++ 2 files changed, 43 insertions(+), 48 deletions(-) create mode 100755 scripts/prepare-ci-bubblewrap.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 80a158210e..9aa20e929b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -277,23 +277,16 @@ jobs: # re-executing their bash calls under a real runner. ubuntu-latest has # no bubblewrap preinstalled and no built Landlock launcher, so without # this the confined executions fail closed (SANDBOX_UNAVAILABLE). The - # install retries after refreshing stale indexes and applies the Ubuntu - # 24.04 AppArmor userns knob. Bubblewrap preparation is independent of - # dependency installation and the build, so it runs beside both. - - name: Install and prepare built snapshot runtime and bubblewrap + # pinned Ubuntu payload is verified and extracted into the ephemeral + # runner instead of paying for a system package transaction. Bubblewrap + # preparation is independent of dependency installation and the build, + # so it runs beside both. + - name: Prepare built snapshot runtime and bubblewrap if: startsWith(matrix.lane, 'snapshot-') run: | pnpm install --frozen-lockfile & install_pid=$! - ( - if ! sudo apt-get install -yq --no-install-recommends bubblewrap; then - echo "initial bubblewrap install failed; refreshing APT indexes and retrying" - sudo apt-get update -q - sudo apt-get install -yq --no-install-recommends bubblewrap - fi - sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 \ - || echo "apparmor userns knob absent — the functional probe decides" - ) & + bash scripts/prepare-ci-bubblewrap.sh & sandbox_pid=$! install_status=0 wait "$install_pid" || install_status=$? @@ -356,19 +349,11 @@ jobs: restore-keys: | ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-eslint-full- - - name: Install and prepare bubblewrap + - name: Install dependencies and prepare bubblewrap run: | pnpm install --frozen-lockfile & install_pid=$! - ( - if ! sudo apt-get install -yq --no-install-recommends bubblewrap; then - echo "initial bubblewrap install failed; refreshing APT indexes and retrying" - sudo apt-get update -q - sudo apt-get install -yq --no-install-recommends bubblewrap - fi - sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 \ - || echo "apparmor userns knob absent — the functional probe decides" - ) & + bash scripts/prepare-ci-bubblewrap.sh & sandbox_pid=$! install_status=0 wait "$install_pid" || install_status=$? @@ -473,20 +458,12 @@ jobs: if: matrix.primary_cpu != true run: pnpm install --frozen-lockfile - - name: Install and prepare bubblewrap + - name: Install dependencies and prepare bubblewrap if: matrix.primary_cpu == true run: | pnpm install --frozen-lockfile & install_pid=$! - ( - if ! sudo apt-get install -yq --no-install-recommends bubblewrap; then - echo "initial bubblewrap install failed; refreshing APT indexes and retrying" - sudo apt-get update -q - sudo apt-get install -yq --no-install-recommends bubblewrap - fi - sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 \ - || echo "apparmor userns knob absent — the functional probe decides" - ) & + bash scripts/prepare-ci-bubblewrap.sh & sandbox_pid=$! install_status=0 wait "$install_pid" || install_status=$? @@ -731,12 +708,8 @@ jobs: - name: Install (immutable) run: pnpm install --frozen-lockfile - - name: Install bubblewrap (unrestrict userns) - run: | - sudo apt-get update -q - sudo apt-get install -yq --no-install-recommends bubblewrap - sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 \ - || echo "apparmor userns knob absent — the functional probe decides" + - name: Prepare bubblewrap (unrestrict userns) + run: bash scripts/prepare-ci-bubblewrap.sh - name: Run complete unsharded primary Node CI serially env: @@ -1016,15 +989,7 @@ jobs: run: | pnpm install --frozen-lockfile & install_pid=$! - ( - if ! sudo apt-get install -yq --no-install-recommends bubblewrap; then - echo "initial bubblewrap install failed; refreshing APT indexes and retrying" - sudo apt-get update -q - sudo apt-get install -yq --no-install-recommends bubblewrap - fi - sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 \ - || echo "apparmor userns knob absent — the functional probe decides" - ) & + bash scripts/prepare-ci-bubblewrap.sh & sandbox_pid=$! install_status=0 wait "$install_pid" || install_status=$? diff --git a/scripts/prepare-ci-bubblewrap.sh b/scripts/prepare-ci-bubblewrap.sh new file mode 100755 index 0000000000..e42ee8cd2c --- /dev/null +++ b/scripts/prepare-ci-bubblewrap.sh @@ -0,0 +1,30 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Ubuntu's package transaction scans the hosted image's full dpkg database and +# runs post-install hooks. CI needs only the signed-archive payload, so pin and +# verify that payload before extracting it into the ephemeral runner directory. +readonly BUBBLEWRAP_VERSION='0.9.0-1ubuntu0.1' +readonly BUBBLEWRAP_SHA256='1b506492bd9c7fd0cdb4f02ac822f1d3e336b0aead5113c1239baf8db5db562a' +readonly BUBBLEWRAP_URL="https://archive.ubuntu.com/ubuntu/pool/main/b/bubblewrap/bubblewrap_${BUBBLEWRAP_VERSION}_amd64.deb" + +: "${RUNNER_TEMP:?prepare-ci-bubblewrap requires RUNNER_TEMP}" +: "${GITHUB_PATH:?prepare-ci-bubblewrap requires GITHUB_PATH}" + +if [[ "$(uname -s)" != 'Linux' || "$(uname -m)" != 'x86_64' ]]; then + echo 'prepare-ci-bubblewrap supports only Linux x86_64 hosted runners' >&2 + exit 1 +fi + +archive="${RUNNER_TEMP}/bubblewrap_${BUBBLEWRAP_VERSION}_amd64.deb" +root="${RUNNER_TEMP}/dsh-bubblewrap" + +curl --fail --silent --show-error --location --retry 3 --output "$archive" "$BUBBLEWRAP_URL" +printf '%s %s\n' "$BUBBLEWRAP_SHA256" "$archive" | sha256sum --check --status +mkdir -p "$root" +dpkg-deb --extract "$archive" "$root" +printf '%s\n' "$root/usr/bin" >> "$GITHUB_PATH" + +sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 \ + || echo 'apparmor userns knob absent — the functional probe decides' +"$root/usr/bin/bwrap" --version From 7b46e1f73c481dfa9e8f39ad3f133bf08a2d4774 Mon Sep 17 00:00:00 2001 From: Turtle <turtle1999@deepseek.com> Date: Wed, 22 Jul 2026 16:49:50 +0800 Subject: [PATCH 73/90] fix: address PR #504 review warnings - workspace-context: a transiently unavailable but still-effective candidate keeps its cached trimmed digest in the directory's dedup slot, so an identical later sibling is not emitted as a duplicate set until the next successful reconciliation - app-boot: --resume rejects a following token that is itself resume syntax instead of accepting it as a session id - tui: the queued-steering badge tracks per-entry sources and a drain removes one matching entry, so loop-authored steering (no agent/queued) cannot consume a pending user message's slot --- .../context/workspace-context/src/state.ts | 12 +++++- .../tests/workspace-context.spec.ts | 41 +++++++++++++++++++ packages/ui/app-boot/src/index.ts | 4 +- packages/ui/app-boot/tests/app-boot.spec.ts | 5 +++ packages/ui/tui/src/index.ts | 41 +++++++++++-------- packages/ui/tui/tests/tui.spec.ts | 17 +++++++- 6 files changed, 101 insertions(+), 19 deletions(-) diff --git a/packages/context/workspace-context/src/state.ts b/packages/context/workspace-context/src/state.ts index b2f1fa88ef..66b70f639d 100644 --- a/packages/context/workspace-context/src/state.ts +++ b/packages/context/workspace-context/src/state.ts @@ -448,7 +448,17 @@ export async function reconcileInstructionContext( const { directory } = decodeScopeKey(scope) const previous = effective.get(scope) const probe = await probeScopeInstruction(scope, projectRoot, resolved, fileSystem, options.signal) - if (probe.kind === 'unavailable') continue + if (probe.kind === 'unavailable') { + // Last-good-state: the candidate stays effective, so its cached trimmed + // digest must keep occupying the directory's dedup slot — otherwise an + // identical later sibling would be emitted as a duplicate `set` until the + // next successful reconciliation removed it again. + const cached = versions.get(scope) + if (cached !== undefined && previous !== undefined && previous.action !== 'remove') { + registerKeptTrimmed(directory, cached.trimmedDigest) + } + continue + } if (probe.kind === 'absent') { if (previous === undefined || previous.action === 'remove') versions.delete(scope) else pushRemoval(scope, previous.path) diff --git a/packages/context/workspace-context/tests/workspace-context.spec.ts b/packages/context/workspace-context/tests/workspace-context.spec.ts index 1565b50db5..c26be1a58e 100644 --- a/packages/context/workspace-context/tests/workspace-context.spec.ts +++ b/packages/context/workspace-context/tests/workspace-context.spec.ts @@ -2201,6 +2201,47 @@ describe('dynamic nested workspace context injection', () => { } }) + it('keeps deduplicating against a loaded candidate whose probe transiently fails', async () => { + const root = await tempRepo() + const home = await tempRepo() + const ctx = new Context() + try { + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + await ctx.plugin(RecordingFileSystem) + const fs = ctx.fs as RecordingFileSystem + fs.entries.set(join(root, '.git'), { type: 'directory' }) + fs.entries.set(join(root, 'pkg/AGENTS.md'), { type: 'file', content: 'nested rule' }) + fs.entries.set(join(root, 'pkg/file.txt'), { type: 'file', content: 'hello' }) + await ctx.plugin(ToolFs) + await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 }) + const agent = stubAgent(root) + + const first = await ctx.tools.execute({ + signal: testToolSignal, + callId: CallId('read-before-transient-probe-failure'), name: 'read', arguments: { file_path: join('pkg', 'file.txt') }, agent, + }) + appendAdditionalContexts(agent, first) + expect(first.additionalContexts).toBeDefined() + + // The loaded candidate's probe fails while an identical sibling appears: + // the cached candidate stays effective (last good state), so the sibling + // must still deduplicate against it rather than land as a duplicate set. + fs.throwOnStat.add(join(root, 'pkg/AGENTS.md')) + fs.entries.set(join(root, 'pkg/CLAUDE.md'), { type: 'file', content: 'nested rule' }) + const duringFailure = await ctx.tools.execute({ + signal: testToolSignal, + callId: CallId('read-during-transient-probe-failure'), name: 'read', arguments: { file_path: join('pkg', 'file.txt') }, agent, + }) + + expect(duringFailure.additionalContexts).toBeUndefined() + } finally { + await ctx.fiber.dispose() + await rm(root, { recursive: true, force: true }) + await rm(home, { recursive: true, force: true }) + } + }) + it('removes a previously rendered sibling once its content becomes a duplicate of an earlier candidate', async () => { const root = await tempRepo() const home = await tempRepo() diff --git a/packages/ui/app-boot/src/index.ts b/packages/ui/app-boot/src/index.ts index c150b4a1c1..2fd4ba4c05 100644 --- a/packages/ui/app-boot/src/index.ts +++ b/packages/ui/app-boot/src/index.ts @@ -66,7 +66,9 @@ export function parseResumeArg( if (arg === RESUME_FLAG || inlineValue) { if (resumeSessionId !== undefined) throw new Error(`${RESUME_FLAG} may be given only once`) const value = inlineValue ? arg.slice(RESUME_FLAG.length + 1) : argv[i + 1] - if (value === undefined || value === '') { + // A following token that is itself resume syntax (`--resume --resume x`) + // is a missing id, not a session literally named `--resume…`. + if (value === undefined || value === '' || value === RESUME_FLAG || value.startsWith(`${RESUME_FLAG}=`)) { throw new Error(`${RESUME_FLAG} requires a session id (e.g. ${RESUME_FLAG} <session-id>)`) } resumeSessionId = value diff --git a/packages/ui/app-boot/tests/app-boot.spec.ts b/packages/ui/app-boot/tests/app-boot.spec.ts index fef17b5b07..76e5238db7 100644 --- a/packages/ui/app-boot/tests/app-boot.spec.ts +++ b/packages/ui/app-boot/tests/app-boot.spec.ts @@ -48,6 +48,11 @@ describe('parseResumeArg', () => { expect(() => parseResumeArg(['--resume='])).toThrow('--resume requires a session id') expect(() => parseResumeArg(['--resume', 'a', '--resume', 'b'])).toThrow('--resume may be given only once') }) + + it('rejects resume syntax used as the flag value instead of resuming a session named like the flag', () => { + expect(() => parseResumeArg(['--resume', '--resume', 'sess'])).toThrow('--resume requires a session id') + expect(() => parseResumeArg(['--resume', '--resume=sess'])).toThrow('--resume requires a session id') + }) }) describe('loadEnv', () => { diff --git a/packages/ui/tui/src/index.ts b/packages/ui/tui/src/index.ts index 77857c0d71..99e0ec2727 100644 --- a/packages/ui/tui/src/index.ts +++ b/packages/ui/tui/src/index.ts @@ -1307,12 +1307,15 @@ export function createTuiChat( let toolsExpanded = false let streaming: StreamingAssistantComponent | undefined let runningStatus: RunningStatus | undefined - // Steering messages the user queued during the running turn that the loop has - // not yet drained, shown as a badge on the status line. Reset on the - // running→idle status transition, which also absorbs a cancellation that - // clears the queue without logging drains; the status line exists only while - // running, so idle carries no badge to keep current. - let pendingSteering = 0 + // Steering messages queued during the running turn (`agent/queued`) that the + // loop has not yet drained, shown as a badge on the status line. Each entry is + // the queued message's serialized source: a drain (`steering/message`) removes + // one MATCHING entry, so loop-authored steering — continuation reasons enter + // the inbox without an `agent/queued` event — cannot consume a pending user + // message's slot. Cleared on leaving `running`, which also absorbs a + // cancellation that discards the queue without logging drains; the status + // line exists only while running, so idle carries no badge to keep current. + const pendingSteering: string[] = [] let disposed = false let shuttingDown: Promise<void> | undefined // Optional: skills mount conditionally, so read the global service store @@ -1509,7 +1512,9 @@ export function createTuiChat( // controller's phase and the current steering count. const renderStatus = (running: RunningStatus): void => { const at = now() - running.loader.setMessage(formatTurnStatus(running.phase, at - running.phaseStartedAt, at - running.stepStartedAt, pendingSteering)) + running.loader.setMessage( + formatTurnStatus(running.phase, at - running.phaseStartedAt, at - running.stepStartedAt, pendingSteering.length), + ) } // Move to a derived phase, resetting the phase timer on a genuine change and @@ -1536,7 +1541,7 @@ export function createTuiChat( const phase = prior?.phase ?? 'waiting' const phaseStartedAt = prior?.phaseStartedAt ?? at const stepStartedAt = prior?.stepStartedAt ?? at - const message = formatTurnStatus(phase, at - phaseStartedAt, at - stepStartedAt, pendingSteering) + const message = formatTurnStatus(phase, at - phaseStartedAt, at - stepStartedAt, pendingSteering.length) const loader = new Loader(ui, text => palette.accent(text), text => palette.muted(text), message) statusContainer.addChild(loader) const running: RunningStatus = { @@ -2240,12 +2245,16 @@ export function createTuiChat( if (session !== agent.session) return recordEventUsage(tokens, event) advanceTurnPhase(event) - if (event.type === 'steering/message' && pendingSteering > 0) { - // A queued steering message reached the model as it drained; drop it from - // the badge. Clamped because loop-authored steering (e.g. continuation - // reasons) also logs here without a matching user-queued increment. - pendingSteering -= 1 - refreshStatus() + if (event.type === 'steering/message') { + // A queued steering message reached the model as it drained; drop its + // entry from the badge. Matching by source keeps loop-authored steering + // (e.g. continuation reasons), which logs here without a matching + // `agent/queued` increment, from consuming a pending user slot. + const drained = pendingSteering.indexOf(JSON.stringify(event.data.source)) + if (drained >= 0) { + pendingSteering.splice(drained, 1) + refreshStatus() + } } if ('surfaceOp' in event && typeof event.surfaceOp === 'object') { rebuildTranscript(false) @@ -2256,7 +2265,7 @@ export function createTuiChat( }) const disposeQueued = ctx.on('agent/queued', (subject, _content, info) => { if (subject !== agent || !info.steering) return - pendingSteering += 1 + pendingSteering.push(JSON.stringify(info.source)) refreshStatus() }) const disposeStatus = ctx.on('agent/status', (subject, status) => { @@ -2264,7 +2273,7 @@ export function createTuiChat( // Leaving 'running' ends the turn's status line; clear any badge so the // next running turn starts from zero (and a cancellation, which discards // the queue without logging drains, cannot strand a stale count). - if (status !== 'running') pendingSteering = 0 + if (status !== 'running') pendingSteering.length = 0 setStatus(status) }) const disposeError = ctx.on('agent/error', (subject, turn, step, error) => { diff --git a/packages/ui/tui/tests/tui.spec.ts b/packages/ui/tui/tests/tui.spec.ts index 10be7f99bf..a718d08eeb 100644 --- a/packages/ui/tui/tests/tui.spec.ts +++ b/packages/ui/tui/tests/tui.spec.ts @@ -584,13 +584,28 @@ describe('pi-tui chat lifecycle and transcript', () => { expect(result.terminal.output).toContain('— Enter sends steering, Esc cancels') expect(result.terminal.output).not.toContain('queued') - // A loop-authored steering drain past zero clamps rather than underflowing. + // A drain with no matching queued entry is ignored rather than underflowing. result.terminal.output = '' drainSteering('continuation') queueSteering('after') await tick() expect(result.terminal.output).toContain('1 queued') + // A loop-authored steering event (plugin source, no matching agent/queued) + // cannot consume a pending user slot, even when it drains first. + result.terminal.output = '' + result.session.append('steering/message', { + turn: 1, + content: [{ type: 'text', text: 'continue: goal not reached' }], + source: { kind: 'plugin', plugin: 'hooks' }, + }, { surfaceOp: 'append' }) + await tick() + expect(result.terminal.output).toContain('1 queued') + result.terminal.output = '' + drainSteering('after') + await tick() + expect(result.terminal.output).not.toContain('queued') + // The turn ending resets the badge, so the next running turn starts clean. result.agent.status = 'idle' result.ctx.emit('agent/status', result.agent, 'idle') From 11236c46fbe9a1b93cdafd01ff4372a941dea349 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Wed, 22 Jul 2026 16:55:39 +0800 Subject: [PATCH 74/90] docs: record final larger-runner evidence --- ...ence-based-larger-hosted-runners.i18n.yaml | 4 ++-- ...22-evidence-based-larger-hosted-runners.md | 22 ++++++++++--------- ...evidence-based-larger-hosted-runners.zh.md | 22 ++++++++++--------- scripts/prepare-ci-bubblewrap.sh | 2 ++ 4 files changed, 28 insertions(+), 22 deletions(-) diff --git a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.i18n.yaml b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.i18n.yaml index 509cff58e2..c036f6996e 100644 --- a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.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 -2026-07-22-evidence-based-larger-hosted-runners.md: 81de62fa15623b96ac4913ee6c6fc15e886222b0 -2026-07-22-evidence-based-larger-hosted-runners.zh.md: 4e31937284b221d92552957ecc0434d9fc2e2914 +2026-07-22-evidence-based-larger-hosted-runners.md: 30a8b0517690d70ad90f8042a3f1ba841cace76e +2026-07-22-evidence-based-larger-hosted-runners.zh.md: 1335fc66bcbf24cdda3508210dd92786b52b9fff diff --git a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.md b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.md index 81de62fa15..30a8b05176 100644 --- a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.md +++ b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.md @@ -28,6 +28,8 @@ Two later runs set the Linux boundaries. A [standard-runner validation](https:// A [documentation-head repeat](https://github.com/deepseek-harness/deepseek-harness/actions/runs/29903735616) showed that 16 coverage forks still admitted the CJS-lexer crash. The coverage process completed its remaining tests in 23.73 seconds, but the dead worker left one file below threshold and correctly failed the lane. Twelve forks completed the same gate in 26.06 seconds in the final run, keeping the CPU lane below one minute while restoring process headroom. +One later exact-head run exposed a host-image tax rather than a repository bottleneck: the CPU lane completed its six gates in 28 seconds but took 66 seconds overall because registering the 50 KB Bubblewrap package scanned the runner's 202,507-file package database and consumed 18 seconds. `scripts/prepare-ci-bubblewrap.sh` instead downloads the pinned Ubuntu 24.04 package payload, verifies its archive checksum, extracts it into the ephemeral runner directory, and runs the same functional confinement probe used by the provider. Dependency installation and this sub-second preparation still overlap. This preserves the real Bubblewrap coverage without mutating the hosted image or adding another shard. + The workflow retains four manual diagnostics. `suite=larger-runner-benchmark` compares isolated critical lanes across every size, `suite=consolidated-runner-benchmark` compares whole aggregates, `suite=sharded-reference` preserves the former production shard topology, and `suite=serial-reference` remains the unsharded cross-platform completeness oracle. `suite=optimized-larger-runners` runs the exact production topology against a branch ref when a pull request cannot form a merge commit. The first [twelve-size critical-lane benchmark](https://github.com/deepseek-harness/deepseek-harness/actions/runs/29895295659) used a workflow-only commit on top of the standard-runner [baseline](https://github.com/deepseek-harness/deepseek-harness/actions/runs/29850033610), so the code, lockfile, and commands were identical: @@ -46,19 +48,19 @@ Those isolated results showed that setup dominated but did not identify the prod The Linux 32-core failure was the first CJS-lexer worker crash. The 96-core aggregate was the only successful all-size result at the one-minute boundary. Although Windows repository work gained little above 16 cores, the 32-core pool can start the complete outer inventory together and, more importantly, removes an entire paid setup from production. -The exact [all-pool validation run](https://github.com/deepseek-harness/deepseek-harness/actions/runs/29904080103) passed every job at the tested branch head: +The exact [all-pool validation run](https://github.com/deepseek-harness/deepseek-harness/actions/runs/29905362252) passed every job at the tested branch head: | Production job (pool) | Active time | Repository work | Result | |---|---:|---:|---:| -| Node 22.19 compatibility (Linux 4) | 30 s | compatibility smokes | passed | -| Python 3.10 (Linux 8) | 31 s | complete keyless SDK suite | passed | -| Production site (Linux 16) | 51 s | VitePress in 24.23 s | passed | -| Node 26 compatibility (Linux 32) | 27 s | compatibility smokes | passed | -| Primary CPU (Linux 64) | 56 s | 6 gates in 26.07 s | passed | -| Primary core (Linux 96) | 49 s | 36 gates in 15.58 s | passed | -| Windows complete (Windows 32) | 102 s | 37 gates in 29.98 s | passed | +| Node 22.19 compatibility (Linux 4) | 26 s | compatibility smokes | passed | +| Python 3.10 (Linux 8) | 23 s | complete keyless SDK suite | passed | +| Production site (Linux 16) | 48 s | VitePress in 25 s | passed | +| Node 26 compatibility (Linux 32) | 28 s | compatibility smokes | passed | +| Primary CPU (Linux 64) | 46 s | 6 gates in 25.74 s | passed | +| Primary core (Linux 96) | 42 s | 36 gates in 15.13 s | passed | +| Windows complete (Windows 32) | 137 s | 37 gates in 37.74 s | passed | -All seven paid jobs began within one second. The slowest non-Windows job finished in 56 seconds. The Windows job spent 21 seconds restoring its pnpm cache and 14 seconds installing dependencies, so its 102-second active time measures hosted setup variance as well as repository work. Every non-Windows job stays below one minute and the sole Windows job stays below three minutes. +All seven paid jobs began within one second. The slowest non-Windows job finished in 48 seconds. The Windows job spent 25 seconds checking out, 22 seconds enabling Developer Mode, 19 seconds restoring its pnpm cache, and 16 seconds installing dependencies, so its 137-second active time measures hosted setup variance as well as repository work. Every non-Windows job stays below one minute and the sole Windows job stays below three minutes. ## Alternatives considered @@ -80,7 +82,7 @@ All seven paid jobs began within one second. The slowest non-Windows job finishe ## Consequences -The all-pool validation consumed one billed minute at each Linux size and two billed 32-core Windows minutes. At the configured larger-runner rates, its larger-runner cost was $0.896. The all-size critical benchmark cost $2.936. GitHub rounds each larger-runner job up to a whole minute, so reducing paid job count from 49 to seven matters as much as shortening repository work. +The all-pool validation consumed one billed minute at each Linux size and three billed 32-core Windows minutes. At the configured larger-runner rates, its larger-runner cost was $1.058. A Windows run below 120 seconds costs $0.896 instead; both shapes remain inside the three-minute target. The all-size critical benchmark cost $2.936. GitHub rounds each larger-runner job up to a whole minute, so reducing paid job count from 49 to seven matters as much as shortening repository work. The existing zero-dollar Actions budget did not block larger-runner jobs. The repo-only runner group, bounded workflow topology, manual benchmark triggers, and job timeouts are the observed cost controls; the budget is not treated as an execution guard. diff --git a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.zh.md b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.zh.md index 4e31937284..1335fc66bc 100644 --- a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.zh.md +++ b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.zh.md @@ -28,6 +28,8 @@ Windows 结构由两项冷路径观测结果确定。首个候选方案使用两 一次[文档分支头重复验证](https://github.com/deepseek-harness/deepseek-harness/actions/runs/29903735616)表明,覆盖率使用 16 个 fork 时仍未能阻止 CJS 词法分析器崩溃。覆盖率进程在 23.73 秒内完成了余下测试,但已终止的工作进程让一个文件的覆盖率低于阈值,因此该通道按预期失败。最终运行使用 12 个 fork,在 26.06 秒内完成同一门禁,既让 CPU 通道保持在 1 分钟以内,也恢复了进程余量。 +后来一次分支头精确运行暴露的是主机映像的额外开销,而非仓库瓶颈:CPU 通道在 28 秒内完成了 6 项门禁,总耗时却达到 66 秒,因为注册 50 KB 的 Bubblewrap 包时扫描了运行器中记录 202,507 个文件的包数据库,耗时 18 秒。`scripts/prepare-ci-bubblewrap.sh` 改为下载固定版本的 Ubuntu 24.04 包内容,验证归档文件的校验和,将其解压到临时运行器目录,并执行与提供方相同的功能性隔离探针。依赖安装仍与这项不足 1 秒的准备工作重叠执行。这样既保留了对真实 Bubblewrap 的覆盖率,又无需修改托管映像或增加分片。 + 工作流保留 4 项手动诊断。`suite=larger-runner-benchmark` 比较所有规格下相互独立的关键通道,`suite=consolidated-runner-benchmark` 比较完整聚合流程,`suite=sharded-reference` 保留原生产分片拓扑,`suite=serial-reference` 则继续作为未分片的跨平台完整性判定基准。当拉取请求无法生成合并提交时,`suite=optimized-larger-runners` 会直接针对分支引用运行与生产环境完全相同的拓扑。 首次[涵盖 12 种规格的关键通道基准测试](https://github.com/deepseek-harness/deepseek-harness/actions/runs/29895295659)以标准运行器[基线](https://github.com/deepseek-harness/deepseek-harness/actions/runs/29850033610)为基础,只叠加了一个仅修改工作流的提交,因此代码、锁文件和命令完全相同: @@ -46,19 +48,19 @@ Windows 结构由两项冷路径观测结果确定。首个候选方案使用两 Linux 32 核作业的失败是首次发生的 CJS 词法分析器工作进程崩溃。在所有规格的结果中,96 核聚合作业是唯一成功达到 1 分钟边界的结果。尽管 Windows 仓库工作在超过 16 核后收益很小,32 核池可以让完整的外层清单同时启动;更重要的是,它能从生产环境中消除整整一次付费设置。 -精确的[全运行器池验证运行](https://github.com/deepseek-harness/deepseek-harness/actions/runs/29904080103)在受测分支头通过了所有作业: +精确的[全运行器池验证运行](https://github.com/deepseek-harness/deepseek-harness/actions/runs/29905362252)在受测分支头通过了所有作业: | 生产作业(运行器池) | 活动耗时 | 仓库工作 | 结果 | |---|---:|---:|---:| -| Node 22.19 兼容性(Linux 4) | 30 秒 | 兼容性冒烟测试 | 通过 | -| Python 3.10(Linux 8) | 31 秒 | 完整无密钥 SDK 套件 | 通过 | -| 生产网站(Linux 16) | 51 秒 | VitePress 耗时 24.23 秒 | 通过 | -| Node 26 兼容性(Linux 32) | 27 秒 | 兼容性冒烟测试 | 通过 | -| 主流程 CPU(Linux 64) | 56 秒 | 6 项门禁耗时 26.07 秒 | 通过 | -| 主流程核心(Linux 96) | 49 秒 | 36 项门禁耗时 15.58 秒 | 通过 | -| Windows 完整流程(Windows 32) | 102 秒 | 37 项门禁耗时 29.98 秒 | 通过 | +| Node 22.19 兼容性(Linux 4) | 26 秒 | 兼容性冒烟测试 | 通过 | +| Python 3.10(Linux 8) | 23 秒 | 完整无密钥 SDK 套件 | 通过 | +| 生产网站(Linux 16) | 48 秒 | VitePress 耗时 25 秒 | 通过 | +| Node 26 兼容性(Linux 32) | 28 秒 | 兼容性冒烟测试 | 通过 | +| 主流程 CPU(Linux 64) | 46 秒 | 6 项门禁耗时 25.74 秒 | 通过 | +| 主流程核心(Linux 96) | 42 秒 | 36 项门禁耗时 15.13 秒 | 通过 | +| Windows 完整流程(Windows 32) | 137 秒 | 37 项门禁耗时 37.74 秒 | 通过 | -7 个付费作业均在 1 秒内开始运行。最慢的非 Windows 作业耗时 56 秒。Windows 作业花费 21 秒恢复 pnpm 缓存、14 秒安装依赖,因此其 102 秒的活动耗时既反映托管环境的设置波动,也反映仓库工作耗时。每个非 Windows 作业均低于 1 分钟,唯一的 Windows 作业也低于 3 分钟。 +7 个付费作业均在 1 秒内开始运行。最慢的非 Windows 作业耗时 48 秒。Windows 作业花费 25 秒检出代码、22 秒启用开发人员模式、19 秒恢复 pnpm 缓存、16 秒安装依赖,因此其 137 秒的活动耗时既反映托管环境的设置波动,也反映仓库工作耗时。每个非 Windows 作业均低于 1 分钟,唯一的 Windows 作业也低于 3 分钟。 ## 曾考虑的替代方案 @@ -80,7 +82,7 @@ Linux 32 核作业的失败是首次发生的 CJS 词法分析器工作进程崩 ## 后果 -全运行器池验证中,每种 Linux 规格各计费 1 分钟,32 核 Windows 计费 2 分钟。按已配置的大型运行器费率计算,其大型运行器成本为 $0.896。全规格关键通道基准测试的成本为 $2.936。GitHub 会把每个大型运行器作业向上取整到整分钟计费,因此把付费作业数从 49 个减少到 7 个,与缩短仓库工作耗时同样重要。 +全运行器池验证中,每种 Linux 规格各计费 1 分钟,32 核 Windows 计费 3 分钟。按已配置的大型运行器费率计算,其大型运行器成本为 $1.058;如果 Windows 运行低于 120 秒,成本则为 $0.896。两种运行形态都保持在 3 分钟目标以内。全规格关键通道基准测试的成本为 $2.936。GitHub 会把每个大型运行器作业向上取整到整分钟计费,因此把付费作业数从 49 个减少到 7 个,与缩短仓库工作耗时同样重要。 现有的零美元 Actions 预算并未阻止大型运行器作业。仅限本仓库的运行器组、有界的工作流拓扑、手动基准测试触发和作业超时限制才是经实测有效的成本控制机制;该预算不被视为执行防护措施。 diff --git a/scripts/prepare-ci-bubblewrap.sh b/scripts/prepare-ci-bubblewrap.sh index e42ee8cd2c..00a513db8f 100755 --- a/scripts/prepare-ci-bubblewrap.sh +++ b/scripts/prepare-ci-bubblewrap.sh @@ -28,3 +28,5 @@ printf '%s\n' "$root/usr/bin" >> "$GITHUB_PATH" sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 \ || echo 'apparmor userns knob absent — the functional probe decides' "$root/usr/bin/bwrap" --version +"$root/usr/bin/bwrap" --ro-bind / / --dev /dev --proc /proc --die-with-parent -- true +echo 'bubblewrap functional probe passed' From b72410b1521c7390621457875fd172e222ab2273 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Wed, 22 Jul 2026 17:30:45 +0800 Subject: [PATCH 75/90] chore: extract vendored Cordis hot-reload fix --- ...-20-config-hot-reload-resilience.i18n.yaml | 6 - ...2026-07-20-config-hot-reload-resilience.md | 38 ------ ...6-07-20-config-hot-reload-resilience.zh.md | 38 ------ .../ui/app-boot/tests/config-reload.spec.ts | 128 ------------------ vendor/README.md | 1 - vendor/include/src/index.ts | 63 ++------- 6 files changed, 14 insertions(+), 260 deletions(-) delete mode 100644 .agents/notes/implemented/bug-fix/2026-07-20-config-hot-reload-resilience.i18n.yaml delete mode 100644 .agents/notes/implemented/bug-fix/2026-07-20-config-hot-reload-resilience.md delete mode 100644 .agents/notes/implemented/bug-fix/2026-07-20-config-hot-reload-resilience.zh.md delete mode 100644 packages/ui/app-boot/tests/config-reload.spec.ts diff --git a/.agents/notes/implemented/bug-fix/2026-07-20-config-hot-reload-resilience.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-20-config-hot-reload-resilience.i18n.yaml deleted file mode 100644 index b16ef70d7c..0000000000 --- a/.agents/notes/implemented/bug-fix/2026-07-20-config-hot-reload-resilience.i18n.yaml +++ /dev/null @@ -1,6 +0,0 @@ -# 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 -2026-07-20-config-hot-reload-resilience.md: 1a8e29c603ede50b60199e9151fca58dadcc3d40 -2026-07-20-config-hot-reload-resilience.zh.md: 6c7a421bfa84504a36d5329e13a485bf72cc6b6c diff --git a/.agents/notes/implemented/bug-fix/2026-07-20-config-hot-reload-resilience.md b/.agents/notes/implemented/bug-fix/2026-07-20-config-hot-reload-resilience.md deleted file mode 100644 index 1a8e29c603..0000000000 --- a/.agents/notes/implemented/bug-fix/2026-07-20-config-hot-reload-resilience.md +++ /dev/null @@ -1,38 +0,0 @@ -# Agent Note: A config hot-reload must not kill or degrade a live app - -Status: implemented - -English | [中文](2026-07-20-config-hot-reload-resilience.zh.md) - -## Problem - -The demo apps mount `@cordisjs/plugin-hmr` as a leaf so a running agent picks up `cordis.yml` edits. One bad edit killed the process: `Include.refresh()` rethrew the YAML parse error, the HMR watcher awaits `refresh()` inside an async chokidar callback nobody catches, and the resulting unhandled rejection tripped `dsh-app-boot`'s fail-loud handler — `exit(1)` mid-session, losing the live TUI. Two adjacent defects made even *valid* reloads wrong: a file that parses to `undefined` (empty or mid-write truncated — editors and `sed -i` routinely produce these states) crashed the entry walk instead of reading as invalid, and a re-read never re-applied the include's `config.patches`, so any hot-reload of an overlay-based tree (Code Mode, personal overlays) silently reverted patched entries and removed inserted ones. - -## Decision - -Harden the vendored `@cordisjs/plugin-include` (logged as local modification 8 in [vendor/README.md](../../../../vendor/README.md)) rather than the callers: - -- `refresh()` awaits the whole read-and-update and catches failures, logs a warning, and keeps the last good entry tree. A hot-reload is advisory; the invariant is that no file state reachable by an editor may take the process down. -- `read()` rejects a non-array parse result with a `TypeError`, folding the `undefined`-parse case into the same "invalid file" signal, and commits `content`/`data` only after a successful parse — so reverting an edit to the exact last good content correctly reads as "unchanged". -- `refresh()` and the `internal/update` listener apply `this.applyPatches(...)` before `root.update()`, restoring parity with `[Service.init]`. `applyPatches` deep-copies the cached parse (`structuredClone`) instead of mutating it, so repeated application converges and removing a patch reverts to the file's own values. The listener uses the incoming config's `patches` and persists that config itself: it vetoes the fiber restart (children update in place), and `Fiber.update` only assigns `this.config` behind `next()`, so without the explicit assignment the next re-read would re-apply the old overlay. - -Boot-time behavior stays fail-loud and gets a sharper diagnostic: `[Service.init]` falls back to `initial` (or "config file not found") only on `ENOENT`; an existing-but-invalid file now fails with its real parse error instead of being mislabelled as absent or silently overwritten by `initial`. - -## Alternatives considered - -**Catch in the HMR watcher callback instead of `refresh()`.** Rejected: it would leave `refresh()` a trap for every other caller (the `internal/update` path shares the same tree-update logic), and it cannot fix the `undefined`-parse or patch-loss defects, which live inside the include. - -**Filter config-file rejections in `installFailLoud`.** Rejected: the fail-loud handler exists to make late load failures visible; teaching it to classify exceptions by origin would silently swallow genuine boot failures and leave the stale-`data` crash in place. - -**A PTY e2e proving the TUI survives a bad edit.** Rejected as the primary gate: the PTY smoke reads the repo's committed `cordis.yml`, so corrupting it in-place is not test-safe, and a temp copy cannot resolve the tree's bare package specifiers. The unit spec drives the exact `refresh()` entry point the watcher calls; the fix was additionally verified manually against the live TUI (bad YAML, empty file, restored file). - -## Consequences - -- A bad `cordis.yml` edit now logs `ignoring config reload at <file>` and the agent keeps running on the last good tree; the next valid edit applies normally. With no logger exporter mounted in the TUI demos the warning is currently invisible on screen — surfacing loader warnings in the TUI is deferred. -- Overlay trees survive base-file reloads with patches intact instead of silently reverting to the unpatched base. -- The vendored include diverges further from upstream; the divergence is logged in the vendor manifest and re-applies on the next sync. -- Known gap, out of scope here: the HMR watcher only handles chokidar `change` events, so editors that replace the file by rename (BSD `sed -i`, `git checkout`) do not trigger a config reload at all; and a reloaded app-entry config does not visibly restart the running TUI (pre-existing on the unmodified tree). - -## Testing - -`packages/ui/app-boot/tests/config-reload.spec.ts` boots real Loader trees against temp configs and pins: an invalid-YAML edit and an empty-file edit both resolve `refresh()` without rejection and keep the previous entry config; a subsequent valid edit applies; an overlay tree re-applies both entry patches and inserted entries on re-read; a hot-update of the include entry's own `patches` applies immediately, survives the next file re-read, and reverts cleanly when the patches are removed. The assertions fail on the unpatched vendored include. diff --git a/.agents/notes/implemented/bug-fix/2026-07-20-config-hot-reload-resilience.zh.md b/.agents/notes/implemented/bug-fix/2026-07-20-config-hot-reload-resilience.zh.md deleted file mode 100644 index 6c7a421bfa..0000000000 --- a/.agents/notes/implemented/bug-fix/2026-07-20-config-hot-reload-resilience.zh.md +++ /dev/null @@ -1,38 +0,0 @@ -# Agent Note: 配置热重载不得杀死或降级正在运行的应用 - -Status: implemented - -[English](2026-07-20-config-hot-reload-resilience.md) | 中文 - -## Problem - -各示例应用把 `@cordisjs/plugin-hmr` 挂载为叶子配置项,让运行中的 agent 能感知 `cordis.yml` 的编辑。一次错误的编辑就会杀死进程:`Include.refresh()` 把 YAML 解析错误原样抛出,HMR 的文件监听器在一个无人捕获的异步 chokidar 回调里 await `refresh()`,产生的未处理 rejection 触发 `dsh-app-boot` 的快速失败处理器——会话中途 `exit(1)`,正在运行的 TUI 就此丢失。另有两个相邻缺陷让*合法*的重载也出错:解析结果为 `undefined` 的文件(空文件或写入中途被截断的文件——编辑器和 `sed -i` 常态性地产生这类中间状态)会让配置项遍历直接崩溃,而不是被判定为无效文件;并且重新读取时从不重新应用 include 的 `config.patches`,因此对基于 overlay 的配置树(Code Mode、个人 overlay)做任何热重载,都会悄悄把打过补丁的配置项回退、并把插入的配置项移除。 - -## Decision - -加固 vendor 的 `@cordisjs/plugin-include`(在 [vendor/README.md](../../../../vendor/README.md) 中记录为本地修改第 8 条),而不是修改调用方: - -- `refresh()` await 整个「读取并更新」过程并捕获失败,记录一条警告,并保留上一份完好的配置树。热重载是尽力而为的;不变式是编辑器可能产生的任何文件状态都不得导致进程退出。 -- `read()` 对非数组的解析结果抛出 `TypeError`,把 `undefined` 解析结果并入同一个「无效文件」信号,并且只在解析成功后才提交 `content`/`data`——因此把编辑撤销回与上一份完好内容完全一致时,会正确地判定为「无变化」。 -- `refresh()` 与 `internal/update` 监听器在 `root.update()` 之前调用 `this.applyPatches(...)`,与 `[Service.init]` 保持一致。`applyPatches` 对缓存的解析结果做深拷贝(`structuredClone`)而不是就地修改,因此重复应用会收敛,移除补丁会回退到文件自身的值。监听器使用传入配置中的 `patches` 并自行持久化该配置:它否决 fiber 重启(子配置项就地更新),而 `Fiber.update` 只在 `next()` 之后才赋值 `this.config`,若不显式赋值,下一次重新读取会重新应用旧的 overlay。 - -启动期行为保持快速失败并获得更准确的诊断:`[Service.init]` 只在 `ENOENT` 时回退到 `initial`(或「config file not found」);存在但无效的文件现在会以真实的解析错误失败,而不是被误标为文件缺失、或被 `initial` 静默覆盖。 - -## Alternatives considered - -**在 HMR 监听回调里捕获,而不是在 `refresh()` 里。** 否决:这会让 `refresh()` 继续成为其他所有调用方的陷阱(`internal/update` 路径共享同一套树更新逻辑),而且无法修复 `undefined` 解析结果与补丁丢失这两个位于 include 内部的缺陷。 - -**在 `installFailLoud` 里过滤配置文件相关的 rejection。** 否决:快速失败处理器的存在意义就是让延迟出现的加载失败可见;教它按来源给异常分类会悄悄吞掉真正的启动失败,并且原样保留陈旧 `data` 导致的崩溃。 - -**用 PTY e2e 证明 TUI 能在错误编辑后存活。** 否决其作为主要门禁:PTY 冒烟测试读取仓库中已提交的 `cordis.yml`,就地破坏它对测试不安全,而临时副本无法解析该配置树的裸包说明符。单元测试直接驱动监听器所调用的 `refresh()` 入口;此外还对运行中的 TUI 做了人工验证(错误 YAML、空文件、恢复文件)。 - -## Consequences - -- 现在错误的 `cordis.yml` 编辑会记录 `ignoring config reload at <file>`,agent 继续运行在上一份完好的配置树上;下一次合法编辑正常生效。TUI 示例没有挂载任何日志导出器,这条警告目前不会显示在屏幕上——在 TUI 中呈现 loader 警告的工作暂缓。 -- overlay 配置树在基础文件重载后补丁保持完整,不再悄悄回退到未打补丁的基础配置。 -- vendor 的 include 与上游进一步分叉;该分叉已记录在 vendor 的 manifest 里,下次同步时重新应用。 -- 已知缺口,不在本次范围内:HMR 监听器只处理 chokidar 的 `change` 事件,因此通过重命名替换文件的编辑方式(BSD `sed -i`、`git checkout`)完全不会触发配置重载;应用配置项重载后也不会可见地重启运行中的 TUI(未修改的代码树上即已如此)。 - -## Testing - -`packages/ui/app-boot/tests/config-reload.spec.ts` 用真实 Loader 树加载临时配置并固定以下行为:无效 YAML 编辑和空文件编辑都让 `refresh()` 正常 resolve 而不产生 rejection,并保留之前的配置项配置;随后的合法编辑正常生效;overlay 配置树在重新读取时重新应用配置项补丁和插入的配置项;对 include 配置项自身 `patches` 的热更新立即生效、在下一次文件重读后依然保持、并在补丁移除后干净地回退。这些断言在未打补丁的 vendor include 上会失败。 diff --git a/packages/ui/app-boot/tests/config-reload.spec.ts b/packages/ui/app-boot/tests/config-reload.spec.ts deleted file mode 100644 index 05ebe80b58..0000000000 --- a/packages/ui/app-boot/tests/config-reload.spec.ts +++ /dev/null @@ -1,128 +0,0 @@ -/** - * Config hot-reload resilience of the booted include tree. `dsh-app-boot` - * installs a fail-loud unhandled-rejection handler, so a `refresh()` that - * rethrows a config-file parse error would kill a live app on one bad - * `cordis.yml` edit (the HMR watcher awaits `refresh()` in an async event - * callback nobody else catches). These tests pin the vendored - * `@cordisjs/plugin-include` contract that boot relies on: an invalid file - * keeps the last good tree, and a valid re-read re-applies overlay patches - * exactly like the initial load. - */ - -import { mkdtempSync, writeFileSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { describe, expect, it } from 'vitest' -import type { Context } from 'cordis' -import type { Include } from '@cordisjs/plugin-include' -import { boot } from '../src/index.ts' - -const NAME = 'dsh-test-bin' - -const NOOP_PLUGIN = 'export const name = "noop"\nexport function apply() {}\n' - -interface TreeFixture { - ctx: Context - dir: string - include: Include -} - -async function bootTree(configBody: string): Promise<TreeFixture> { - const dir = mkdtempSync(join(tmpdir(), 'dsh-config-reload-')) - writeFileSync(join(dir, 'noop.mjs'), NOOP_PLUGIN) - writeFileSync(join(dir, 'cordis.yml'), configBody) - const ctx = await boot(NAME, join(dir, 'cordis.yml')) - const entry = [...ctx.loader.entries()].find(candidate => candidate.subtree !== undefined) - if (entry?.subtree === undefined) throw new Error('booted tree has no include entry') - return { ctx, dir, include: entry.subtree as Include } -} - -function entryConfig(ctx: Context, id: string): unknown { - return [...ctx.loader.entries()].find(entry => entry.options.id === id)?.options.config -} - -describe('include refresh with an invalid file', () => { - it('keeps the last good tree instead of throwing, then applies the next valid edit', async () => { - const { ctx, dir, include } = await bootTree('- id: noop\n name: ./noop.mjs\n config:\n value: 1\n') - try { - expect(entryConfig(ctx, 'noop')).toEqual({ value: 1 }) - - writeFileSync(join(dir, 'cordis.yml'), 'invalid: [unclosed\n') - await expect(include.refresh()).resolves.toBeUndefined() - expect(entryConfig(ctx, 'noop')).toEqual({ value: 1 }) - - // An empty file parses to `undefined` without a YAML error; it must be - // treated exactly like a parse failure, not crash the entry walk. - writeFileSync(join(dir, 'cordis.yml'), '') - await expect(include.refresh()).resolves.toBeUndefined() - expect(entryConfig(ctx, 'noop')).toEqual({ value: 1 }) - - writeFileSync(join(dir, 'cordis.yml'), '- id: noop\n name: ./noop.mjs\n config:\n value: 2\n') - await include.refresh() - await ctx.loader.await() - expect(entryConfig(ctx, 'noop')).toEqual({ value: 2 }) - } finally { - await ctx.fiber.dispose() - } - }) -}) - -describe('include refresh with overlay patches', () => { - it('re-applies entry patches and inserted entries on every re-read (parity with initial load)', async () => { - const dir = mkdtempSync(join(tmpdir(), 'dsh-config-reload-overlay-')) - writeFileSync(join(dir, 'noop.mjs'), NOOP_PLUGIN) - writeFileSync(join(dir, 'base.yml'), '- id: noop\n name: ./noop.mjs\n config:\n value: base\n') - writeFileSync(join(dir, 'cordis.yml'), [ - '- id: base', - " name: 'cordis:include'", - ' config:', - ' path: ./base.yml', - ' patches:', - ' - id: noop', - ' name: ./noop.mjs', - ' config:', - ' value: patched', - ' - insert:', - ' - id: extra', - ' name: ./noop.mjs', - '', - ].join('\n')) - const ctx = await boot(NAME, join(dir, 'cordis.yml')) - try { - const entry = [...ctx.loader.entries()].find(candidate => candidate.options.id === 'base') - if (entry?.subtree === undefined) throw new Error('overlay tree has no base include entry') - const include = entry.subtree as Include - expect(entryConfig(ctx, 'noop')).toEqual({ value: 'patched' }) - expect(entryConfig(ctx, 'extra')).toBeUndefined() - expect([...ctx.loader.entries()].some(candidate => candidate.options.id === 'extra')).toBe(true) - - writeFileSync(join(dir, 'base.yml'), '- id: noop\n name: ./noop.mjs\n config:\n value: edited\n') - await include.refresh() - await ctx.loader.await() - expect(entryConfig(ctx, 'noop')).toEqual({ value: 'patched' }) - expect([...ctx.loader.entries()].some(candidate => candidate.options.id === 'extra')).toBe(true) - - // Hot-update of the include entry's own config (the `internal/update` - // path): the new patches must apply now AND stick for later re-reads — - // the listener vetoes the fiber restart, so it must persist the new - // config itself or the next refresh() re-applies the old overlay. - await entry.update({ config: { path: './base.yml', patches: [{ id: 'noop', name: './noop.mjs', config: { value: 'patched-v2' } }] } }) - await ctx.loader.await() - expect(entryConfig(ctx, 'noop')).toEqual({ value: 'patched-v2' }) - expect([...ctx.loader.entries()].some(candidate => candidate.options.id === 'extra')).toBe(false) - - writeFileSync(join(dir, 'base.yml'), '- id: noop\n name: ./noop.mjs\n config:\n value: edited-2\n') - await include.refresh() - await ctx.loader.await() - expect(entryConfig(ctx, 'noop')).toEqual({ value: 'patched-v2' }) - - // Removing every patch must revert to the file's own values: patching - // may not bake earlier patch results into the cached parse. - await entry.update({ config: { path: './base.yml', patches: [] } }) - await ctx.loader.await() - expect(entryConfig(ctx, 'noop')).toEqual({ value: 'edited-2' }) - } finally { - await ctx.fiber.dispose() - } - }) -}) diff --git a/vendor/README.md b/vendor/README.md index 1f4d61e6b1..ae43760ceb 100644 --- a/vendor/README.md +++ b/vendor/README.md @@ -37,7 +37,6 @@ Keep this log exhaustive — every divergence from upstream must be listed. 5. **`schemastery/tsdown.config.ts` and `logger-console/tsdown.config.ts`**: ours, not upstream files — per-package build-shape overrides (dual ESM+CJS output; separate node/browser entries) for the repo-root tsdown build. They read the JS emitted under `lib/types` and then write the publish runtime entries under `lib/`. Like the regenerated tsconfigs, they are not part of the upstream sync surface. 6. **`cordis/src/fiber.ts` lifecycle hardening**: locally closes three reentrant disposal gaps. An effect's owner-list wrapper is registered before its setup body runs, so an unload begun from inside setup awaits setup and every collected cleanup; synchronous setup failure removes the wrapper and rolls back collected cleanup. Async cleanup stays owner-visible until quiescence, and Cordis's internal effect composition joins an already-running cleanup while repeated public disposer calls retain their upstream single-shot result. Effect creation is rejected while the owner is `UNLOADING` (while `PENDING` and `LOADING` remain legal), preventing cleanup-time registrations from escaping the unload snapshot. Child fibers register and receive their parent-owned disposer before `internal/plugin` publication, resolve dependency declarations added by that notification before activation, drain effects attached while pending, skip plugin execution when reentrant disposal invalidates the load epoch before its first checkpoint, and contain teardown-notification failures per observer so one callback cannot starve peers or interrupt ownership cleanup. 7. **`cordis/src/*.ts` JSDoc enrichment**: added `@param`/`@returns` tags and contract documentation (disposal semantics, waterfall veto, bail conditions, error cases) across the public plugin-author surface — `Context` (class, statics, and the `Context` interface properties incl. `root`), `EventsService`, `Fiber`, `RegistryService`, `ReflectService`, `Service`, `LoggerService` and their `declare module './context.ts'` overloads. Comment-only; no code changes. Motivation: the website API-reference generator renders these docs and hard-errors on undocumented members. Retire this entry when the enrichment is upstreamed to the fork. -8. **`include/src/index.ts` hot-reload hardening**: `refresh()` awaits the full read-and-update and catches failures (logging a warning and keeping the last good entry tree) instead of rethrowing — upstream's throw escaped `@cordisjs/plugin-hmr`'s async watcher callback as an unhandled rejection, so one bad `cordis.yml` edit killed a live app. `read()` rejects a non-array parse result (an empty or mid-write truncated file parses to `undefined`, which upstream later crashed on) and commits `content`/`data` only on success, so reverting an edit to the exact last good content reads as "unchanged". `refresh()` and the `internal/update` listener re-apply `config.patches` before `root.update()`, matching initial load; upstream applied patches only in `[Service.init]`, so any config hot-reload silently reverted overlay-patched entries and removed inserted ones. `applyPatches` deep-copies via `structuredClone` instead of mutating the cached parse (repeated application converges; removing a patch reverts), and the veto-style `internal/update` listener persists the incoming config itself (`Fiber.update` only assigns behind `next()`), so later re-reads use the new patches. `[Service.init]` falls back to `initial` only on `ENOENT`; an existing-but-invalid file fails loud with its real parse error instead of "config file not found" (or a silent overwrite). Covered by `packages/ui/app-boot/tests/config-reload.spec.ts`. ## Sync procedure diff --git a/vendor/include/src/index.ts b/vendor/include/src/index.ts index b1517d5458..2258d3af06 100644 --- a/vendor/include/src/index.ts +++ b/vendor/include/src/index.ts @@ -77,15 +77,7 @@ export class Include extends EntryTree { ctx.on('internal/update', (config, _, next) => { if (config.path !== this.config.path) return next() - // Veto the fiber restart (children update in place), but persist the new - // config ourselves — `Fiber.update` only assigns `this.config` behind - // `next()`, and a stale `this.config.patches` would make the next - // `refresh()` re-apply the old overlay. - this.config = config - this.root.update(this.applyPatches(this.data!, config.patches)).catch((error) => { - this.ctx.logger.warn('config update at %C failed', this.filename) - this.ctx.logger.warn(error) - }) + this.root.update(this.data!) }) } @@ -101,37 +93,22 @@ export class Include extends EntryTree { private async read(forced = false) { const content = await readFile(this.filename, 'utf8') if (!forced && this.content === content) return false - let data: any + this.content = content if (this.type === 'application/yaml') { - data = yaml.load(content, { schema }) + this.data = yaml.load(this.content, { schema }) as any } else if (this.type === 'application/json') { - data = JSON.parse(content) + this.data = JSON.parse(this.content) as any } else { const module = await import(/* @vite-ignore */ this.filename) - data = module.default || module + this.data = module.default || module } - // An empty or truncated file (common mid-edit: editors and `sed -i` write - // through temp states) parses to `undefined`, not an error; reject every - // non-array shape here so callers see one "invalid file" signal. Content - // and data commit only on success, so an edit that is later reverted to - // the exact last good content correctly reads as "unchanged". - if (!Array.isArray(data)) { - throw new TypeError(`config file must be a top-level array of entries: ${this.filename}`) - } - this.content = content - this.data = data await this.checkAccess() return true } - private applyPatches(data: EntryOptions[], patches = this.config.patches): EntryOptions[] { - // Always detach from the cached parse: patching shared entry objects would - // bake earlier patch values into `this.data`, so repeated application - // (config hot-reloads) could never revert a removed or changed patch. The - // supported extensions guarantee JSON-safe plain data, so `structuredClone` - // cannot throw here. - if (!patches?.length) return [...data] - data = structuredClone(data) + private applyPatches(data: EntryOptions[]): EntryOptions[] { + const { patches } = this.config + if (!patches?.length) return data const entryMap = new Map<string, EntryOptions>() const buildMap = (entries: EntryOptions[]) => { @@ -197,11 +174,7 @@ export class Include extends EntryTree { async* [Service.init]() { try { await this.read() - } catch (error) { - // Only a missing file falls back to `initial` (or the not-found error): - // an existing-but-invalid file must fail loud with its real parse error, - // never be mislabelled as absent or silently overwritten. - if ((error as NodeJS.ErrnoException | null)?.code !== 'ENOENT') throw error + } catch { if (this.config.initial) { this.writeFile(this.config.initial as any) await this.read() @@ -211,26 +184,18 @@ export class Include extends EntryTree { } yield () => this.stop() - await this.root.update(this.applyPatches(this.data!)) + const data = this.applyPatches([...this.data!]) + await this.root.update(data) } stop() { this.root.stop() } - /** - * Re-read the file and refresh child entries when content changed. An - * unreadable or unparsable file logs a warning and keeps the last good - * tree: a hot-reload of a live app must never take the process down. - */ + /** Re-read the file and refresh child entries when content changed. */ async refresh() { - try { - if (!await this.read()) return - await this.root.update(this.applyPatches(this.data!)) - } catch (error) { - this.ctx.logger.warn('config reload at %C failed; keeping the running tree', this.filename) - this.ctx.logger.warn(error) - } + if (!await this.read()) return + this.root.update(this.data!) } private async _writeFile(config: EntryOptions[]) { From 25f06b4e29530ef4b7ae43eb593025728496d25c Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Wed, 22 Jul 2026 17:31:10 +0800 Subject: [PATCH 76/90] chore(install): flag TypeScript extraction --- scripts/install.sh | 2 ++ 1 file changed, 2 insertions(+) diff --git a/scripts/install.sh b/scripts/install.sh index cb3b04ead4..bc60dee983 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -24,6 +24,8 @@ # DSH_SOURCE checkout location (default: ~/.dsh/source) # DSH_BIN_DIR directory the `dsh` symlink lands in (default: ~/.local/bin) # DSH_HOME Harness home holding the personal config (default: ~/.dsh) +# FIXME(install-ts): Move the post-checkout workflow into a tested TypeScript +# entrypoint; keep this POSIX shell file as the curl/source bootstrap. set -eu DSH_REF=${DSH_REF:-master} From 988dd70e7c64a8d794a5b0875600300af0b8c441 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Wed, 22 Jul 2026 17:33:52 +0800 Subject: [PATCH 77/90] docs: leave Cordis reload guarantees to child --- .../feature/2026-07-20-dsh-cli-personal-config.i18n.yaml | 4 ++-- .../feature/2026-07-20-dsh-cli-personal-config.md | 2 -- .../feature/2026-07-20-dsh-cli-personal-config.zh.md | 2 -- .../feature/2026-07-21-tui-reload-command.i18n.yaml | 4 ++-- .../implemented/feature/2026-07-21-tui-reload-command.md | 6 +++--- .../implemented/feature/2026-07-21-tui-reload-command.zh.md | 6 +++--- 6 files changed, 10 insertions(+), 14 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.i18n.yaml b/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.i18n.yaml index 7addc991d2..a8c9c28d58 100644 --- a/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.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 -2026-07-20-dsh-cli-personal-config.md: 514bb5b12a3e04c7deaad1e8616472eed1c920e1 -2026-07-20-dsh-cli-personal-config.zh.md: 16fada82c59c8a356e6df112234e6b7565aae1bf +2026-07-20-dsh-cli-personal-config.md: e349374a6bc7fc0137bf14836469aef8bae8d49d +2026-07-20-dsh-cli-personal-config.zh.md: 88210dc386a245002de927950dab2852e40218ea diff --git a/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.md b/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.md index 514bb5b12a..e349374a6b 100644 --- a/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.md +++ b/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.md @@ -22,8 +22,6 @@ Two coupled pieces, aligned with the `apps/` assembly tier proposed by the `dsh The PTY smoke's launcher isolates `$DSH_HOME` to a per-test directory, exactly as it already isolates `DSH_AGENTS_HOME`, so a developer's real personal overlay cannot leak into fixtures; only the dsh CLI reads personal config, so no other test launcher needed changes. -Hot-reload interplay: the include re-applies its `patches` on every config re-read (the [config hot-reload resilience Agent Note](../bug-fix/2026-07-20-config-hot-reload-resilience.md)), so a live `cordis.yml` edit keeps the personal overlay applied. - ## Alternatives considered **A standalone `bin/dsh` wrapper owning the `dsh` name.** Rejected after reading PR #443: that PR establishes `apps/cli` as the `dsh` CLI with subcommand dispatch (`web`, `-p`) and leaves the default slot unclaimed. Two competing `dsh` entrypoints would collide in `$PATH` and in product identity; claiming the default slot inside the same package shape confines the eventual merge conflict to the small dispatch chain. diff --git a/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.zh.md b/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.zh.md index 16fada82c5..88210dc386 100644 --- a/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.zh.md +++ b/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.zh.md @@ -22,8 +22,6 @@ Status: implemented PTY 冒烟测试的启动器把 `$DSH_HOME` 隔离到每个测试自己的目录,与它已有的 `DSH_AGENTS_HOME` 隔离方式完全一致,开发者真实的个人 overlay 不可能泄漏进 fixture;只有 dsh CLI 读取个人配置,因此其他测试启动器无需改动。 -与热重载的交互:include 在每次配置重读时重新应用其 `patches`(见[配置热重载韧性 Agent Note](../bug-fix/2026-07-20-config-hot-reload-resilience.md)),因此运行中编辑 `cordis.yml` 后个人 overlay 仍保持生效。 - ## Alternatives considered **独立的 `bin/dsh` 包装脚本占有 `dsh` 这个名字。** 读过 PR #443 后否决:该 PR 把 `apps/cli` 确立为带子命令分发(`web`、`-p`)的 `dsh` CLI,并且默认位空缺。两个互相竞争的 `dsh` 入口会在 `$PATH` 和产品身份上冲突;在同一包形态内认领默认位,把最终的合并冲突限制在小小的分发链上。 diff --git a/.agents/notes/implemented/feature/2026-07-21-tui-reload-command.i18n.yaml b/.agents/notes/implemented/feature/2026-07-21-tui-reload-command.i18n.yaml index f3fba8a006..321131ac96 100644 --- a/.agents/notes/implemented/feature/2026-07-21-tui-reload-command.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-21-tui-reload-command.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 -2026-07-21-tui-reload-command.md: e5600f0ab5cd82dc556df76006fcf532d8c7d302 -2026-07-21-tui-reload-command.zh.md: 3798b0518df1c379cca808bd4af38490016567cb +2026-07-21-tui-reload-command.md: de9a5502214a610d88024730b1c0c1044a396c92 +2026-07-21-tui-reload-command.zh.md: 25d1d448459221698ca63377f8f18d05a0fa3d21 diff --git a/.agents/notes/implemented/feature/2026-07-21-tui-reload-command.md b/.agents/notes/implemented/feature/2026-07-21-tui-reload-command.md index e5600f0ab5..de9a550221 100644 --- a/.agents/notes/implemented/feature/2026-07-21-tui-reload-command.md +++ b/.agents/notes/implemented/feature/2026-07-21-tui-reload-command.md @@ -10,7 +10,7 @@ HMR's file watcher only reacts to in-place `change` events under its configured ## Decision -`dsh-tui` gains an **experimental, dev-only** `/reload` slash command: it walks `ctx.loader.entries()` and calls `refresh()` on every file-backed subtree (`Include`), i.e. the exact code path the HMR watcher's config-change branch drives, invoked manually and watcher-independent. Unchanged files are no-ops (content comparison in `Include.read`); invalid files warn and keep the running tree (the hot-reload-resilience contract); include `patches` — including the dsh CLI's personal overlay — re-apply on every re-read. +`dsh-tui` gains an **experimental, dev-only** `/reload` slash command: it walks `ctx.loader.entries()` and calls `refresh()` on every file-backed subtree (`Include`), i.e. the exact code path the HMR watcher's config-change branch drives, invoked manually and watcher-independent. Unchanged files are no-ops (content comparison in `Include.read`). The TUI reaches the Loader **structurally** (`ctx.loader` via a local type, not `inject`): tests and embedders run the TUI without a Loader, where `/reload` degrades to a warning notice instead of failing the mount. Module-source hot reload stays watcher-owned; `/reload` refreshes configs only. @@ -28,8 +28,8 @@ The TUI reaches the Loader **structurally** (`ctx.loader` via a local type, not - The command reports tree count and completion as transcript notices; per-file failures surface only in loader logs, which the TUI does not display — acceptable for a dev-only surface, noted in the completion message. - A re-entrancy guard serializes reloads: `/reload` while one is in flight is refused with a warning, keeping the loader's unmutexed tree-update pass single-writer; the guard releases on completion or failure. - `/reload` runs only while the agent is idle: a reload can dispose and re-mount entries, which under an active turn could tear tools or the adapter out from under in-flight calls. The check is advisory (a send can race in after it) but removes the common footgun. -- If `refresh()`'s never-reject contract ever changes, the command reports the failure instead of leaving an unhandled rejection. +- If any `refresh()` rejects, the command reports the failure instead of leaving an unhandled rejection. ## Testing -`packages/ui/tui/tests/tui.spec.ts` pins: `/reload` refreshes every file-backed subtree and skips plain entries (structural fake Loader), reports completion, refuses re-entry while a gated refresh is in flight and runs again after release, releases the guard on the failure arm, refuses a running agent and runs again at idle, reports a rejecting refresh, and degrades to a warning without a Loader — including mounted as a real plugin fiber, where a throwing service lookup would escape. Verified live in tmux against the real tree: probe edit → reload applies; invalid edit → reload keeps the running tree. +`packages/ui/tui/tests/tui.spec.ts` pins: `/reload` refreshes every file-backed subtree and skips plain entries (structural fake Loader), reports completion, refuses re-entry while a gated refresh is in flight and runs again after release, releases the guard on the failure arm, refuses a running agent and runs again at idle, reports a rejecting refresh, and degrades to a warning without a Loader — including mounted as a real plugin fiber, where a throwing service lookup would escape. Verified live in tmux against the real tree: a probe edit reloads successfully. diff --git a/.agents/notes/implemented/feature/2026-07-21-tui-reload-command.zh.md b/.agents/notes/implemented/feature/2026-07-21-tui-reload-command.zh.md index 3798b0518d..25d1d44845 100644 --- a/.agents/notes/implemented/feature/2026-07-21-tui-reload-command.zh.md +++ b/.agents/notes/implemented/feature/2026-07-21-tui-reload-command.zh.md @@ -10,7 +10,7 @@ HMR 的文件监听器只对其配置根目录(示例中即配置叶子所在 ## Decision -`dsh-tui` 增加一个**实验性、仅供开发**的 `/reload` 斜杠命令:遍历 `ctx.loader.entries()`,对每个文件后端的子树(`Include`)调用 `refresh()`——即 HMR 监听器配置变更分支所走的同一条代码路径,改为手动触发、不依赖监听器。未变化的文件是无操作(`Include.read` 做内容比较);无效文件记录警告并保留运行中的树(热重载韧性契约);include 的 `patches`——包括 dsh CLI 的个人 overlay——在每次重读时重新应用。 +`dsh-tui` 增加一个**实验性、仅供开发**的 `/reload` 斜杠命令:遍历 `ctx.loader.entries()`,对每个文件后端的子树(`Include`)调用 `refresh()`——即 HMR 监听器配置变更分支所走的同一条代码路径,改为手动触发、不依赖监听器。未变化的文件是无操作(`Include.read` 做内容比较)。 TUI 以**结构方式**访问 Loader(通过局部类型访问 `ctx.loader`,而非 `inject`):测试和嵌入方在没有 Loader 的情况下运行 TUI,此时 `/reload` 退化为一条警告通知而不是挂载失败。模块源码热重载仍由监听器负责;`/reload` 只刷新配置。 @@ -28,8 +28,8 @@ TUI 以**结构方式**访问 Loader(通过局部类型访问 `ctx.loader`, - 命令以 transcript 通知报告树数量与完成;单文件失败只出现在 loader 日志里,TUI 不显示——对仅供开发的表面可以接受,完成消息中已注明。 - 重入保护串行化重载:前一次进行中时 `/reload` 会被拒绝并提示警告,使 loader 无互斥的树更新过程保持单写者;保护在完成或失败时释放。 - `/reload` 只在 agent 空闲时运行:重载可能卸载并重新挂载配置项,在活跃轮次下这会把工具或适配器从进行中的调用脚下抽掉。检查是建议性的(检查后仍可能有 send 竞争进来),但消除了常见的坑。 -- 若 `refresh()` 的永不 reject 契约将来改变,命令会报告失败而不是留下未处理的 rejection。 +- 任一 `refresh()` 若 reject,命令会报告失败而不是留下未处理的 rejection。 ## Testing -`packages/ui/tui/tests/tui.spec.ts` 固定:`/reload` 刷新每个文件后端子树并跳过普通配置项(结构化的假 Loader)、报告完成、在门控的刷新进行中拒绝重入并在释放后可再次运行、失败分支同样释放保护、拒绝运行中的 agent 并在空闲后可再次运行、报告 reject 的 refresh、无 Loader 时退化为警告——包括作为真实插件 fiber 挂载的情形,在那里会抛出的服务查找会泄露出去。已在 tmux 中对真实配置树实机验证:探针编辑 → reload 生效;无效编辑 → reload 保留运行中的树。 +`packages/ui/tui/tests/tui.spec.ts` 固定:`/reload` 刷新每个文件后端子树并跳过普通配置项(结构化的假 Loader)、报告完成、在门控的刷新进行中拒绝重入并在释放后可再次运行、失败分支同样释放保护、拒绝运行中的 agent 并在空闲后可再次运行、报告 reject 的 refresh、无 Loader 时退化为警告——包括作为真实插件 fiber 挂载的情形,在那里会抛出的服务查找会泄露出去。已在 tmux 中对真实配置树实机验证:探针编辑后 reload 成功生效。 From 41609c4ce42c90d932f3995521bc5ae2d20cfccd Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Wed, 22 Jul 2026 17:53:01 +0800 Subject: [PATCH 78/90] ci: consolidate primary checks on one larger runner --- .../2026-07-06-parallel-github-ci-gates.md | 10 +- ...ence-based-larger-hosted-runners.i18n.yaml | 4 +- ...22-evidence-based-larger-hosted-runners.md | 82 ++- ...evidence-based-larger-hosted-runners.zh.md | 82 ++- .github/workflows/ci.yml | 504 +----------------- examples/acp-agent/tests/acp.snapshot.ts | 4 - .../tests/snapshot-scenario-shard.spec.ts | 20 - .../tests/snapshot-scenario-shard.ts | 18 - package.json | 2 - packages/support/acp-snapshot/README.md | 2 +- packages/support/acp-snapshot/src/index.ts | 1 - .../acp-snapshot/src/scenario-shard.ts | 36 -- packages/support/acp-snapshot/src/suite.ts | 14 +- .../acp-snapshot/tests/scenario-shard.spec.ts | 25 - .../support/acp-snapshot/tests/suite.spec.ts | 22 - scripts/coverage-shards.spec.ts | 46 -- scripts/coverage-shards.ts | 114 ---- scripts/lint-shards.spec.ts | 81 --- scripts/lint-shards.ts | 56 -- scripts/run-gates.ts | 94 +--- scripts/snapshot-shards.spec.ts | 54 -- scripts/snapshot-shards.ts | 49 -- scripts/static-shards.spec.ts | 26 - scripts/static-shards.ts | 86 --- 24 files changed, 102 insertions(+), 1330 deletions(-) delete mode 100644 examples/acp-agent/tests/snapshot-scenario-shard.spec.ts delete mode 100644 examples/acp-agent/tests/snapshot-scenario-shard.ts delete mode 100644 packages/support/acp-snapshot/src/scenario-shard.ts delete mode 100644 packages/support/acp-snapshot/tests/scenario-shard.spec.ts delete mode 100644 scripts/coverage-shards.spec.ts delete mode 100644 scripts/coverage-shards.ts delete mode 100644 scripts/lint-shards.spec.ts delete mode 100644 scripts/lint-shards.ts delete mode 100644 scripts/snapshot-shards.spec.ts delete mode 100644 scripts/snapshot-shards.ts delete mode 100644 scripts/static-shards.spec.ts delete mode 100644 scripts/static-shards.ts diff --git a/.agents/notes/implemented/process/2026-07-06-parallel-github-ci-gates.md b/.agents/notes/implemented/process/2026-07-06-parallel-github-ci-gates.md index c7904ae378..fef5852153 100644 --- a/.agents/notes/implemented/process/2026-07-06-parallel-github-ci-gates.md +++ b/.agents/notes/implemented/process/2026-07-06-parallel-github-ci-gates.md @@ -12,13 +12,13 @@ The artifact boundary remains load-bearing. `publint`, `verify-node-next-types`, ## Decision -The production topology below was superseded by [Evidence-based larger hosted runners](2026-07-22-evidence-based-larger-hosted-runners.md). Its shard selectors and former job layout remain available only through manual `suite=sharded-reference`; this note preserves why that earlier topology was implemented. +The production topology below is historical and is superseded by [Evidence-based larger hosted runners](2026-07-22-evidence-based-larger-hosted-runners.md). The larger-runner decision removes its shard selectors and workflow jobs; this note preserves why that earlier topology was implemented. [CI](../../../../.github/workflows/ci.yml) treats one minute for non-Windows jobs and three minutes for Windows jobs as observed performance targets, not cancellation deadlines. Hosted-runner variance should leave complete timing evidence and useful failure logs instead of cancelling an otherwise-correct gate. The [serial cross-platform CI reference](2026-07-21-serial-cross-platform-ci-reference.md) independently runs the complete unsharded primary Node aggregate on Linux, macOS, and Windows so the optimized lane inventory is not its own completeness oracle. -[scripts/run-gates.ts](../../../../scripts/run-gates.ts) remains the common bounded scheduler, but GitHub supplies explicit shard names for the expensive gate families. [scripts/static-shards.ts](../../../../scripts/static-shards.ts) partitions static gates into foundation, documentation-type, API-contract, catalog, prose, documentation-projection, and documentation-build ownership and rejects a missing or duplicate gate assignment; low-cost ownership groups may share one hosted lane to stay below the runner-provisioning concurrency wave. Linux lint uses disjoint A-C, D-M, N-S, and T-Z package-source and package-test lanes, while the longer Windows target uses one complete package-source lane and one complete package-test lane; both topologies include a repository complement that still starts from `.` so a new top-level lint target cannot disappear between shards and owns the single cross-file duplication run. [scripts/coverage-shards.ts](../../../../scripts/coverage-shards.ts) assigns every workspace package to exactly one source-coverage lane; its test expands the live package tree, so a new package makes CI red until it has an owner. Directory filters retain a trailing separator because Vitest positional filters match substrings and would otherwise admit prefix-named sibling packages. Each coverage lane includes only its owned source files, repeats the exhaustive companion topology test, and runs without a preceding build because the complete coverage suite passes from a tree with every generated `lib/` removed. +In that topology, [scripts/run-gates.ts](../../../../scripts/run-gates.ts) was the common bounded scheduler and GitHub supplied explicit shard names for the expensive gate families. `scripts/static-shards.ts` partitioned static gates into foundation, documentation-type, API-contract, catalog, prose, documentation-projection, and documentation-build ownership and rejected a missing or duplicate gate assignment. Linux lint used disjoint A-C, D-M, N-S, and T-Z package-source and package-test lanes, while Windows used complete package-source and package-test lanes; both included a repository complement starting from `.` so new top-level targets could not disappear between shards and owned the single cross-file duplication run. `scripts/coverage-shards.ts` assigned every workspace package to exactly one source-coverage lane. Directory filters retained a trailing separator because Vitest positional filters match substrings and would otherwise admit prefix-named siblings. Each coverage lane included only its owned source files, repeated the exhaustive companion topology test, and ran without a preceding build because the complete coverage suite passes from a tree with every generated `lib/` removed. -Snapshot replay uses two explicit multi-file lanes and eight scenario partitions of the large ACP file. [scripts/snapshot-shards.ts](../../../../scripts/snapshot-shards.ts) owns that inventory, and its test discovers every file admitted by the snapshot config so a new file cannot land outside CI. Each snapshot job installs dependencies while its Linux runner installs bubblewrap, refreshing APT indexes and retrying if the hosted image's initial package install fails, then builds the shipped runtime and runs only its assigned replay surface. CI explicitly retains the suite's bounded concurrency of five subprocesses instead of clamping it to the runner's two logical CPUs, because replay spends most of its time waiting on child protocol I/O. Fixture guards still inspect the complete ACP scenario table in every partition. Static, lint, coverage, and snapshot sharding changes only GitHub scheduling: the ordinary local package scripts still run their complete suites. +Snapshot replay used two explicit multi-file lanes and eight scenario partitions of the large ACP file. `scripts/snapshot-shards.ts` owned that inventory, and its test discovered every file admitted by the snapshot config. Each snapshot job installed dependencies while its Linux runner prepared Bubblewrap, built the shipped runtime, and ran only its assigned replay surface. The suite retained bounded concurrency of five subprocesses because replay spent most of its time waiting on child protocol I/O. Fixture guards still inspected the complete ACP scenario table in every partition. Cold standalone documentation typechecking rebuilds the complete project-reference graph, so a dedicated documentation-type lane builds once and checks Markdown blocks against those declarations. The Linux documentation lane uses VitePress's MPA build to retain page rendering and dead-link validation within the observed non-Windows target; separate blocking Windows build and production-site lanes preserve the emitted-package and shipped-site checks without putting both critical paths in one job. @@ -41,9 +41,7 @@ The workflow caches the pnpm store, keys each immutable ESLint cache to its owni ## Consequences -The Actions UI contains more matrix checks and total runner time can exceed a serial workflow, but PR wall time is the slowest bounded lane instead of the sum of unrelated work. Repeated setup and builds are the deliberate price of sub-minute non-Windows feedback and sub-three-minute Windows feedback. - -Shard inventories are repository contracts. Static selection validates the complete live gate list at runtime, coverage tests validate exhaustive package ownership, and Vitest owns deterministic file sharding for snapshots and built-bin smokes. Adding a gate or package therefore requires an explicit scheduling decision instead of silently lengthening an existing lane. +The shard inventories and matrix jobs described above are not part of the current repository contract. The superseding larger-runner decision keeps the complete primary inventory in one process and uses the serial suite as its independent completeness oracle. The optimized publication validators rely on the manifest `files` contract enforced by `verify-package-invariants`. If publication rules grow beyond that contract, the structural gate and both staged views must change together. diff --git a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.i18n.yaml b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.i18n.yaml index c036f6996e..d2114f1bd0 100644 --- a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.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 -2026-07-22-evidence-based-larger-hosted-runners.md: 30a8b0517690d70ad90f8042a3f1ba841cace76e -2026-07-22-evidence-based-larger-hosted-runners.zh.md: 1335fc66bcbf24cdda3508210dd92786b52b9fff +2026-07-22-evidence-based-larger-hosted-runners.md: bb10a53058a7ae1a46d493933868887bb2f55265 +2026-07-22-evidence-based-larger-hosted-runners.zh.md: ffaf958560dae6e044da58f910c0f53e21789344 diff --git a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.md b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.md index 30a8b05176..bb10a53058 100644 --- a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.md +++ b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.md @@ -14,76 +14,58 @@ Larger runners make it possible to pay setup once and parallelize inside the rep The organization keeps twelve x64 larger-runner pools in the repo-restricted `dsh-larger-ci` group: Ubuntu 24.04 and Windows 2025 at 4, 8, 16, 32, 64, and 96 cores. Public IPs are disabled. Each pool has an autoscaling ceiling of 256; the ceiling does not allocate idle machines or remove the need to bound workflow demand. -Production CI assigns each of the six Linux pool sizes exactly once, assigns one 32-core Windows pool, and keeps only the final aggregator on a standard runner. The version and language jobs are environment contracts rather than slices of one gate inventory; the primary Node work has three coarse lanes instead of a gate-level shard matrix: +Production CI uses five larger-runner executions and one standard-runner aggregator. The primary Node inventory is not sharded: -- `node 24 / core` uses the 96-core Linux pool. One checkout, setup, cache restore, and install feeds 36 unsharded static, lint, documentation, hygiene, build, and artifact gates. `run-gates` starts up to 32 independent gates and ESLint uses 32 workers. Build starts eagerly; its artifact consumers still wait for emitted output. -- `node 24 / cpu` uses the 64-core Linux pool for six CPU- or dependency-critical gates: typecheck, coverage, build followed by snapshot replay, and two Node 24 compatibility smokes. Coverage uses at most 12 workers and snapshot uses at most 16. This lane builds separately so snapshot replay consumes same-lane output. Coverage stays at 12 forks because 32 forks crashed Node 24's CJS lexer twice and a later 16-fork run reproduced the same worker loss and invalid coverage. -- `node 24 / production site` uses the 16-core Linux pool for the longest independent primary gate. This is one coarse split, not a shard matrix: the job performs one setup and one production VitePress build. -- Node 22.19 compatibility, Python 3.10, and Node 26 compatibility use the 4-, 8-, and 32-core Linux pools respectively. Distinct labels avoid both standard-runner setup outliers and the delayed second allocation observed when two jobs shared one pool. -- `windows node 24 / complete` uses the 32-core Windows pool. One setup feeds the required package build, the required production site build, and the complete observational portability inventory. The outer scheduler has 32 slots. Required failures fail the job; observational failures are printed as non-blocking and preserve their former advisory status. ESLint itself stays single-threaded because 16 ESLint worker threads increased full-lint time to 174.54 seconds; outer gate concurrency uses the runner without multiplying Windows worker startup and TypeScript project loading. +- `node 24 / complete` uses one 96-core Linux runner. One checkout, setup, cache restore, and install feeds all 42 primary gates. `run-gates` starts up to 32 independent gates; ESLint uses 32 workers, coverage uses at most 12, and snapshot replay uses at most 16. Build starts immediately beside typecheck, coverage, lint, and documentation work, while snapshot replay and publication consumers retain explicit dependencies on emitted `lib/` output. +- Node 22.19 and Node 26 use the 4- and 32-core Linux pools for their runtime compatibility smokes. Python 3.10 uses the 8-core Linux pool for the complete keyless SDK suite. These are environment contracts, not slices of the primary Node gate inventory. +- `windows node 24 / complete` uses one 32-core Windows runner. One setup feeds the required package build, required production site build, and complete observational portability inventory. Required failures fail the job; observational failures are reported as non-blocking. ESLint stays single-threaded because 16 ESLint workers took 174.54 seconds, while the outer scheduler retains 32 slots. -The Windows shape followed two cold-path observations. A first candidate used two 16-core Windows jobs, and GitHub took 93 seconds to provision the second same-label runner despite the configured autoscaling ceiling. A later [documentation-head validation](https://github.com/deepseek-harness/deepseek-harness/actions/runs/29900502413) took 266 seconds on a separate Windows blocking job after spending 138 seconds restoring a 153 MB pnpm cache. Combining all Windows work on one 32-core box removed the duplicate setup wave. +The former gate-level and coarse primary shard jobs are absent from the workflow. Their static, lint, coverage, snapshot, and scenario shard selectors are also absent from the repository, so an unused diagnostic path cannot preserve a second CI architecture. -Two later runs set the Linux boundaries. A [standard-runner validation](https://github.com/deepseek-harness/deepseek-harness/actions/runs/29902209492) took 67 seconds for Node 26 even though repository work took five seconds, because GitHub spent 36 seconds in `Set up job`. Moving the environment contracts to distinct larger pools removed that lottery. The next [all-larger-runner validation](https://github.com/deepseek-harness/deepseek-harness/actions/runs/29902541203) took 68 seconds on the 96-core primary job: repository work remained 26 seconds, but setup, cache, install, and finalization consumed 42 seconds. Moving typecheck, coverage, and the build-to-snapshot dependency chain to one coarse 64-core lane reduced the 96-core lane's repository critical path to 14.81 seconds without returning to per-gate shards. +An [exact-head all-size benchmark](https://github.com/deepseek-harness/deepseek-harness/actions/runs/29908491351) ran the complete unsharded primary Node aggregate on every Linux pool before the eager-build correction: -A [documentation-head repeat](https://github.com/deepseek-harness/deepseek-harness/actions/runs/29903735616) showed that 16 coverage forks still admitted the CJS-lexer crash. The coverage process completed its remaining tests in 23.73 seconds, but the dead worker left one file below threshold and correctly failed the lane. Twelve forks completed the same gate in 26.06 seconds in the final run, keeping the CPU lane below one minute while restoring process headroom. - -One later exact-head run exposed a host-image tax rather than a repository bottleneck: the CPU lane completed its six gates in 28 seconds but took 66 seconds overall because registering the 50 KB Bubblewrap package scanned the runner's 202,507-file package database and consumed 18 seconds. `scripts/prepare-ci-bubblewrap.sh` instead downloads the pinned Ubuntu 24.04 package payload, verifies its archive checksum, extracts it into the ephemeral runner directory, and runs the same functional confinement probe used by the provider. Dependency installation and this sub-second preparation still overlap. This preserves the real Bubblewrap coverage without mutating the hosted image or adding another shard. - -The workflow retains four manual diagnostics. `suite=larger-runner-benchmark` compares isolated critical lanes across every size, `suite=consolidated-runner-benchmark` compares whole aggregates, `suite=sharded-reference` preserves the former production shard topology, and `suite=serial-reference` remains the unsharded cross-platform completeness oracle. `suite=optimized-larger-runners` runs the exact production topology against a branch ref when a pull request cannot form a merge commit. - -The first [twelve-size critical-lane benchmark](https://github.com/deepseek-harness/deepseek-harness/actions/runs/29895295659) used a workflow-only commit on top of the standard-runner [baseline](https://github.com/deepseek-harness/deepseek-harness/actions/runs/29850033610), so the code, lockfile, and commands were identical: - -| Critical job | Standard | 4 cores | 8 cores | 16 cores | 32 cores | 64 cores | 96 cores | -|---|---:|---:|---:|---:|---:|---:|---:| -| Linux typecheck | 56 s | 38 s | 35 s | 40 s | 35 s | 44 s | 40 s | -| Windows production site | 160 s | 117 s | 103 s | 113 s | 75 s | 105 s | 108 s | - -Those isolated results showed that setup dominated but did not identify the production size. A [whole-aggregate benchmark without native ESLint concurrency](https://github.com/deepseek-harness/deepseek-harness/actions/runs/29897826082) found a 69-second single-threaded Linux lint gate. After enabling native Linux ESLint concurrency, the [second whole-aggregate benchmark](https://github.com/deepseek-harness/deepseek-harness/actions/runs/29898331705) produced these active job times: - -| Job | 4 cores | 8 cores | 16 cores | 32 cores | 64 cores | 96 cores | +| Complete Linux primary | 4 cores | 8 cores | 16 cores | 32 cores | 64 cores | 96 cores | |---|---:|---:|---:|---:|---:|---:| -| Linux complete primary | 147 s | 104 s | 95 s | failed at 57 s | 66 s | 60 s | -| Windows blocking builds | 137 s | 127 s | 113 s | 107 s | 105 s | 131 s | +| Active time | 243 s | 144 s | 103 s | 87 s | 62 s | 65 s | -The Linux 32-core failure was the first CJS-lexer worker crash. The 96-core aggregate was the only successful all-size result at the one-minute boundary. Although Windows repository work gained little above 16 cores, the 32-core pool can start the complete outer inventory together and, more importantly, removes an entire paid setup from production. +The 96-core trace spent 39.14 seconds in repository gates. Typecheck occupied 25.71 seconds, then a scheduler dependency delayed the 2.13-second build and 11.29-second snapshot replay until it finished. The same run already proved build and typecheck independently, and the former CPU lane ran them concurrently. Removing that dependency makes lint at 33.30 seconds the measured critical gate while preserving dependencies only for consumers of build output. The 64-core trace exposed the same idle chain: typecheck, build, and snapshot consumed 44.85 seconds in sequence while its independent lint and documentation builds finished in 36.83 and 36.15 seconds. More cores therefore become useful only after the repository scheduler can feed them. -The exact [all-pool validation run](https://github.com/deepseek-harness/deepseek-harness/actions/runs/29905362252) passed every job at the tested branch head: +The same benchmark measured the required Windows build surfaces across every provisioned size: -| Production job (pool) | Active time | Repository work | Result | -|---|---:|---:|---:| -| Node 22.19 compatibility (Linux 4) | 26 s | compatibility smokes | passed | -| Python 3.10 (Linux 8) | 23 s | complete keyless SDK suite | passed | -| Production site (Linux 16) | 48 s | VitePress in 25 s | passed | -| Node 26 compatibility (Linux 32) | 28 s | compatibility smokes | passed | -| Primary CPU (Linux 64) | 46 s | 6 gates in 25.74 s | passed | -| Primary core (Linux 96) | 42 s | 36 gates in 15.13 s | passed | -| Windows complete (Windows 32) | 137 s | 37 gates in 37.74 s | passed | +| Windows blocking builds | 4 cores | 8 cores | 16 cores | 32 cores | 64 cores | 96 cores | +|---|---:|---:|---:|---:|---:|---:| +| Active time | 152 s | 104 s | 104 s | 92 s | 103 s | 110 s | -All seven paid jobs began within one second. The slowest non-Windows job finished in 48 seconds. The Windows job spent 25 seconds checking out, 22 seconds enabling Developer Mode, 19 seconds restoring its pnpm cache, and 16 seconds installing dependencies, so its 137-second active time measures hosted setup variance as well as repository work. Every non-Windows job stays below one minute and the sole Windows job stays below three minutes. +Repository work gains little above 16 Windows cores, but the 32-core pool can start the complete outer inventory together. A [retargeted production validation](https://github.com/deepseek-harness/deepseek-harness/actions/runs/29907581119/attempts/2) completed the full one-box Windows inventory in 173 seconds, including coverage and snapshot replay, so Windows remains consolidated. + +Two host effects remain part of the decision. A standard Node 26 job once spent 36 of its 67 seconds in `Set up job`, which is why environment contracts use distinct larger-runner pools instead of standard capacity. A Linux candidate spent 18 seconds registering a 50 KB Bubblewrap package because the hosted image scanned 202,507 package-database files. [`scripts/prepare-ci-bubblewrap.sh`](../../../../scripts/prepare-ci-bubblewrap.sh) instead verifies and extracts the pinned payload into the ephemeral runner directory, runs a functional confinement probe, and overlaps that preparation with dependency installation. + +Coverage remains capped at 12 forks. Thirty-two forks crashed Node 24's CJS lexer twice, and a later 16-fork run reproduced the worker loss and invalid coverage result. Twelve forks preserve process headroom without becoming the single-host critical path. + +The workflow retains three manual measurement suites. `suite=larger-runner-benchmark` compares isolated critical lanes across every size, `suite=consolidated-runner-benchmark` compares whole aggregates, and `suite=serial-reference` remains the unsharded cross-platform completeness oracle. `suite=optimized-larger-runners` dispatches the exact production topology against a branch ref when a pull request cannot form a merge commit. ## Alternatives considered -**Keep the former shard topology in production.** The shards can be fast when provisioned together, but 49 larger-runner jobs repeat setup and create more chances for a cold outlier. The 231-second Windows control demonstrated that a short lint shard does not protect the end-to-end job target. +**Keep the three coarse primary Linux lanes.** The core, CPU, and production-site jobs met the latency targets, but they paid three setup waves and left primary Node work sharded after larger runners were available. The all-size trace showed that one unnecessary dependency, not a lack of host capacity, kept the single-box aggregate above one minute. -**Select a production size from the critical-lane benchmark.** Four cores looked cost-effective for isolated typecheck and site builds, but the full aggregate found repository-wide lint and dependent artifact work that those commands did not represent. +**Keep the former gate-level shard topology as a manual reference.** A dormant second topology kept hundreds of workflow lines, selector modules, and scenario-partition behavior alive. The all-size and serial suites provide timing and completeness controls without preserving production code that no required job exercises. -**Run the production site inside the Linux core aggregate.** This reached 50 seconds with warm hosted setup, then crossed the threshold at 64 seconds when the site gate took 29.05 seconds. One coarse independent site job protects the target without returning to gate-level sharding. +**Use the 64-core pool for the complete primary aggregate.** Its sampled active time was three seconds lower than the 96-core result because hosted setup was nine seconds faster, but its repository gates were 5.72 seconds slower. Production uses 96 cores for the shorter controllable critical path; the benchmark suite retains both pools so a sustained image or pricing change can reverse that choice with evidence. -**Keep every primary gate on the 96-core Linux runner.** Repository work completed in 26 seconds, but a 42-second cold path still pushed the job to 68 seconds. The 64-core CPU lane owns the three longest independent or dependency-critical paths; the remaining 36-gate core inventory completes its repository work in 14.81 seconds. +**Keep build behind typecheck.** This orders independent compiler invocations and turns snapshot replay into a three-stage critical chain. Build output has its own success dependency, so only snapshot and publication consumers wait for it. -**Keep required and observational Windows checks in separate jobs.** The split preserved status semantics at the workflow level but paid setup twice, and a cold cache pushed the required job to 266 seconds. `run-gates` now preserves those semantics inside one process: build and production site are required, while the remaining inventory is explicitly non-blocking. +**Keep compatibility and Python on standard runners.** Warm standard runs can fit, but runner setup alone has crossed the non-Windows target. Distinct larger pools isolate these environment contracts from that allocation lottery. -**Prebuild before starting the Linux aggregate.** This moved build onto the setup path and produced a 66-second candidate. Starting build eagerly inside `run-gates` preserves artifact dependencies while overlapping it with unrelated checks. +**Keep required and observational Windows checks in separate jobs.** The split preserves status semantics at the workflow level but pays setup twice. `run-gates` preserves the same required versus non-blocking distinction inside one process. -**Use native ESLint worker concurrency on Windows.** Sixteen workers made lint more than five times slower than the final single-threaded result. Outer gate parallelism uses the 32-core runner without multiplying ESLint's Windows worker startup and TypeScript project loading. - -**Keep compatibility and Python on standard runners.** Warm runs completed in 43 seconds or less, but one Node 26 job later spent 36 seconds in GitHub setup and crossed the target despite only five seconds of repository work. Distinct larger pools stabilize those environment contracts; the three-second final aggregator remains on a standard runner because it begins only after the paid jobs release capacity. +**Install Bubblewrap through the system package manager.** This uses the host's package database and can dominate the job even when the payload is tiny. Pinned extraction plus a confinement probe preserves the runtime contract without mutating the hosted image. ## Consequences -The all-pool validation consumed one billed minute at each Linux size and three billed 32-core Windows minutes. At the configured larger-runner rates, its larger-runner cost was $1.058. A Windows run below 120 seconds costs $0.896 instead; both shapes remain inside the three-minute target. The all-size critical benchmark cost $2.936. GitHub rounds each larger-runner job up to a whole minute, so reducing paid job count from 49 to seven matters as much as shortening repository work. +Primary Node CI has one job, one setup wave, one complete gate inventory, and no shard selectors. Together with two Node compatibility executions, Python, and Windows, production has five paid larger-runner executions instead of seven coarse-lane executions or 49 gate-level executions. -The existing zero-dollar Actions budget did not block larger-runner jobs. The repo-only runner group, bounded workflow topology, manual benchmark triggers, and job timeouts are the observed cost controls; the budget is not treated as an execution guard. +GitHub rounds each larger-runner execution up to a whole minute, so eliminating setup waves reduces billed time as well as workflow complexity. The final aggregator remains on a standard runner because it begins only after the paid jobs release capacity. -Production CI depends on the organization-owned runner names in this note and in `.github/workflows/ci.yml`. Missing or renamed pools leave jobs queued instead of falling back to standard capacity. Manual all-size, consolidated, former-shard, and serial suites remain available so image, dependency, scheduler, or pricing changes can be remeasured before changing production labels. +The current targets are observed performance contracts, not cancellation deadlines. Exact-head production runs must show every non-Windows job below one minute and the consolidated Windows job below three minutes; manual all-size and serial suites remain available when image, dependency, scheduler, or pricing changes need remeasurement. + +Production CI depends on the organization-owned runner labels in [`.github/workflows/ci.yml`](../../../../.github/workflows/ci.yml). Missing or renamed pools leave jobs queued instead of falling back to standard capacity. All twelve pools remain provisioned so the manual benchmarks can re-evaluate the production size without an administrative setup cycle. diff --git a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.zh.md b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.zh.md index 1335fc66bc..ffaf958560 100644 --- a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.zh.md +++ b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.zh.md @@ -14,76 +14,58 @@ Status: implemented 组织在仅限本仓库使用的 `dsh-larger-ci` 运行器组中保留 12 个 x64 大型运行器池:Ubuntu 24.04 和 Windows 2025 各设 4、8、16、32、64、96 核规格。公网 IP 已禁用。每个池的自动扩缩容上限为 256;该上限既不会分配闲置机器,也不能免除限制工作流需求的必要性。 -生产 CI 对 6 种 Linux 池规格各使用一次,另使用一个 32 核 Windows 池,只有最终聚合作业继续使用标准运行器。版本和语言作业属于环境契约,并非从同一门禁清单切出的分片;主 Node 工作采用 3 个粗粒度通道,而非门禁级分片矩阵: +生产 CI 包含 5 次大型运行器执行和 1 个标准运行器聚合作业。主 Node 门禁清单不再分片: -- `node 24 / core` 使用 96 核 Linux 池。只需执行一次代码检出、设置、缓存恢复和安装,即可供 36 项未分片的静态、lint、文档、hygiene、构建和产物门禁使用。`run-gates` 最多同时启动 32 项相互独立的门禁,ESLint 使用 32 个工作线程。构建会尽早启动;其产物消费方仍会等待生成的输出。 -- `node 24 / cpu` 使用 64 核 Linux 池运行 6 项 CPU 或依赖关键门禁:类型检查、覆盖率、构建后回放快照,以及两项 Node 24 兼容性冒烟测试。覆盖率最多使用 12 个工作进程,快照最多使用 16 个。该通道单独构建,使快照回放使用同一通道生成的输出。覆盖率运行固定使用 12 个 fork,因为 32 个 fork 曾两次导致 Node 24 的 CJS 词法分析器崩溃,而后续一次使用 16 个 fork 的运行又复现了相同的工作进程丢失,并使覆盖率结果无效。 -- `node 24 / production site` 使用 16 核 Linux 池运行耗时最长的独立主门禁。这是一项粗粒度拆分,而非分片矩阵:该作业执行一次设置和一次生产版 VitePress 构建。 -- Node 22.19 兼容性、Python 3.10 和 Node 26 兼容性分别使用 4 核、8 核和 32 核 Linux 池。不同标签既避免了标准运行器设置耗时的异常值,也避免了两个作业共享一个池时曾出现的第二次分配延迟。 -- `windows node 24 / complete` 使用 32 核 Windows 池。一次设置供必需的包构建、必需的生产网站构建以及完整的观测性可移植性清单共用。外层调度器设有 32 个槽位。任何必需项失败都会使作业失败;观测项失败会明确打印为非阻塞,并保留其原有的建议性状态。ESLint 本身仍采用单线程,因为启用 16 个 ESLint 工作线程会把完整 lint 的耗时增加至 174.54 秒;外层门禁并发能够利用该运行器,同时不会成倍增加 Windows 工作线程的启动和 TypeScript 项目加载开销。 +- `node 24 / complete` 使用一台 96 核 Linux 运行器。只需执行一次代码检出、设置、缓存恢复和安装,即可供全部 42 项主门禁使用。`run-gates` 最多同时启动 32 项相互独立的门禁;ESLint 使用 32 个工作线程,覆盖率最多使用 12 个,快照回放最多使用 16 个。构建会立即与类型检查、覆盖率、lint 和文档工作同时启动,而快照回放和发布消费方仍显式依赖生成的 `lib/` 输出。 +- Node 22.19 和 Node 26 分别使用 4 核和 32 核 Linux 池运行各自的运行时兼容性冒烟测试。Python 3.10 使用 8 核 Linux 池运行完整的无密钥 SDK 套件。这些作业属于环境契约,并非主 Node 门禁清单的分片。 +- `windows node 24 / complete` 使用一台 32 核 Windows 运行器。一次设置供必需的包构建、必需的生产网站构建以及完整的观测性可移植性清单共用。任何必需项失败都会使作业失败;观测项失败则报告为非阻塞。ESLint 保持单线程,因为 16 个 ESLint 工作线程耗时 174.54 秒,外层调度器则保留 32 个槽位。 -Windows 结构由两项冷路径观测结果确定。首个候选方案使用两个 16 核 Windows 作业;尽管已配置自动扩缩容上限,GitHub 仍花费 93 秒才预配好第二台同标签运行器。随后一次[文档分支头验证](https://github.com/deepseek-harness/deepseek-harness/actions/runs/29900502413)中,一个独立的 Windows 阻塞性作业耗时 266 秒,其中恢复 153 MB pnpm 缓存就用了 138 秒。把所有 Windows 工作合并到一台 32 核机器上,消除了这轮重复设置。 +原有的门禁级和粗粒度主流程分片作业已从工作流中移除。相应的静态、lint、覆盖率、快照和场景分片选择器也已从仓库中移除,因此未使用的诊断路径无法继续维系第二套 CI 架构。 -随后两次运行确定了 Linux 的边界。一次[标准运行器验证](https://github.com/deepseek-harness/deepseek-harness/actions/runs/29902209492)中,Node 26 作业耗时 67 秒,而仓库工作仅耗时 5 秒,因为 GitHub 在 `Set up job` 上花费了 36 秒。把各项环境契约迁移到不同的大型运行器池后,这种随机波动随之消失。接下来的[全大型运行器验证](https://github.com/deepseek-harness/deepseek-harness/actions/runs/29902541203)中,96 核主作业耗时 68 秒:仓库工作仍为 26 秒,但设置、缓存、安装和收尾共占用 42 秒。把类型检查、覆盖率以及构建至快照的依赖链迁移到一个粗粒度 64 核通道后,96 核通道的仓库关键路径缩短至 14.81 秒,且无需恢复按门禁分片。 +一次[分支头精确的全规格基准测试](https://github.com/deepseek-harness/deepseek-harness/actions/runs/29908491351)在修正构建尽早启动逻辑前,对每种 Linux 池都运行了完整且未分片的主 Node 聚合流程: -一次[文档分支头重复验证](https://github.com/deepseek-harness/deepseek-harness/actions/runs/29903735616)表明,覆盖率使用 16 个 fork 时仍未能阻止 CJS 词法分析器崩溃。覆盖率进程在 23.73 秒内完成了余下测试,但已终止的工作进程让一个文件的覆盖率低于阈值,因此该通道按预期失败。最终运行使用 12 个 fork,在 26.06 秒内完成同一门禁,既让 CPU 通道保持在 1 分钟以内,也恢复了进程余量。 - -后来一次分支头精确运行暴露的是主机映像的额外开销,而非仓库瓶颈:CPU 通道在 28 秒内完成了 6 项门禁,总耗时却达到 66 秒,因为注册 50 KB 的 Bubblewrap 包时扫描了运行器中记录 202,507 个文件的包数据库,耗时 18 秒。`scripts/prepare-ci-bubblewrap.sh` 改为下载固定版本的 Ubuntu 24.04 包内容,验证归档文件的校验和,将其解压到临时运行器目录,并执行与提供方相同的功能性隔离探针。依赖安装仍与这项不足 1 秒的准备工作重叠执行。这样既保留了对真实 Bubblewrap 的覆盖率,又无需修改托管映像或增加分片。 - -工作流保留 4 项手动诊断。`suite=larger-runner-benchmark` 比较所有规格下相互独立的关键通道,`suite=consolidated-runner-benchmark` 比较完整聚合流程,`suite=sharded-reference` 保留原生产分片拓扑,`suite=serial-reference` 则继续作为未分片的跨平台完整性判定基准。当拉取请求无法生成合并提交时,`suite=optimized-larger-runners` 会直接针对分支引用运行与生产环境完全相同的拓扑。 - -首次[涵盖 12 种规格的关键通道基准测试](https://github.com/deepseek-harness/deepseek-harness/actions/runs/29895295659)以标准运行器[基线](https://github.com/deepseek-harness/deepseek-harness/actions/runs/29850033610)为基础,只叠加了一个仅修改工作流的提交,因此代码、锁文件和命令完全相同: - -| 关键作业 | 标准 | 4 核 | 8 核 | 16 核 | 32 核 | 64 核 | 96 核 | -|---|---:|---:|---:|---:|---:|---:|---:| -| Linux 类型检查 | 56 秒 | 38 秒 | 35 秒 | 40 秒 | 35 秒 | 44 秒 | 40 秒 | -| Windows 生产网站 | 160 秒 | 117 秒 | 103 秒 | 113 秒 | 75 秒 | 105 秒 | 108 秒 | - -这些单项结果表明设置开销占主导地位,却无法确定生产环境应选用的规格。一项[未启用 ESLint 原生并发的完整聚合基准测试](https://github.com/deepseek-harness/deepseek-harness/actions/runs/29897826082)发现,Linux 单线程 lint 门禁耗时 69 秒。启用 Linux ESLint 原生并发后,[第二次完整聚合基准测试](https://github.com/deepseek-harness/deepseek-harness/actions/runs/29898331705)得到了以下作业活动耗时: - -| 作业 | 4 核 | 8 核 | 16 核 | 32 核 | 64 核 | 96 核 | +| Linux 完整主流程 | 4 核 | 8 核 | 16 核 | 32 核 | 64 核 | 96 核 | |---|---:|---:|---:|---:|---:|---:| -| Linux 完整主流程 | 147 秒 | 104 秒 | 95 秒 | 57 秒时失败 | 66 秒 | 60 秒 | -| Windows 阻塞性构建 | 137 秒 | 127 秒 | 113 秒 | 107 秒 | 105 秒 | 131 秒 | +| 活动耗时 | 243 秒 | 144 秒 | 103 秒 | 87 秒 | 62 秒 | 65 秒 | -Linux 32 核作业的失败是首次发生的 CJS 词法分析器工作进程崩溃。在所有规格的结果中,96 核聚合作业是唯一成功达到 1 分钟边界的结果。尽管 Windows 仓库工作在超过 16 核后收益很小,32 核池可以让完整的外层清单同时启动;更重要的是,它能从生产环境中消除整整一次付费设置。 +96 核运行轨迹中的仓库门禁耗时 39.14 秒。类型检查占用 25.71 秒,随后一项调度器依赖使耗时 2.13 秒的构建和耗时 11.29 秒的快照回放都要等到类型检查结束后才启动。同一次运行已经分别证明构建和类型检查可以独立执行,原 CPU 通道也曾让二者并发运行。移除这项依赖后,耗时 33.30 秒的 lint 成为实测关键门禁,而只有构建输出的消费方仍保留依赖关系。64 核运行轨迹暴露了相同的空闲链:类型检查、构建和快照依次执行,共耗时 44.85 秒,而相互独立的 lint 和文档构建分别在 36.83 秒和 36.15 秒内完成。因此,只有仓库调度器能够为更多核心持续提供工作时,增加核心数才有价值。 -精确的[全运行器池验证运行](https://github.com/deepseek-harness/deepseek-harness/actions/runs/29905362252)在受测分支头通过了所有作业: +同一项基准测试还测量了每种已预配规格上的 Windows 必需构建项: -| 生产作业(运行器池) | 活动耗时 | 仓库工作 | 结果 | -|---|---:|---:|---:| -| Node 22.19 兼容性(Linux 4) | 26 秒 | 兼容性冒烟测试 | 通过 | -| Python 3.10(Linux 8) | 23 秒 | 完整无密钥 SDK 套件 | 通过 | -| 生产网站(Linux 16) | 48 秒 | VitePress 耗时 25 秒 | 通过 | -| Node 26 兼容性(Linux 32) | 28 秒 | 兼容性冒烟测试 | 通过 | -| 主流程 CPU(Linux 64) | 46 秒 | 6 项门禁耗时 25.74 秒 | 通过 | -| 主流程核心(Linux 96) | 42 秒 | 36 项门禁耗时 15.13 秒 | 通过 | -| Windows 完整流程(Windows 32) | 137 秒 | 37 项门禁耗时 37.74 秒 | 通过 | +| Windows 阻塞性构建 | 4 核 | 8 核 | 16 核 | 32 核 | 64 核 | 96 核 | +|---|---:|---:|---:|---:|---:|---:| +| 活动耗时 | 152 秒 | 104 秒 | 104 秒 | 92 秒 | 103 秒 | 110 秒 | -7 个付费作业均在 1 秒内开始运行。最慢的非 Windows 作业耗时 48 秒。Windows 作业花费 25 秒检出代码、22 秒启用开发人员模式、19 秒恢复 pnpm 缓存、16 秒安装依赖,因此其 137 秒的活动耗时既反映托管环境的设置波动,也反映仓库工作耗时。每个非 Windows 作业均低于 1 分钟,唯一的 Windows 作业也低于 3 分钟。 +Windows 仓库工作在超过 16 核后收益很小,但 32 核池可以让完整的外层清单同时启动。一次[重新定向的生产验证](https://github.com/deepseek-harness/deepseek-harness/actions/runs/29907581119/attempts/2)在 173 秒内完成了单机 Windows 完整清单,其中包括覆盖率和快照回放,因此 Windows 继续采用合并执行方式。 + +两项主机效应仍构成这项决策的依据。一个标准 Node 26 作业曾在总共 67 秒的耗时中,把 36 秒用在 `Set up job` 上,因此各项环境契约使用不同的大型运行器池,而非标准容量。一个 Linux 候选作业在注册 50 KB 的 Bubblewrap 包时耗时 18 秒,因为托管映像扫描了 202,507 个包数据库文件。[`scripts/prepare-ci-bubblewrap.sh`](../../../../scripts/prepare-ci-bubblewrap.sh) 改为验证固定包内容并将其解压到临时运行器目录,执行功能性隔离探针,并让这项准备工作与依赖安装重叠执行。 + +覆盖率仍限制为 12 个 fork。32 个 fork 曾两次导致 Node 24 的 CJS 词法分析器崩溃,后来一次使用 16 个 fork 的运行又复现了工作进程丢失和无效的覆盖率结果。12 个 fork 能保留进程余量,同时不会成为单机关键路径。 + +工作流保留 3 项手动测量套件。`suite=larger-runner-benchmark` 比较所有规格下相互独立的关键通道,`suite=consolidated-runner-benchmark` 比较完整聚合流程,`suite=serial-reference` 则继续作为未分片的跨平台完整性判定基准。当拉取请求无法生成合并提交时,`suite=optimized-larger-runners` 会直接针对分支引用触发与生产环境完全相同的拓扑。 ## 曾考虑的替代方案 -**在生产环境中保留原分片拓扑。** 各分片在一同完成预配时可以很快,但 49 个大型运行器作业会重复设置,也增加了出现冷启动异常值的机会。耗时 231 秒的 Windows 对照结果表明,短小的 lint 分片并不能保障端到端作业达到时长目标。 +**保留 3 个粗粒度 Linux 主流程通道。** 核心、CPU 和生产网站作业均达到延迟目标,但它们需要 3 轮设置,而且在大型运行器已经可用后仍对主 Node 工作进行分片。全规格运行轨迹表明,让单机聚合流程超过 1 分钟的是一项不必要的依赖,而非主机容量不足。 -**根据关键通道基准测试选择生产规格。** 对单独的类型检查和网站构建而言,4 核看起来具备成本效益,但完整聚合流程发现了这些命令未覆盖的全仓库 lint 和存在依赖关系的产物工作。 +**将原有的门禁级分片拓扑保留为手动参考。** 一套闲置的第二拓扑会让数百行工作流、选择器模块和场景分区行为继续存活。全规格和串行套件无需保留任何必需作业都不执行的生产代码,也能提供计时与完整性对照。 -**在 Linux 核心聚合流程内运行生产网站。** 托管环境设置处于热状态时,此方案用时 50 秒;当网站门禁耗时 29.05 秒时,总耗时增至 64 秒,超过了目标。一项独立的粗粒度网站作业能够保障目标,而无需重新采用门禁级分片。 +**使用 64 核池运行完整主聚合流程。** 由于托管设置快了 9 秒,其采样活动耗时比 96 核结果少 3 秒,但仓库门禁慢了 5.72 秒。生产环境使用 96 核来缩短可控的关键路径;基准测试套件保留两种规格,因此如果映像或定价发生持续性变化,仍可根据证据反转这项选择。 -**将所有主门禁都保留在 96 核 Linux 运行器上。** 仓库工作在 26 秒内完成,但 42 秒的冷路径仍把作业总耗时推高至 68 秒。64 核 CPU 通道负责 3 条耗时最长的独立或依赖关键路径;其余 36 项核心门禁清单的仓库工作耗时为 14.81 秒。 +**让构建继续等待类型检查。** 此方案会给相互独立的编译器调用排定先后顺序,并把快照回放变成 3 阶段关键链。构建输出本身有独立的成功依赖关系,因此只有快照和发布消费方需要等待它。 -**将必需的 Windows 检查和观测性 Windows 检查保留在不同作业中。** 这种拆分在工作流层保留了状态语义,却要支付两次设置开销,而且冷缓存让必需作业的耗时达到 266 秒。`run-gates` 目前在一个进程内保留这些语义:构建和生产网站属于必需项,其余清单则明确为非阻塞。 +**让兼容性和 Python 继续使用标准运行器。** 标准运行器热运行可以达到目标,但仅运行器设置一项就曾超过非 Windows 目标。不同的大型运行器池可以让这些环境契约免受这种分配波动影响。 -**在启动 Linux 聚合流程前预先构建。** 此方案让构建成为设置路径的一部分,并产生了一个耗时 66 秒的候选结果。在 `run-gates` 内尽早启动构建,既能保留产物依赖关系,又能让构建与无关检查重叠执行。 +**将必需的 Windows 检查和观测性 Windows 检查保留在不同作业中。** 这种拆分在工作流层保留状态语义,却需要支付两次设置开销。`run-gates` 在一个进程内保留了相同的必需与非阻塞区别。 -**在 Windows 上使用 ESLint 原生工作线程并发。** 16 个工作线程让 lint 比最终的单线程结果慢 5 倍以上。外层门禁并发能够利用 32 核运行器,同时不会成倍增加 ESLint 在 Windows 上启动工作线程和加载 TypeScript 项目的开销。 - -**让兼容性和 Python 继续使用标准运行器。** 热运行均在 43 秒以内完成,但后来一个 Node 26 作业在 GitHub 设置上花费 36 秒,即使仓库工作仅耗时 5 秒,仍超过了目标。不同的大型运行器池能够稳定这些环境契约;最终聚合作业耗时 3 秒,仍使用标准运行器,因为它只会在付费作业释放容量后启动。 +**通过系统包管理器安装 Bubblewrap。** 此方案会使用主机的包数据库,即使包内容很小,也可能主导整个作业耗时。固定版本的解压方式配合隔离探针,无需修改托管映像即可保留运行时契约。 ## 后果 -全运行器池验证中,每种 Linux 规格各计费 1 分钟,32 核 Windows 计费 3 分钟。按已配置的大型运行器费率计算,其大型运行器成本为 $1.058;如果 Windows 运行低于 120 秒,成本则为 $0.896。两种运行形态都保持在 3 分钟目标以内。全规格关键通道基准测试的成本为 $2.936。GitHub 会把每个大型运行器作业向上取整到整分钟计费,因此把付费作业数从 49 个减少到 7 个,与缩短仓库工作耗时同样重要。 +主 Node CI 只有 1 个作业、1 轮设置、1 份完整门禁清单,而且没有分片选择器。加上 2 次 Node 兼容性执行、Python 和 Windows,生产环境共有 5 次付费大型运行器执行,而非 7 次粗粒度通道执行或 49 次门禁级执行。 -现有的零美元 Actions 预算并未阻止大型运行器作业。仅限本仓库的运行器组、有界的工作流拓扑、手动基准测试触发和作业超时限制才是经实测有效的成本控制机制;该预算不被视为执行防护措施。 +GitHub 会把每次大型运行器执行向上取整到整分钟计费,因此消除设置轮次既能减少计费时长,也能降低工作流复杂度。最终聚合作业仍使用标准运行器,因为它只会在付费作业释放容量后启动。 -生产 CI 依赖本 Agent Note 和 `.github/workflows/ci.yml` 中由组织持有的运行器名称。池缺失或改名会让作业一直排队,不会回退到标准容量。手动全规格、完整聚合、原分片和串行套件均予以保留,以便在映像、依赖、调度器或定价发生变化后重新测量,再调整生产标签。 +当前目标是基于观测得到的性能契约,而非取消截止时间。分支头精确的生产运行必须表明每个非 Windows 作业都低于 1 分钟,合并后的 Windows 作业低于 3 分钟;当映像、依赖、调度器或定价发生变化而需要重新测量时,仍可使用手动全规格和串行套件。 + +生产 CI 依赖 [`.github/workflows/ci.yml`](../../../../.github/workflows/ci.yml) 中由组织持有的运行器标签。池缺失或改名会让作业一直排队,不会回退到标准容量。全部 12 个池均保持已预配状态,因此手动基准测试无需再次经过管理配置周期,就能重新评估生产规格。 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 68a2d902ba..c6bb9fe627 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,7 +15,6 @@ on: - serial-reference - larger-runner-benchmark - consolidated-runner-benchmark - - sharded-reference - optimized-larger-runners concurrency: @@ -29,299 +28,21 @@ env: PRIMARY_NODE_VERSION: '24' jobs: - node-24-sharded: - if: github.event_name == 'workflow_dispatch' && inputs.suite == 'sharded-reference' - runs-on: ${{ startsWith(matrix.lane, 'snapshot-') && 'dsh-ubuntu-24-04-8core' || 'dsh-ubuntu-24-04-4core' }} - name: node 24 / ${{ matrix.lane }} - env: - DSH_GATE_CONCURRENCY: ${{ matrix.gate_concurrency }} - DSH_PUBLINT_CONCURRENCY: ${{ matrix.publint_concurrency }} - DSH_ARTIFACT_SHARD: ${{ matrix.artifact_shard }} - DSH_COVERAGE_MAX_WORKERS: ${{ matrix.coverage_max_workers }} - DSH_COVERAGE_SHARD: ${{ matrix.coverage_shard }} - DSH_LINT_SHARD: ${{ matrix.lint_shard }} - DSH_SNAPSHOT_LANE: ${{ matrix.snapshot_lane }} - DSH_STATIC_SHARD: ${{ matrix.static_shard }} - DSH_SNAPSHOT_MAX_CONCURRENCY: ${{ matrix.snapshot_max_concurrency }} - DSH_SNAPSHOT_PREBUILT: ${{ matrix.snapshot_prebuilt }} - DSH_ESLINT_CACHE: ${{ matrix.eslint_cache }} - strategy: - fail-fast: false - matrix: - include: - - lane: static-foundation-projection - command: pnpm run check:ci:static - gate_concurrency: '4' - static_shard: 'foundation,site-projection' - - lane: static-doc-types - command: pnpm run check:ci:static - gate_concurrency: '1' - static_shard: doc-types - - lane: static-api-contracts - command: pnpm run check:ci:static - gate_concurrency: '4' - static_shard: api-contracts - - lane: static-catalogs - command: pnpm run check:ci:static - gate_concurrency: '4' - static_shard: catalogs - - lane: static-prose - command: pnpm run check:ci:static - gate_concurrency: '4' - static_shard: prose - - lane: static-site-mpa - command: pnpm run check:ci:static - gate_concurrency: '1' - static_shard: site-build - - lane: typecheck - command: pnpm run typecheck - - lane: lint-package-sources-a-c - command: pnpm run check:ci:lint - gate_concurrency: '1' - eslint_cache: '1' - lint_shard: package-sources-a-c - - lane: lint-package-sources-d-m - command: pnpm run check:ci:lint - gate_concurrency: '1' - eslint_cache: '1' - lint_shard: package-sources-d-m - - lane: lint-package-sources-n-s - command: pnpm run check:ci:lint - gate_concurrency: '1' - eslint_cache: '1' - lint_shard: package-sources-n-s - - lane: lint-package-sources-t-z - command: pnpm run check:ci:lint - gate_concurrency: '1' - eslint_cache: '1' - lint_shard: package-sources-t-z - - lane: lint-package-tests-a-c - command: pnpm run check:ci:lint - gate_concurrency: '1' - eslint_cache: '1' - lint_shard: package-tests-a-c - - lane: lint-package-tests-d-m - command: pnpm run check:ci:lint - gate_concurrency: '1' - eslint_cache: '1' - lint_shard: package-tests-d-m - - lane: lint-package-tests-n-s - command: pnpm run check:ci:lint - gate_concurrency: '1' - eslint_cache: '1' - lint_shard: package-tests-n-s - - lane: lint-package-tests-t-z - command: pnpm run check:ci:lint - gate_concurrency: '1' - eslint_cache: '1' - lint_shard: package-tests-t-z - - lane: lint-repository - command: pnpm run check:ci:lint - gate_concurrency: '1' - eslint_cache: '1' - lint_shard: repository - - lane: coverage-core-loop - command: pnpm run check:ci:coverage - gate_concurrency: '1' - coverage_max_workers: '4' - coverage_shard: core-loop - - lane: coverage-state-session - command: pnpm run check:ci:coverage - gate_concurrency: '1' - coverage_max_workers: '4' - coverage_shard: state-session - - lane: coverage-session-title - command: pnpm run check:ci:coverage - gate_concurrency: '1' - coverage_max_workers: '4' - coverage_shard: session-title - - lane: coverage-models - command: pnpm run check:ci:coverage - gate_concurrency: '1' - coverage_max_workers: '4' - coverage_shard: models - - lane: coverage-integrations - command: pnpm run check:ci:coverage - gate_concurrency: '1' - coverage_max_workers: '4' - coverage_shard: integrations - - lane: coverage-sdk-capabilities - command: pnpm run check:ci:coverage - gate_concurrency: '1' - coverage_max_workers: '4' - coverage_shard: sdk-capabilities - - lane: coverage-interfaces - command: pnpm run check:ci:coverage - gate_concurrency: '1' - coverage_max_workers: '4' - coverage_shard: interfaces - - lane: coverage-execution - command: pnpm run check:ci:coverage - gate_concurrency: '1' - coverage_max_workers: '4' - coverage_shard: execution - - lane: coverage-workflow - command: pnpm run check:ci:coverage - gate_concurrency: '1' - coverage_max_workers: '4' - coverage_shard: workflow - - lane: coverage-workflow-worker - command: pnpm run check:ci:coverage - gate_concurrency: '1' - coverage_max_workers: '4' - coverage_shard: workflow-worker - - lane: coverage-delegation - command: pnpm run check:ci:coverage - gate_concurrency: '1' - coverage_max_workers: '4' - coverage_shard: delegation - - lane: coverage-repository - command: pnpm run check:ci:coverage - gate_concurrency: '1' - coverage_max_workers: '4' - coverage_shard: repository - - lane: snapshot-support - command: pnpm run check:ci:snapshot - gate_concurrency: '1' - snapshot_lane: support - snapshot_max_concurrency: '5' - snapshot_prebuilt: '1' - - lane: snapshot-agents - command: pnpm run check:ci:snapshot - gate_concurrency: '1' - snapshot_lane: agents - snapshot_max_concurrency: '5' - snapshot_prebuilt: '1' - - lane: snapshot-acp-1 - command: pnpm run check:ci:snapshot - gate_concurrency: '1' - snapshot_lane: acp-1 - snapshot_max_concurrency: '5' - snapshot_prebuilt: '1' - - lane: snapshot-acp-2 - command: pnpm run check:ci:snapshot - gate_concurrency: '1' - snapshot_lane: acp-2 - snapshot_max_concurrency: '5' - snapshot_prebuilt: '1' - - lane: snapshot-acp-3 - command: pnpm run check:ci:snapshot - gate_concurrency: '1' - snapshot_lane: acp-3 - snapshot_max_concurrency: '5' - snapshot_prebuilt: '1' - - lane: snapshot-acp-4 - command: pnpm run check:ci:snapshot - gate_concurrency: '1' - snapshot_lane: acp-4 - snapshot_max_concurrency: '5' - snapshot_prebuilt: '1' - - lane: snapshot-acp-5 - command: pnpm run check:ci:snapshot - gate_concurrency: '1' - snapshot_lane: acp-5 - snapshot_max_concurrency: '5' - snapshot_prebuilt: '1' - - lane: snapshot-acp-6 - command: pnpm run check:ci:snapshot - gate_concurrency: '1' - snapshot_lane: acp-6 - snapshot_max_concurrency: '5' - snapshot_prebuilt: '1' - - lane: snapshot-acp-7 - command: pnpm run check:ci:snapshot - gate_concurrency: '1' - snapshot_lane: acp-7 - snapshot_max_concurrency: '5' - snapshot_prebuilt: '1' - - lane: snapshot-acp-8 - command: pnpm run check:ci:snapshot - gate_concurrency: '1' - snapshot_lane: acp-8 - snapshot_max_concurrency: '5' - snapshot_prebuilt: '1' - - lane: artifacts-metadata - command: pnpm run check:ci:artifacts - gate_concurrency: '3' - publint_concurrency: '8' - artifact_shard: metadata - - lane: artifacts-smoke - command: pnpm run check:ci:artifacts - gate_concurrency: '1' - artifact_shard: smoke - steps: - - uses: actions/checkout@v6 - - - uses: actions/setup-node@v6 - with: - node-version: ${{ env.PRIMARY_NODE_VERSION }} - - - name: Enable corepack and resolve pnpm store path - id: pnpm-store - run: | - corepack enable - echo "path=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT" - - - uses: actions/cache@v4 - with: - path: ${{ steps.pnpm-store.outputs.path }} - key: ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm-${{ hashFiles('pnpm-lock.yaml') }} - restore-keys: | - ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm- - - - name: Install (immutable) - if: ${{ ! startsWith(matrix.lane, 'snapshot-') }} - run: pnpm install --frozen-lockfile - - # The snapshot lanes REPLAY the sandbox example's recorded scenarios, - # re-executing their bash calls under a real runner. ubuntu-latest has - # no bubblewrap preinstalled and no built Landlock launcher, so without - # this the confined executions fail closed (SANDBOX_UNAVAILABLE). The - # pinned Ubuntu payload is verified and extracted into the ephemeral - # runner instead of paying for a system package transaction. Bubblewrap - # preparation is independent of dependency installation and the build, - # so it runs beside both. - - name: Prepare built snapshot runtime and bubblewrap - if: startsWith(matrix.lane, 'snapshot-') - run: | - pnpm install --frozen-lockfile & - install_pid=$! - bash scripts/prepare-ci-bubblewrap.sh & - sandbox_pid=$! - install_status=0 - wait "$install_pid" || install_status=$? - build_status=0 - if (( install_status == 0 )); then - pnpm run build || build_status=$? - fi - sandbox_status=0 - wait "$sandbox_pid" || sandbox_status=$? - if (( install_status != 0 )); then exit "$install_status"; fi - if (( build_status != 0 )); then exit "$build_status"; fi - exit "$sandbox_status" - - - uses: actions/cache@v4 - if: startsWith(matrix.lane, 'lint-') - with: - path: .cache/eslint - key: ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-eslint-${{ matrix.lint_shard }}-${{ hashFiles('pnpm-lock.yaml', 'eslint.config.mjs', 'tsconfig.json', 'packages/*/*/tsconfig.json', 'examples/*/tsconfig.json') }} - restore-keys: | - ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-eslint-${{ matrix.lint_shard }}- - - - name: Run gates - run: ${{ matrix.command }} # One large runner pays hosted setup once, then the repository scheduler - # overlaps the unsharded core inventory. CPU and site chains use coarse lanes. + # overlaps the complete unsharded primary Node inventory. Build starts eagerly; + # only consumers of emitted output wait for it. node-24: if: github.event_name != 'workflow_dispatch' || inputs.suite == 'optimized-larger-runners' runs-on: dsh-ubuntu-24-04-96core - name: node 24 / core + name: node 24 / complete env: + DSH_COVERAGE_MAX_WORKERS: '12' DSH_ESLINT_CACHE: '1' DSH_ESLINT_CONCURRENCY: '32' DSH_GATE_CONCURRENCY: '32' DSH_PUBLINT_CONCURRENCY: '32' + DSH_SNAPSHOT_MAX_CONCURRENCY: '16' steps: - uses: actions/checkout@v6 @@ -362,53 +83,19 @@ jobs: if (( install_status != 0 )); then exit "$install_status"; fi exit "$sandbox_status" - - name: Run unsharded primary Node core CI concurrently - run: pnpm run check:ci:large-runner + - name: Run complete unsharded primary Node CI concurrently + run: pnpm run check:ci - # Keep the longest independent Linux gate on its own coarse-grained runner so - # cold install variance does not push either primary aggregate over a minute. - node-24-site: - if: github.event_name != 'workflow_dispatch' || inputs.suite == 'optimized-larger-runners' - runs-on: dsh-ubuntu-24-04-16core - name: node 24 / production site - steps: - - uses: actions/checkout@v6 - - - uses: actions/setup-node@v6 - with: - node-version: ${{ env.PRIMARY_NODE_VERSION }} - - - name: Enable corepack and resolve pnpm store path - id: pnpm-store - run: | - corepack enable - echo "path=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT" - - - uses: actions/cache@v4 - with: - path: ${{ steps.pnpm-store.outputs.path }} - key: ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm-${{ hashFiles('pnpm-lock.yaml') }} - restore-keys: | - ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm- - - - name: Install (immutable) - run: pnpm install --frozen-lockfile - - - name: Build documentation site (production SPA) - run: pnpm run docs:build node-compat: if: github.event_name != 'workflow_dispatch' || inputs.suite == 'optimized-larger-runners' # Distinct larger-runner pools avoid both standard-runner setup outliers and - # the delayed second allocation observed when two jobs shared one pool. The - # primary Node row also owns the CPU/dependency-critical primary gate chain. + # delayed allocation when independent environment contracts share one pool. runs-on: ${{ matrix.runner }} name: ${{ matrix.name }} env: - DSH_COVERAGE_MAX_WORKERS: ${{ matrix.coverage_workers }} DSH_GATE_CONCURRENCY: ${{ matrix.gate_concurrency }} - DSH_NODE_COMPAT_SKIP_TYPECHECK: ${{ matrix.skip_typecheck }} - DSH_SNAPSHOT_MAX_CONCURRENCY: ${{ matrix.snapshot_workers }} + DSH_NODE_COMPAT_SKIP_TYPECHECK: '1' strategy: fail-fast: false matrix: @@ -416,24 +103,11 @@ jobs: - node: '22.19' name: node 22.19 runner: dsh-ubuntu-24-04-4core - command: pnpm run check:node-compat gate_concurrency: '2' - skip_typecheck: '1' - - node: 24 - name: node 24 / cpu - runner: dsh-ubuntu-24-04-64core - command: pnpm run check:ci:primary-cpu - coverage_workers: '12' - gate_concurrency: '6' - primary_cpu: true - snapshot_workers: '16' - skip_typecheck: '1' - node: 26 name: node 26 runner: dsh-ubuntu-24-04-32core - command: pnpm run check:node-compat gate_concurrency: '2' - skip_typecheck: '1' steps: - uses: actions/checkout@v6 @@ -455,25 +129,10 @@ jobs: ${{ runner.os }}-node-${{ matrix.node }}-pnpm- - name: Install (immutable) - if: matrix.primary_cpu != true run: pnpm install --frozen-lockfile - - name: Install dependencies and prepare bubblewrap - if: matrix.primary_cpu == true - run: | - pnpm install --frozen-lockfile & - install_pid=$! - bash scripts/prepare-ci-bubblewrap.sh & - sandbox_pid=$! - install_status=0 - wait "$install_pid" || install_status=$? - sandbox_status=0 - wait "$sandbox_pid" || sandbox_status=$? - if (( install_status != 0 )); then exit "$install_status"; fi - exit "$sandbox_status" - - - name: Run assigned gates - run: ${{ matrix.command }} + - name: Run compatibility smokes + run: pnpm run check:node-compat python-sdk: if: github.event_name != 'workflow_dispatch' || inputs.suite == 'optimized-larger-runners' @@ -493,145 +152,6 @@ jobs: - name: Run complete keyless Python suite run: uv run --python 3.10 --group test --project python/sdk pytest - # Manual sharded Windows reference matching the former production topology. - # Blocking build and production-site lanes keep the already-green - # native outputs protected without putting both critical paths in one job. - # The broader observational gate matrix below exposes the remaining - # portability work without blocking mainline merges. - windows-build-sharded: - if: github.event_name == 'workflow_dispatch' && inputs.suite == 'sharded-reference' - runs-on: dsh-windows-2025-4core - name: windows / build - steps: - - uses: actions/checkout@v6 - - - uses: actions/setup-node@v6 - with: - node-version: ${{ env.PRIMARY_NODE_VERSION }} - - - name: Install (immutable) - run: | - corepack enable - pnpm install --frozen-lockfile - - - name: Build (tsc -b + tsdown) - run: pnpm run build - - windows-site-sharded: - if: github.event_name == 'workflow_dispatch' && inputs.suite == 'sharded-reference' - runs-on: dsh-windows-2025-4core - name: windows / production site - steps: - - uses: actions/checkout@v6 - - - uses: actions/setup-node@v6 - with: - node-version: ${{ env.PRIMARY_NODE_VERSION }} - - - name: Install (immutable) - run: | - corepack enable - pnpm install --frozen-lockfile - - - name: Build documentation site (production SPA) - run: pnpm run docs:build - - # Observational, non-blocking Windows static, lint, and artifact lanes. Coverage - # and snapshot stay Linux-only until their platform-specific runtime failures - # have dedicated support. Run the gates from native PowerShell: an MSYS parent - # would change the environment being measured. This job intentionally stays - # out of all-checks-passed.needs. - windows-gates-sharded: - if: github.event_name == 'workflow_dispatch' && inputs.suite == 'sharded-reference' - continue-on-error: true - runs-on: dsh-windows-2025-4core - name: windows node 24 / ${{ matrix.lane }} - env: - DSH_GATE_CONCURRENCY: ${{ matrix.gate_concurrency }} - DSH_PUBLINT_CONCURRENCY: ${{ matrix.publint_concurrency }} - DSH_ARTIFACT_SHARD: ${{ matrix.artifact_shard }} - DSH_LINT_SHARD: ${{ matrix.lint_shard }} - DSH_STATIC_SHARD: ${{ matrix.static_shard }} - DSH_ESLINT_CACHE: ${{ matrix.eslint_cache }} - strategy: - fail-fast: false - matrix: - include: - - lane: static-general - command: pnpm run check:ci:static - gate_concurrency: '4' - static_shard: 'foundation,catalogs,prose' - - lane: static-contracts - command: pnpm run check:ci:static - gate_concurrency: '4' - static_shard: 'doc-types,api-contracts' - - lane: lint-package-sources - command: pnpm run check:ci:lint - gate_concurrency: '1' - eslint_cache: '1' - lint_shard: package-sources - - lane: lint-package-tests - command: pnpm run check:ci:lint - gate_concurrency: '1' - eslint_cache: '1' - lint_shard: package-tests - - lane: lint-repository - command: pnpm run check:ci:lint - gate_concurrency: '1' - eslint_cache: '1' - lint_shard: repository - - lane: artifacts-metadata - command: pnpm run check:ci:artifacts - gate_concurrency: '3' - publint_concurrency: '8' - artifact_shard: metadata - - lane: artifacts-smoke - command: pnpm run check:ci:artifacts - gate_concurrency: '1' - artifact_shard: smoke - steps: - - uses: actions/checkout@v6 - - - name: Enable Developer Mode (symlink support) - shell: pwsh - run: >- - reg add "HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\AppModelUnlock" - /t REG_DWORD /f /v "AllowDevelopmentWithoutDevLicense" /d "1" - - - uses: actions/setup-node@v6 - with: - node-version: ${{ env.PRIMARY_NODE_VERSION }} - - - name: Enable corepack and resolve pnpm store path - id: pnpm-store - shell: pwsh - run: | - corepack enable - "path=$(pnpm store path --silent)" >> $env:GITHUB_OUTPUT - - - uses: actions/cache@v4 - with: - path: ${{ steps.pnpm-store.outputs.path }} - key: ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm-${{ hashFiles('pnpm-lock.yaml') }} - restore-keys: | - ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm- - - - name: Install (immutable) - shell: pwsh - run: pnpm install --frozen-lockfile - - - uses: actions/cache@v4 - if: startsWith(matrix.lane, 'lint-') - with: - path: .cache/eslint - key: ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-eslint-${{ matrix.lint_shard }}-${{ hashFiles('pnpm-lock.yaml', 'eslint.config.mjs', 'tsconfig.json', 'packages/*/*/tsconfig.json', 'examples/*/tsconfig.json') }} - restore-keys: | - ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-eslint-${{ matrix.lint_shard }}- - - - name: Run gates - shell: pwsh - run: ${{ matrix.command }} - # One Windows box shares setup across the required build/site checks and the # complete observational portability inventory. run-gates reports failures # from observational gates without allowing them to fail the required job. @@ -1035,7 +555,7 @@ jobs: all-checks-passed: name: all checks passed runs-on: ubuntu-latest - needs: [node-24, node-24-site, node-compat, python-sdk, windows] + needs: [node-24, node-compat, python-sdk, windows] if: always() && (github.event_name != 'workflow_dispatch' || inputs.suite == 'optimized-larger-runners') steps: - name: Fail if any needed job did not succeed diff --git a/examples/acp-agent/tests/acp.snapshot.ts b/examples/acp-agent/tests/acp.snapshot.ts index 41edde1aae..65df474f97 100644 --- a/examples/acp-agent/tests/acp.snapshot.ts +++ b/examples/acp-agent/tests/acp.snapshot.ts @@ -1,7 +1,6 @@ import { fileURLToPath } from 'node:url' import { dirname, join } from 'node:path' import { defineAcpSnapshotSuite, type Scenario, type SnapshotSuiteOptions } from '@deepseek-ai/dsh-acp-snapshot' -import { snapshotScenarioShardFromEnv } from './snapshot-scenario-shard.ts' /** * The acp-agent example's snapshot suite: the scenario table for @@ -199,12 +198,9 @@ const SCENARIOS: Scenario[] = [ { name: 'fs-escalation-approved', hasModelTurn: true, recorded: true, headerClass: 'sandbox' }, ] -const scenarioShard = snapshotScenarioShardFromEnv(process.env.DSH_SNAPSHOT_SCENARIO_SHARD) - defineAcpSnapshotSuite({ agent: AGENT, snapshotsDir: join(dirname(fileURLToPath(import.meta.url)), 'snapshots'), scenarios: SCENARIOS, mode: snapshotModeFromEnv(process.env.DSH_SNAPSHOT), - ...scenarioShard === undefined ? {} : { scenarioShard }, }) diff --git a/examples/acp-agent/tests/snapshot-scenario-shard.spec.ts b/examples/acp-agent/tests/snapshot-scenario-shard.spec.ts deleted file mode 100644 index 5a70cfe3b1..0000000000 --- a/examples/acp-agent/tests/snapshot-scenario-shard.spec.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { snapshotScenarioShardFromEnv } from './snapshot-scenario-shard.ts' - -describe('ACP snapshot scenario shard environment', () => { - it('keeps ordinary snapshot runs complete', () => { - expect(snapshotScenarioShardFromEnv()).toBeUndefined() - expect(snapshotScenarioShardFromEnv('')).toBeUndefined() - }) - - it('parses a valid one-based shard', () => { - expect(snapshotScenarioShardFromEnv('2/4')).toEqual({ index: 2, total: 4 }) - }) - - it.each(['0/1', '1/0', '2/1', '1.5/2', 'missing', '999999999999999999999/999999999999999999999'])( - 'rejects %s', - (value) => { - expect(() => snapshotScenarioShardFromEnv(value)).toThrow('DSH_SNAPSHOT_SCENARIO_SHARD') - }, - ) -}) diff --git a/examples/acp-agent/tests/snapshot-scenario-shard.ts b/examples/acp-agent/tests/snapshot-scenario-shard.ts deleted file mode 100644 index 4e2763bdb6..0000000000 --- a/examples/acp-agent/tests/snapshot-scenario-shard.ts +++ /dev/null @@ -1,18 +0,0 @@ -import type { SnapshotScenarioShard } from '@deepseek-ai/dsh-acp-snapshot' - -/** - * Parse the optional CI scenario shard passed to this snapshot suite. - * - * @param value An `INDEX/TOTAL` string or an unset value. - * @returns A validated one-based shard, or undefined for the complete suite. - */ -export function snapshotScenarioShardFromEnv(value?: string): SnapshotScenarioShard | undefined { - if (value === undefined || value === '') return undefined - const match = /^([1-9]\d*)\/([1-9]\d*)$/.exec(value) - if (match === null) throw new Error(`DSH_SNAPSHOT_SCENARIO_SHARD must be INDEX/TOTAL, got ${JSON.stringify(value)}`) - const shard = { index: Number(match[1]), total: Number(match[2]) } - if (!Number.isSafeInteger(shard.index) || !Number.isSafeInteger(shard.total) || shard.index > shard.total) { - throw new Error(`DSH_SNAPSHOT_SCENARIO_SHARD is out of range: ${JSON.stringify(value)}`) - } - return shard -} diff --git a/package.json b/package.json index 69d22d7ef1..e6daf9b139 100644 --- a/package.json +++ b/package.json @@ -26,8 +26,6 @@ "test:snapshot:record": "DSH_SNAPSHOT=record vitest run --config vitest.snapshot.config.ts --update", "test:snapshot:refresh": "DSH_SNAPSHOT=refresh vitest run --config vitest.snapshot.config.ts", "check:ci": "tsx scripts/run-gates.ts ci-primary", - "check:ci:primary-cpu": "tsx scripts/run-gates.ts ci-primary-cpu", - "check:ci:large-runner": "tsx scripts/run-gates.ts ci-primary-large-runner", "check:ci:static": "tsx scripts/run-gates.ts ci-static", "check:ci:lint": "tsx scripts/run-gates.ts ci-lint", "check:ci:coverage": "tsx scripts/run-gates.ts ci-coverage", diff --git a/packages/support/acp-snapshot/README.md b/packages/support/acp-snapshot/README.md index 0875e0fb86..6e37534e73 100644 --- a/packages/support/acp-snapshot/README.md +++ b/packages/support/acp-snapshot/README.md @@ -7,7 +7,7 @@ Four layers, importable separately: - **`launchAcpTestAgent` (launcher)** — boots a source agent under tsx or a built `lib` agent under plain Node from a temp cwd, connects the SDK client over a raw-byte stdout tee, collects session updates and stderr, surfaces asynchronous spawn failures through startup, fails closed on unhandled permission requests, and owns graceful or signalled shutdown. Shutdown waits for process exit, inherited stdio closure, and ACP parser exhaustion before resolving or propagating a child error, so captures are complete and callers can remove owned paths after either outcome. When Windows accepts forced termination but publishes its exit marker asynchronously, shutdown gives that marker a bounded grace before treating fallback refusal as a second failure. Snapshot and ordinary e2e suites share this process boundary; a test supplies only agent paths, cwd, environment overrides, and any permission policy. - **`runScenario` (harness)** — drives ACP JSON-RPC stdio from a deterministic `input.json` script through the launcher, tees raw stdout for the expected-output and purity checks, and harvests every persisted raw JSONL session log (parent and subagent children, primary-first) after graceful stdin EOF. `AgentUnderTest` supplies absolute `binScript`, optional `libBinScript`, `configPath`, and `tsconfigPath` paths because the subprocess cwd is outside the repo. Startup failures preserve captured agent stderr in the rejected diagnostic. - **Normalizers** — pure functions turning the two captured surfaces into stable text: `normalizeStdout` (JSON-RPC ids → first-seen sequence; UUIDs/cwd → tokens; cwd-rooted separators selected as canonical `/` or host-native; `session_info_update.updatedAt` → `{{updatedAt}}`; doubles as the stdout-purity check), `normalizeSessionLog` (times zeroed, `seq` kept, the same cwd-path policy), `scrubSystemPrompts` (prompt text → `{{system}}`), `scrubToolSchemas` (schema bulk → `{{tools}}`), and `scrubRequestHeaders` (all header bulk → `{{system}}`/`{{tools}}`/`{{messagePrefix}}` outside each pin, structure kept — [pinned-header Agent Note](../../../.agents/notes/implemented/testing/2026-07-06-pin-request-header-content-in-one-scenario.md)). -- **`defineAcpSnapshotSuite` (factory)** — registers the whole describe/it tree for a scenario table: per-scenario expected-output and re-persisted-log comparisons, record/refresh fixture write-back, rejection of structured `UNKNOWN_TOOL` results, the per-header-class pin (`system-prompt.expected.md` plus `tool-schemas.expected.json`) with its live uniformity guard, and the fixture guard block (no orphan scenario dirs, required files present, exactly one pin per class, every JSONL prompt/schema-scrubbed, non-pinning fixtures fully header-scrubbed). Replay may partition subprocess-backed scenarios with `scenarioShard`; every lane still runs fixture guards against the complete table, while record and refresh reject sharding because they write fixtures. Refresh preserves existing volatile fields by event position and gives a newly inserted `session/title` its preceding event's time, so feature-driven insertions do not churn the remainder of a fixture. Each scenario directory's `session.jsonl` plus contiguous `session.<n>.jsonl` siblings are the ordered primary/child inventory; the scenario table does not duplicate their count. Must be called at vitest collection time. +- **`defineAcpSnapshotSuite` (factory)** — registers the whole describe/it tree for a scenario table: per-scenario expected-output and re-persisted-log comparisons, record/refresh fixture write-back, rejection of structured `UNKNOWN_TOOL` results, the per-header-class pin (`system-prompt.expected.md` plus `tool-schemas.expected.json`) with its live uniformity guard, and the fixture guard block (no orphan scenario dirs, required files present, exactly one pin per class, every JSONL prompt/schema-scrubbed, non-pinning fixtures fully header-scrubbed). Refresh preserves existing volatile fields by event position and gives a newly inserted `session/title` its preceding event's time, so feature-driven insertions do not churn the remainder of a fixture. Each scenario directory's `session.jsonl` plus contiguous `session.<n>.jsonl` siblings are the ordered primary/child inventory; the scenario table does not duplicate their count. Must be called at vitest collection time. A consuming `*.snapshot.ts` is the scenario table plus one factory call: diff --git a/packages/support/acp-snapshot/src/index.ts b/packages/support/acp-snapshot/src/index.ts index c67721ecdd..2a03947fef 100644 --- a/packages/support/acp-snapshot/src/index.ts +++ b/packages/support/acp-snapshot/src/index.ts @@ -48,4 +48,3 @@ export { type Scenario, type SnapshotSuiteOptions, } from './suite.ts' -export type { SnapshotScenarioShard } from './scenario-shard.ts' diff --git a/packages/support/acp-snapshot/src/scenario-shard.ts b/packages/support/acp-snapshot/src/scenario-shard.ts deleted file mode 100644 index 67b2492b80..0000000000 --- a/packages/support/acp-snapshot/src/scenario-shard.ts +++ /dev/null @@ -1,36 +0,0 @@ -/** Scenario-level sharding for one ACP snapshot suite. */ - -/** A one-based, exhaustive partition of a scenario table. */ -export interface SnapshotScenarioShard { - /** One-based lane index. */ - index: number - /** Total number of lanes. */ - total: number -} - -/** - * Select one stable modulo partition while rejecting empty or malformed lanes. - * - * @param scenarios Complete ordered scenario table. - * @param shard Optional one-based shard description. - * @returns The complete table or the selected non-empty partition. - */ -export function selectSnapshotScenarios<T>( - scenarios: readonly T[], - shard?: SnapshotScenarioShard, -): T[] { - if (shard === undefined) return [...scenarios] - if (!Number.isSafeInteger(shard.index) || shard.index < 1) { - throw new Error(`acp-snapshot: shard index must be a positive integer, got ${shard.index}`) - } - if (!Number.isSafeInteger(shard.total) || shard.total < 1) { - throw new Error(`acp-snapshot: shard total must be a positive integer, got ${shard.total}`) - } - if (shard.index > shard.total) { - throw new Error(`acp-snapshot: shard index ${shard.index} exceeds total ${shard.total}`) - } - if (shard.total > scenarios.length) { - throw new Error(`acp-snapshot: ${shard.total} shards exceed ${scenarios.length} scenarios`) - } - return scenarios.filter((_, offset) => offset % shard.total === shard.index - 1) -} diff --git a/packages/support/acp-snapshot/src/suite.ts b/packages/support/acp-snapshot/src/suite.ts index 708fc15134..ab6008913c 100644 --- a/packages/support/acp-snapshot/src/suite.ts +++ b/packages/support/acp-snapshot/src/suite.ts @@ -29,7 +29,6 @@ import { scrubSystemPrompts, scrubToolSchemas, } from './normalize.ts' -import { selectSnapshotScenarios, type SnapshotScenarioShard } from './scenario-shard.ts' /** The readable system-prompt snapshot beside each header-pinning fixture. */ const SYSTEM_PROMPT_SNAPSHOT = 'system-prompt.expected.md' @@ -170,11 +169,6 @@ export interface SnapshotSuiteOptions { snapshotsDir: string /** The scenario table; exactly one entry per header class must set `pinsHeader`. */ scenarios: Scenario[] - /** - * Optional replay-only scenario partition. Fixture guards still validate the - * complete table in every lane; only subprocess-backed scenario tests split. - */ - scenarioShard?: SnapshotScenarioShard /** * `replay` (keyless, the default tier), `record` (live API; re-records the * `recorded` scenarios' fixtures and refreshes the Vitest expected outputs under @@ -517,11 +511,7 @@ export function stabilizeRefreshLog(fresh: string, existing: string, replacement * @param options The agent, snapshots directory, scenario table, and mode. */ export function defineAcpSnapshotSuite(options: SnapshotSuiteOptions): void { - const { agent, snapshotsDir, scenarios, mode, scenarioShard } = options - if (scenarioShard !== undefined && mode !== 'replay') { - throw new Error('acp-snapshot: scenario sharding is supported only in replay mode') - } - const selectedScenarios = selectSnapshotScenarios(scenarios, scenarioShard) + const { agent, snapshotsDir, scenarios, mode } = options const RECORDING = mode === 'record' const REFRESHING = mode === 'refresh' const childMode: 'replay' | 'record' = RECORDING ? 'record' : 'replay' @@ -546,7 +536,7 @@ export function defineAcpSnapshotSuite(options: SnapshotSuiteOptions): void { } scenarioSuite('snapshot scenarios', () => { - for (const scenario of selectedScenarios) { + for (const scenario of scenarios) { // In RECORD mode, only re-run the `recorded` (live-API) scenarios; the `authored` ones // (sidecar-driven errors/cancel) are never re-recorded. `posixOnly` scenarios skip on // Windows, where their process semantics cannot be driven. diff --git a/packages/support/acp-snapshot/tests/scenario-shard.spec.ts b/packages/support/acp-snapshot/tests/scenario-shard.spec.ts deleted file mode 100644 index eb5c117153..0000000000 --- a/packages/support/acp-snapshot/tests/scenario-shard.spec.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { selectSnapshotScenarios } from '../src/scenario-shard.ts' - -describe('ACP snapshot scenario shards', () => { - it('keeps the ordinary suite complete', () => { - expect(selectSnapshotScenarios(['a', 'b', 'c'])).toEqual(['a', 'b', 'c']) - }) - - it('partitions the ordered table without gaps or overlap', () => { - const scenarios = ['a', 'b', 'c', 'd', 'e'] - expect(selectSnapshotScenarios(scenarios, { index: 1, total: 2 })).toEqual(['a', 'c', 'e']) - expect(selectSnapshotScenarios(scenarios, { index: 2, total: 2 })).toEqual(['b', 'd']) - }) - - it.each([ - [{ index: 0, total: 1 }, 'index must be a positive integer'], - [{ index: 1.5, total: 2 }, 'index must be a positive integer'], - [{ index: 1, total: 0 }, 'total must be a positive integer'], - [{ index: 1, total: Number.NaN }, 'total must be a positive integer'], - [{ index: 3, total: 2 }, 'exceeds total'], - [{ index: 1, total: 4 }, 'exceed 3 scenarios'], - ] as const)('rejects malformed shard %#', (shard, message) => { - expect(() => selectSnapshotScenarios(['a', 'b', 'c'], shard)).toThrow(message) - }) -}) diff --git a/packages/support/acp-snapshot/tests/suite.spec.ts b/packages/support/acp-snapshot/tests/suite.spec.ts index 3fdcc85423..b80b5a50d2 100644 --- a/packages/support/acp-snapshot/tests/suite.spec.ts +++ b/packages/support/acp-snapshot/tests/suite.spec.ts @@ -107,16 +107,6 @@ describe('defineAcpSnapshotSuite: replay mode', () => { defineAcpSnapshotSuite({ agent: AGENT, snapshotsDir: REPLAY_DIR, scenarios: REPLAY_SCENARIOS, mode: 'replay' }) }) -describe('defineAcpSnapshotSuite: sharded replay mode', () => { - defineAcpSnapshotSuite({ - agent: AGENT, - snapshotsDir: REPLAY_DIR, - scenarios: REPLAY_SCENARIOS, - mode: 'replay', - scenarioShard: { index: 2, total: 2 }, - }) -}) - // The record suite's tests run in registration order: rec-pin re-records the // pinned fixture FIRST, so rec-child's uniformity guard reads the fresh pin. describe('defineAcpSnapshotSuite: record mode', () => { @@ -166,18 +156,6 @@ describe('defineAcpSnapshotSuite: record inventory write-back', () => { }) describe('defineAcpSnapshotSuite: registration contract', () => { - it('rejects scenario sharding in a fixture-writing mode', () => { - expect(() => { - defineAcpSnapshotSuite({ - agent: AGENT, - snapshotsDir: REPLAY_DIR, - scenarios: REPLAY_SCENARIOS, - mode: 'refresh', - scenarioShard: { index: 1, total: 2 }, - }) - }).toThrow('supported only in replay mode') - }) - it("throws when a scenario's header class has no pinning scenario", () => { expect(() => { defineAcpSnapshotSuite({ diff --git a/scripts/coverage-shards.spec.ts b/scripts/coverage-shards.spec.ts deleted file mode 100644 index 678011618e..0000000000 --- a/scripts/coverage-shards.spec.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { readdirSync } from 'node:fs' -import { resolve } from 'node:path' -import { describe, expect, it } from 'vitest' -import { coverageArgs, coverageShards } from './coverage-shards.ts' - -const repositoryRoot = resolve(import.meta.dirname, '..') - -describe('coverage shards', () => { - it('assigns every workspace package to exactly one lane', () => { - const packagesRoot = resolve(repositoryRoot, 'packages') - const workspacePackages = readdirSync(packagesRoot, { withFileTypes: true }) - .filter(group => group.isDirectory()) - .flatMap(group => readdirSync(resolve(packagesRoot, group.name), { withFileTypes: true }) - .filter(entry => entry.isDirectory()) - .map(entry => `${group.name}/${entry.name}`)) - .sort() - const assignedPackages = coverageShards.flatMap(shard => shard.packageRoots.flatMap((packageRoot) => { - if (packageRoot.includes('/')) return [packageRoot] - return readdirSync(resolve(packagesRoot, packageRoot), { withFileTypes: true }) - .filter(entry => entry.isDirectory()) - .map(entry => `${packageRoot}/${entry.name}`) - })) - - expect([...assignedPackages].sort()).toEqual(workspacePackages) - expect(new Set(assignedPackages).size).toBe(assignedPackages.length) - }) - - it.each(coverageShards)('selects tests and source includes for $name', (shard) => { - const args = coverageArgs(shard.name) - for (const packageRoot of shard.packageRoots) { - expect(args).toContain(`packages/${packageRoot}/`) - expect(args).toContain(packageRoot.includes('/') - ? `--coverage.include=packages/${packageRoot}/src/**/*.ts` - : `--coverage.include=packages/${packageRoot}/*/src/**/*.ts`) - } - if ('extraTestRoots' in shard) { - for (const testRoot of shard.extraTestRoots) expect(args).toContain(`${testRoot}/`) - } - expect(args).toContain('scripts/test-invariants.spec.ts') - expect(new Set(args).size).toBe(args.length) - }) - - it('rejects an unknown lane', () => { - expect(() => coverageArgs('missing')).toThrow('unknown DSH_COVERAGE_SHARD') - }) -}) diff --git a/scripts/coverage-shards.ts b/scripts/coverage-shards.ts deleted file mode 100644 index 84f4d1a63e..0000000000 --- a/scripts/coverage-shards.ts +++ /dev/null @@ -1,114 +0,0 @@ -/** Coverage shard definitions for the GitHub Actions source-test lanes. */ - -/** A coverage lane that owns complete package roots and optional cross-package tests. */ -export interface CoverageShard { - /** Stable lane identifier passed through `DSH_COVERAGE_SHARD`. */ - name: string - /** Group or package paths below `packages/` whose tests and source coverage belong to the lane. */ - packageRoots: readonly string[] - /** Additional test roots needed for cross-package behavior or repository scripts. */ - extraTestRoots?: readonly string[] -} - -/** Exhaustive, non-overlapping ownership of workspace packages in coverage CI. */ -export const coverageShards = [ - { - name: 'core-loop', - packageRoots: ['core/agent', 'core/agent-loop', 'core/tools'], - }, - { - name: 'state-session', - packageRoots: [ - 'core/session', - 'core/scope', - 'core/system-prompt', - 'context', - 'session-persistence', - 'session-query', - 'support/invariants', - ], - extraTestRoots: [ - 'packages/examples/cli-demo/tests', - 'packages/llm/token-meter/tests', - 'scripts', - ], - }, - { - name: 'models', - packageRoots: ['llm', 'compact'], - }, - { - name: 'session-title', - packageRoots: ['session-title'], - }, - { - name: 'integrations', - packageRoots: ['hooks/hook-protocol', 'lsp', 'mcp', 'hooks/hooks-claude'], - }, - { - name: 'sdk-capabilities', - packageRoots: [ - 'sdk', - 'hooks/hooks-codex', - 'web', - 'skill', - 'spill', - 'util', - 'guard', - 'todo', - 'timeout', - ], - }, - { - name: 'interfaces', - packageRoots: ['ui', 'examples', 'goal'], - extraTestRoots: ['examples'], - }, - { name: 'execution', packageRoots: ['fs', 'bash', 'sandbox', 'code-runtime'] }, - { - name: 'workflow', - packageRoots: ['workflow/workflow', 'workflow/tool-workflow', 'workflow/tool-ralph'], - }, - { - name: 'workflow-worker', - packageRoots: ['workflow/workflow-workerthread'], - }, - { name: 'delegation', packageRoots: ['subagent', 'tasks'] }, - { - name: 'repository', - packageRoots: [ - 'cordis', - 'support/acp-snapshot', - 'support/agent-loop-testkit', - 'support/llm-replay', - 'support/loader-smoke', - ], - }, -] as const satisfies readonly CoverageShard[] - -/** - * Build Vitest filters and coverage include globs for one source-test lane. - * - * @param name Stable shard name from {@link coverageShards}. - * @returns Positional test roots followed by per-group coverage include flags. - */ -export function coverageArgs(name: string): string[] { - const shard = coverageShards.find(candidate => candidate.name === name) - if (shard === undefined) { - throw new Error(`run-gates: unknown DSH_COVERAGE_SHARD ${JSON.stringify(name)}.`) - } - - // Vitest positional filters are substrings; the trailing separator keeps - // prefix-named sibling packages out of each lane. - const testRoots = new Set([ - ...shard.packageRoots.map(packageRoot => `packages/${packageRoot}/`), - ...('extraTestRoots' in shard ? shard.extraTestRoots.map(testRoot => `${testRoot}/`) : []), - 'scripts/test-invariants.spec.ts', - ]) - return [ - ...testRoots, - ...shard.packageRoots.map(packageRoot => packageRoot.includes('/') - ? `--coverage.include=packages/${packageRoot}/src/**/*.ts` - : `--coverage.include=packages/${packageRoot}/*/src/**/*.ts`), - ] -} diff --git a/scripts/lint-shards.spec.ts b/scripts/lint-shards.spec.ts deleted file mode 100644 index d3020fa6b2..0000000000 --- a/scripts/lint-shards.spec.ts +++ /dev/null @@ -1,81 +0,0 @@ -import { readdirSync } from 'node:fs' -import { resolve } from 'node:path' -import { describe, expect, it } from 'vitest' -import { selectLintShard } from './lint-shards.ts' - -const packagesRoot = resolve(import.meta.dirname, '..', 'packages') - -describe('lint gate shards', () => { - it('keeps the unsharded local command complete', () => { - expect(selectLintShard()).toEqual({ eslintTargets: ['.'], includeDuplication: true }) - expect(selectLintShard('')).toEqual({ eslintTargets: ['.'], includeDuplication: true }) - }) - - it('partitions package sources and tests into alphabetic ranges plus their repository complement', () => { - expect(selectLintShard('package-sources-a-c')).toEqual({ - eslintTargets: ['packages/[a-c]*/*/src/**/*.ts'], - includeDuplication: false, - }) - expect(selectLintShard('package-sources-d-m')).toEqual({ - eslintTargets: ['packages/[d-m]*/*/src/**/*.ts'], - includeDuplication: false, - }) - expect(selectLintShard('package-sources-n-s')).toEqual({ - eslintTargets: ['packages/[n-s]*/*/src/**/*.ts'], - includeDuplication: false, - }) - expect(selectLintShard('package-sources-t-z')).toEqual({ - eslintTargets: ['packages/[t-z]*/*/src/**/*.ts'], - includeDuplication: false, - }) - expect(selectLintShard('package-tests-a-c')).toEqual({ - eslintTargets: ['packages/[a-c]*/*/tests/**/*.ts'], - includeDuplication: false, - }) - expect(selectLintShard('package-tests-d-m')).toEqual({ - eslintTargets: ['packages/[d-m]*/*/tests/**/*.ts'], - includeDuplication: false, - }) - expect(selectLintShard('package-tests-n-s')).toEqual({ - eslintTargets: ['packages/[n-s]*/*/tests/**/*.ts'], - includeDuplication: false, - }) - expect(selectLintShard('package-tests-t-z')).toEqual({ - eslintTargets: ['packages/[t-z]*/*/tests/**/*.ts'], - includeDuplication: false, - }) - expect(selectLintShard('package-sources')).toEqual({ - eslintTargets: ['packages/*/*/src/**/*.ts'], - includeDuplication: false, - }) - expect(selectLintShard('package-tests')).toEqual({ - eslintTargets: ['packages/*/*/tests/**/*.ts'], - includeDuplication: false, - }) - expect(selectLintShard('repository')).toEqual({ - eslintTargets: [ - '.', - '--ignore-pattern', - 'packages/*/*/src/**', - '--ignore-pattern', - 'packages/*/*/tests/**', - ], - includeDuplication: true, - }) - }) - - it('assigns every package group once in the Linux topology', () => { - const groups = readdirSync(packagesRoot, { withFileTypes: true }) - .filter(entry => entry.isDirectory()) - .map(entry => entry.name) - .sort() - const ranges = [/^[a-c]/u, /^[d-m]/u, /^[n-s]/u, /^[t-z]/u] - const assignments = ranges.flatMap(range => groups.filter(group => range.test(group))).sort() - - expect(assignments).toEqual(groups) - }) - - it('rejects an unknown lane', () => { - expect(() => selectLintShard('missing')).toThrow('unknown DSH_LINT_SHARD') - }) -}) diff --git a/scripts/lint-shards.ts b/scripts/lint-shards.ts deleted file mode 100644 index 05c806c49f..0000000000 --- a/scripts/lint-shards.ts +++ /dev/null @@ -1,56 +0,0 @@ -/** Lint-lane selection for GitHub Actions. */ - -/** One ESLint target set and whether it owns the cross-file duplication gate. */ -export interface LintSelection { - /** Shell-free arguments passed to ESLint before its cache options. */ - eslintTargets: readonly string[] - /** Whether this lane also runs the repository-wide duplication check. */ - includeDuplication: boolean -} - -/** - * Select an exhaustive lint partition without changing the ordinary local lint command. - * - * @param name Optional stable shard name from `DSH_LINT_SHARD`. - * @returns ESLint targets and ownership of the duplication gate. - */ -export function selectLintShard(name?: string): LintSelection { - switch (name) { - case undefined: - case '': - return { eslintTargets: ['.'], includeDuplication: true } - case 'package-sources-a-c': - return { eslintTargets: ['packages/[a-c]*/*/src/**/*.ts'], includeDuplication: false } - case 'package-sources-d-m': - return { eslintTargets: ['packages/[d-m]*/*/src/**/*.ts'], includeDuplication: false } - case 'package-sources-n-s': - return { eslintTargets: ['packages/[n-s]*/*/src/**/*.ts'], includeDuplication: false } - case 'package-sources-t-z': - return { eslintTargets: ['packages/[t-z]*/*/src/**/*.ts'], includeDuplication: false } - case 'package-sources': - return { eslintTargets: ['packages/*/*/src/**/*.ts'], includeDuplication: false } - case 'package-tests-a-c': - return { eslintTargets: ['packages/[a-c]*/*/tests/**/*.ts'], includeDuplication: false } - case 'package-tests-d-m': - return { eslintTargets: ['packages/[d-m]*/*/tests/**/*.ts'], includeDuplication: false } - case 'package-tests-n-s': - return { eslintTargets: ['packages/[n-s]*/*/tests/**/*.ts'], includeDuplication: false } - case 'package-tests-t-z': - return { eslintTargets: ['packages/[t-z]*/*/tests/**/*.ts'], includeDuplication: false } - case 'package-tests': - return { eslintTargets: ['packages/*/*/tests/**/*.ts'], includeDuplication: false } - case 'repository': - return { - eslintTargets: [ - '.', - '--ignore-pattern', - 'packages/*/*/src/**', - '--ignore-pattern', - 'packages/*/*/tests/**', - ], - includeDuplication: true, - } - default: - throw new Error(`run-gates: unknown DSH_LINT_SHARD ${JSON.stringify(name)}.`) - } -} diff --git a/scripts/run-gates.ts b/scripts/run-gates.ts index 3e31384d6b..bc1ade1458 100644 --- a/scripts/run-gates.ts +++ b/scripts/run-gates.ts @@ -8,15 +8,9 @@ import { spawn } from 'node:child_process' import { availableParallelism } from 'node:os' import { resolve } from 'node:path' import { performance } from 'node:perf_hooks' -import { coverageArgs } from './coverage-shards.ts' -import { selectLintShard } from './lint-shards.ts' -import { selectSnapshotLane } from './snapshot-shards.ts' -import { selectStaticGates } from './static-shards.ts' type Mode = | 'ci-primary' - | 'ci-primary-cpu' - | 'ci-primary-large-runner' | 'ci-static' | 'ci-lint' | 'ci-coverage' @@ -92,8 +86,6 @@ if (results.some(result => result.gate.allowFailure !== true && (result.status = function parseMode(raw: string | undefined): Mode { switch (raw) { case 'ci-primary': - case 'ci-primary-cpu': - case 'ci-primary-large-runner': case 'ci-static': case 'ci-lint': case 'ci-coverage': @@ -107,7 +99,7 @@ function parseMode(raw: string | undefined): Mode { return raw default: throw new Error( - `run-gates: expected mode ci-primary | ci-primary-cpu | ci-primary-large-runner | ci-static | ci-lint | ci-coverage | ci-snapshot | ci-artifacts | ci-windows-blocking | ci-windows-complete | ci-windows-observational | node-compat | doc-sync, got ${JSON.stringify(raw)}.`, + `run-gates: expected mode ci-primary | ci-static | ci-lint | ci-coverage | ci-snapshot | ci-artifacts | ci-windows-blocking | ci-windows-complete | ci-windows-observational | node-compat | doc-sync, got ${JSON.stringify(raw)}.`, ) } } @@ -173,25 +165,17 @@ function gatesForMode(selected: Mode): Gate[] { switch (selected) { case 'ci-primary': return ciPrimaryGates() - case 'ci-primary-cpu': - return ciPrimaryCpuGates() - case 'ci-primary-large-runner': - return ciPrimaryLargeRunnerGates() case 'ci-static': return ciStaticGates() - case 'ci-lint': { - const selection = selectLintShard(process.env.DSH_LINT_SHARD) + case 'ci-lint': return [ - lintGate(selection.eslintTargets), - ...selection.includeDuplication ? [pnpmScript('duplication', 'duplication')] : [], + lintGate(), + pnpmScript('duplication', 'duplication'), ] - } case 'ci-coverage': return [coverageGate()] case 'ci-snapshot': - return flagEnabled('DSH_SNAPSHOT_PREBUILT') - ? [snapshotGate([])] - : [pnpmScript('build', 'build'), snapshotGate()] + return [pnpmScript('build', 'build'), snapshotGate()] case 'ci-artifacts': return ciArtifactGates() case 'ci-windows-blocking': @@ -217,11 +201,12 @@ function ciPrimaryGates(): Gate[] { lintGate(), pnpmScript('duplication', 'duplication'), coverageGate(), + ...nodeCompatSmokeGates(), snapshotGate(), ...docSyncLeafGates(), pnpmScript('module-graph', 'verify-module-graph', { label: 'module graph' }), pnpmScript('knip', 'knip'), - pnpmScript('build', 'build', { needs: ['typecheck'] }), + pnpmScript('build', 'build'), pnpmScript('publint', 'publint', { needs: ['build'] }), pnpmScript('node-next-types', 'verify-node-next-types', { label: 'node-next types', @@ -232,30 +217,6 @@ function ciPrimaryGates(): Gate[] { ] } -function ciPrimaryLargeRunnerGates(): Gate[] { - // The CPU lane owns typecheck, coverage, and the build-to-snapshot chain. - // This core lane starts its own build eagerly for the remaining artifact consumers. - return ciPrimaryGates() - .filter(gate => !['coverage', 'docs-site-build', 'snapshot', 'typecheck'].includes(gate.id)) - .map((gate) => { - if (gate.id !== 'build') return gate - const eagerBuild = { ...gate } - delete eagerBuild.needs - return eagerBuild - }) -} - -function ciPrimaryCpuGates(): Gate[] { - // Build and snapshot stay together so the dependent replay consumes this lane's output. - return [ - pnpmScript('typecheck', 'typecheck'), - coverageGate(), - pnpmScript('build', 'build'), - snapshotGate(), - ...nodeCompatSmokeGates(), - ] -} - function nodeCompatGates(): Gate[] { return [ ...flagEnabled('DSH_NODE_COMPAT_SKIP_TYPECHECK') ? [] : [pnpmScript('typecheck', 'typecheck')], @@ -279,7 +240,7 @@ function nodeCompatSmokeGates(): Gate[] { } function ciStaticGates(): Gate[] { - const gates = [ + return [ pnpmScript('runtime-closure', 'verify-runtime-closure', { label: 'runtime closure' }), pnpmScript('constraints', 'constraints'), pnpmScript('package-invariants', 'verify-package-invariants', { label: 'package invariants' }), @@ -293,12 +254,10 @@ function ciStaticGates(): Gate[] { pnpmScript('module-graph', 'verify-module-graph', { label: 'module graph' }), pnpmScript('knip', 'knip'), ] - return selectStaticGates(gates, process.env.DSH_STATIC_SHARD) } function ciArtifactGates(): Gate[] { - const shard = process.env.DSH_ARTIFACT_SHARD - const metadataGates = [ + return [ pnpmScript('build', 'build'), pnpmScript('publint', 'publint', { needs: ['build'] }), pnpmScript('node-next-types', 'verify-node-next-types', { @@ -306,13 +265,8 @@ function ciArtifactGates(): Gate[] { needs: ['build'], }), builtPackageInvariantsGate(['build']), + builtBinSmokeGate(), ] - if (shard === 'metadata') return metadataGates - if (shard === 'smoke') return [pnpmScript('build', 'build'), builtBinSmokeGate()] - if (shard !== undefined && shard !== '') { - throw new Error(`run-gates: unknown DSH_ARTIFACT_SHARD ${JSON.stringify(shard)}.`) - } - return [...metadataGates, builtBinSmokeGate()] } function ciWindowsBlockingGates(): Gate[] { @@ -396,12 +350,10 @@ function eslintConcurrencyArgs(): string[] { } function coverageGate(): Gate { - const shard = process.env.DSH_COVERAGE_SHARD return pnpmExec('coverage', [ 'vitest', 'run', '--coverage', - ...(shard === undefined || shard === '' ? [] : coverageArgs(shard)), ...positiveIntArg('DSH_COVERAGE_MAX_WORKERS', '--maxWorkers'), ], { label: 'test:coverage', @@ -409,23 +361,12 @@ function coverageGate(): Gate { } // The snapshot suite boots the example bins in `lib` mode (built artifact under plain Node, -// plugins via real exports). CI normally pairs it with `build`, so it exercises what ships rather -// than the tsx/source path dev uses; callers with prebuilt output may omit that dependency. -function snapshotGate(needs: string[] = ['build']): Gate { - const lane = selectSnapshotLane(process.env.DSH_SNAPSHOT_LANE) - return pnpmExec('snapshot', [ - 'vitest', - 'run', - '--config', - 'vitest.snapshot.config.ts', - ...lane.files, - ], { - label: 'test:snapshot', - env: { - DSH_EXAMPLE_MODE: 'lib', - ...lane.scenarioShard === undefined ? {} : { DSH_SNAPSHOT_SCENARIO_SHARD: lane.scenarioShard }, - }, - ...needs.length === 0 ? {} : { needs }, +// plugins via real exports). CI pairs it with `build`, so it exercises what ships rather than +// the tsx/source path dev uses and therefore waits on `build`. +function snapshotGate(): Gate { + return pnpmScript('snapshot', 'test:snapshot', { + env: { DSH_EXAMPLE_MODE: 'lib' }, + needs: ['build'], }) } @@ -492,7 +433,7 @@ function docSyncLeafGates(options: { ] } -function builtBinSmokeGate(shard?: string): Gate { +function builtBinSmokeGate(): Gate { return pnpmExec('built-bin-smoke', [ 'vitest', 'run', @@ -508,7 +449,6 @@ function builtBinSmokeGate(shard?: string): Gate { // (the e2e lane runs unbuilt, so these files self-skip there). 'packages/workflow/workflow-workerthread/tests/built-worker.e2e.ts', 'packages/code-runtime/code-runtime-worker/tests/built-lib.e2e.ts', - ...(shard === undefined ? [] : [`--shard=${shard}`]), ], { label: 'built-bin smoke', needs: ['build'], diff --git a/scripts/snapshot-shards.spec.ts b/scripts/snapshot-shards.spec.ts deleted file mode 100644 index 1b8db7dc39..0000000000 --- a/scripts/snapshot-shards.spec.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { existsSync, readdirSync } from 'node:fs' -import { join, relative, sep } from 'node:path' -import { describe, expect, it } from 'vitest' -import { selectSnapshotLane, snapshotLanes } from './snapshot-shards.ts' - -const root = join(import.meta.dirname, '..') - -function snapshotFiles(dir: string): string[] { - if (!existsSync(dir)) return [] - return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => { - const path = join(dir, entry.name) - if (entry.isDirectory()) return snapshotFiles(path) - return entry.name.endsWith('.snapshot.ts') ? [relative(root, path).split(sep).join('/')] : [] - }) -} - -describe('snapshot lanes', () => { - it('assigns every configured snapshot file and every ACP scenario shard', () => { - const discovered = [ - ...snapshotFiles(join(root, 'examples')), - ...snapshotFiles(join(root, 'packages/sdk')), - ...snapshotFiles(join(root, 'packages/ui/tui')), - ].filter(path => !path.includes('/node_modules/') && !path.includes('/lib/')).sort() - const ordinary = snapshotLanes.filter(lane => lane.scenarioShard === undefined).flatMap(lane => lane.files) - const acp = snapshotLanes.filter(lane => lane.scenarioShard !== undefined) - - expect(new Set(ordinary).size).toBe(ordinary.length) - expect(acp.map(lane => lane.files)).toEqual(Array.from( - { length: 8 }, - () => ['examples/acp-agent/tests/acp.snapshot.ts'], - )) - expect(acp.map(lane => lane.scenarioShard)).toEqual([ - '1/8', - '2/8', - '3/8', - '4/8', - '5/8', - '6/8', - '7/8', - '8/8', - ]) - expect([...ordinary, 'examples/acp-agent/tests/acp.snapshot.ts'].sort()).toEqual(discovered) - }) - - it('keeps ordinary runs complete and selects known lanes', () => { - expect(selectSnapshotLane()).toEqual({ name: 'complete', files: [] }) - expect(selectSnapshotLane('')).toEqual({ name: 'complete', files: [] }) - for (const lane of snapshotLanes) expect(selectSnapshotLane(lane.name)).toBe(lane) - }) - - it('rejects an unknown lane', () => { - expect(() => selectSnapshotLane('missing')).toThrow('unknown DSH_SNAPSHOT_LANE') - }) -}) diff --git a/scripts/snapshot-shards.ts b/scripts/snapshot-shards.ts deleted file mode 100644 index 5439be94fb..0000000000 --- a/scripts/snapshot-shards.ts +++ /dev/null @@ -1,49 +0,0 @@ -/** Snapshot-lane definitions for GitHub Actions. */ - -/** One explicit snapshot file lane, optionally split again by ACP scenarios. */ -export interface SnapshotLane { - /** Stable lane name passed through `DSH_SNAPSHOT_LANE`. */ - name: string - /** Snapshot test files owned by the lane. */ - files: readonly string[] - /** Optional one-based ACP scenario partition. */ - scenarioShard?: string -} - -/** Exhaustive file ownership plus scenario partitions for the large ACP suite. */ -export const snapshotLanes: readonly SnapshotLane[] = [ - { - name: 'support', - files: [ - 'packages/sdk/scripts/tests/config.snapshot.ts', - 'packages/sdk/create-sdk/tests/create.snapshot.ts', - 'packages/ui/tui/tests/tui.snapshot.ts', - ], - }, - { - name: 'agents', - files: [ - 'examples/tui-agent/tests/tui.snapshot.ts', - 'examples/acp-agent/tests/goal.snapshot.ts', - 'examples/headless-agent/tests/headless.snapshot.ts', - ], - }, - ...Array.from({ length: 8 }, (_, offset) => ({ - name: `acp-${offset + 1}`, - files: ['examples/acp-agent/tests/acp.snapshot.ts'], - scenarioShard: `${offset + 1}/8`, - })), -] - -/** - * Resolve one CI lane while preserving a complete ordinary snapshot run. - * - * @param name Optional stable lane name. - * @returns An empty file list for the full suite, or one explicit CI lane. - */ -export function selectSnapshotLane(name?: string): SnapshotLane { - if (name === undefined || name === '') return { name: 'complete', files: [] } - const lane = snapshotLanes.find(candidate => candidate.name === name) - if (lane === undefined) throw new Error(`run-gates: unknown DSH_SNAPSHOT_LANE ${JSON.stringify(name)}.`) - return lane -} diff --git a/scripts/static-shards.spec.ts b/scripts/static-shards.spec.ts deleted file mode 100644 index df9e2922f1..0000000000 --- a/scripts/static-shards.spec.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { selectStaticGates, staticShards } from './static-shards.ts' - -const completeInventory = staticShards.flatMap(shard => shard.gateIds).map(id => ({ id })) - -describe('static gate shards', () => { - it.each(staticShards)('selects only the gates owned by $name', (shard) => { - expect(selectStaticGates(completeInventory, shard.name).map(gate => gate.id)).toEqual(shard.gateIds) - }) - - it('selects multiple lanes in gate inventory order', () => { - const selectedNames = new Set(['foundation', 'catalogs', 'prose']) - const expected = staticShards - .filter(shard => selectedNames.has(shard.name)) - .flatMap(shard => shard.gateIds) - expect(selectStaticGates(completeInventory, 'foundation,catalogs,prose').map(gate => gate.id)).toEqual(expected) - }) - - it('rejects missing, duplicate, and unknown assignments', () => { - expect(() => selectStaticGates(completeInventory.slice(1))).toThrow('assign every static gate exactly once') - expect(() => selectStaticGates([...completeInventory, completeInventory[0]!])).toThrow('static gate IDs must be unique') - expect(() => selectStaticGates(completeInventory, 'missing')).toThrow('unknown DSH_STATIC_SHARD') - expect(() => selectStaticGates(completeInventory, 'foundation,foundation')).toThrow('nonempty and unique') - expect(() => selectStaticGates(completeInventory, 'foundation,,prose')).toThrow('nonempty and unique') - }) -}) diff --git a/scripts/static-shards.ts b/scripts/static-shards.ts deleted file mode 100644 index 3476099993..0000000000 --- a/scripts/static-shards.ts +++ /dev/null @@ -1,86 +0,0 @@ -/** Static-gate shard definitions for GitHub Actions. */ - -/** A static CI lane identified by the gate IDs it owns. */ -export interface StaticShard { - /** Stable lane identifier passed through `DSH_STATIC_SHARD`. */ - name: string - /** Gate IDs selected from the static gate inventory. */ - gateIds: readonly string[] -} - -/** Exhaustive, non-overlapping ownership of static CI gates. */ -export const staticShards = [ - { - name: 'foundation', - gateIds: [ - 'runtime-closure', - 'constraints', - 'package-invariants', - 'cordis-config', - 'module-graph', - 'knip', - ], - }, - { name: 'doc-types', gateIds: ['build', 'doc-typecheck'] }, - { - name: 'api-contracts', - gateIds: ['cordis-api', 'export-jsdoc', 'scoped-events', 'type-equivalence'], - }, - { - name: 'catalogs', - gateIds: ['cordis-catalog', 'tool-catalog', 'config-catalog', 'persistence-catalog', 'doc-graphs'], - }, - { - name: 'prose', - gateIds: [ - 'markdown-wrap', - 'markdown-links', - 'doc-refs', - 'package-paths', - 'package-readme-model-experience', - 'mermaid', - 'agent-note-classification', - 'agent-note-format', - 'translation-prompt', - 'translation-pairing', - 'doc-budgets', - 'package-readme-limitations', - ], - }, - { name: 'site-projection', gateIds: ['docs-site-projection'] }, - { name: 'site-build', gateIds: ['docs-site-build'] }, -] as const satisfies readonly StaticShard[] - -/** - * Validate the complete gate partition and optionally select one lane. - * - * @param gates Complete static gate inventory. - * @param name Optional comma-separated stable shard names. - * @returns All gates when no shard is requested, otherwise the selected lanes in inventory order. - */ -export function selectStaticGates<T extends { id: string }>(gates: readonly T[], name?: string): T[] { - const gateIds = gates.map(gate => gate.id) - const assignedIds = staticShards.flatMap(shard => shard.gateIds) - const uniqueGateIds = new Set<string>(gateIds) - const uniqueAssignedIds = new Set<string>(assignedIds) - if (uniqueGateIds.size !== gateIds.length) throw new Error('run-gates: static gate IDs must be unique.') - if (uniqueAssignedIds.size !== assignedIds.length) throw new Error('run-gates: static shard gate IDs must be unique.') - if (gateIds.length !== assignedIds.length - || gateIds.some(id => !uniqueAssignedIds.has(id)) - || assignedIds.some(id => !uniqueGateIds.has(id))) { - throw new Error('run-gates: static shards must assign every static gate exactly once.') - } - if (name === undefined || name === '') return [...gates] - - const shardNames = name.split(',') - if (shardNames.some(shardName => shardName === '') || new Set(shardNames).size !== shardNames.length) { - throw new Error(`run-gates: DSH_STATIC_SHARD names must be nonempty and unique, got ${JSON.stringify(name)}.`) - } - const selectedShards = shardNames.map((shardName) => { - const shard = staticShards.find(candidate => candidate.name === shardName) - if (shard === undefined) throw new Error(`run-gates: unknown DSH_STATIC_SHARD ${JSON.stringify(shardName)}.`) - return shard - }) - const selectedIds = new Set<string>(selectedShards.flatMap(shard => shard.gateIds)) - return gates.filter(gate => selectedIds.has(gate.id)) -} From 280207c824dbc6f9aa35e555fd19bfe90f1819a6 Mon Sep 17 00:00:00 2001 From: ZiyaZhang <199893125+ZiyaZhang@users.noreply.github.com> Date: Wed, 22 Jul 2026 03:10:14 -0700 Subject: [PATCH 79/90] feat(tui): add /status session diagnostics --- ...cated-full-screen-tui-front-door.i18n.yaml | 4 +- ...17-dedicated-full-screen-tui-front-door.md | 2 +- ...dedicated-full-screen-tui-front-door.zh.md | 2 +- examples/tui-agent/README.md | 2 +- .../tui-agent/tests/tui-keyless-smoke.e2e.ts | 14 ++- packages/ui/tui/README.md | 4 +- packages/ui/tui/src/index.ts | 51 +++++++++++ packages/ui/tui/tests/harness.ts | 14 +-- .../snapshots/disposed-terminal.expected.txt | 30 ++++--- .../snapshots/errors-and-help.expected.txt | 30 ++++--- .../snapshots/status-diagnostics.expected.txt | 79 +++++++++++++++++ packages/ui/tui/tests/tui.snapshot.ts | 40 ++++++++- packages/ui/tui/tests/tui.spec.ts | 86 +++++++++++++++++++ 13 files changed, 316 insertions(+), 42 deletions(-) create mode 100644 packages/ui/tui/tests/snapshots/status-diagnostics.expected.txt diff --git a/.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.i18n.yaml b/.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.i18n.yaml index 810f8863e5..8d6c7be831 100644 --- a/.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.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 -2026-07-17-dedicated-full-screen-tui-front-door.md: 3e2b1e751001020eccc9193438daff23dd42518a -2026-07-17-dedicated-full-screen-tui-front-door.zh.md: 5f3632f43702e16ca9dec07c0bb8e42bf50499b7 +2026-07-17-dedicated-full-screen-tui-front-door.md: ecfda138593fc2b98ac42929acc586b11e437ee2 +2026-07-17-dedicated-full-screen-tui-front-door.zh.md: 6b8cc63f7657672a6da542e2033d765b54bd4f07 diff --git a/.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.md b/.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.md index 3e2b1e7510..ecfda13859 100644 --- a/.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.md +++ b/.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.md @@ -22,7 +22,7 @@ The selected front door receives the exact generated or resumed `SessionId` used The TUI rebuilds the transcript from the active `session.surface` and reprojects it whenever an event carries a `surfaceOp`, so resumed and compacted history matches the model-visible conversation. It renders Markdown text and reasoning, token totals, the latest `todo/write` plan, and tool cards produced through each tool definition's `presentCall` and `presentResult` methods. Long card bodies retain a configurable head/tail preview with the hidden-line count; one terminal control expands or collapses every card. Pending chunks and tool calls update the same components that completed events settle. -Editor input calls `agent.send()` while idle and `agent.steer()` while a turn is running. Cancellation, reasoning visibility, tool-card expansion, redraw, transcript clearing, and exit are terminal-only controls. The idle footer derives context occupancy from `tokenMeter` and pairs the selected model with its reasoning state; during a run, elapsed activity and the Escape interrupt hint replace that summary. The plugin registers the shared `userInteraction` provider and presents queued questions in a wide bottom-left keyboard panel with batch progress, numbered options, and aligned descriptions; agent behavior and answer logging remain owned by their existing services. +Editor input calls `agent.send()` while idle and `agent.steer()` while a turn is running. Cancellation, reasoning visibility, tool-card expansion, redraw, transcript clearing, and exit are terminal-only controls. The idle footer derives context occupancy from `tokenMeter` and shows the selected model; during a run, elapsed activity and the Escape interrupt hint replace that summary. `/status` remains available in either state and appends a detailed terminal-only snapshot: session identity and timestamps, selected model and reasoning visibility, lifecycle counts folded from the event log, the same deduplicated usage buckets and KV-cache rate as the footer, and context use from `tokenMeter` plus the selected model's advertised capacity. The plugin registers the shared `userInteraction` provider and presents queued questions in a wide bottom-left keyboard panel with batch progress, numbered options, and aligned descriptions; agent behavior and answer logging remain owned by their existing services. The `/model` command presents the advisory `ctx.llm` catalog as a keyboard selector and changes only this TUI session's target; argument forms remain available for direct selection. Agent-scoped prompt-assembly and request waterfalls snapshot one provider/model pair per step, so `{{provider}}` / `{{model}}` interpolation and request routing cannot split when a command arrives during assembly. The latest logged request header restores a used target; a selection that never reaches a request remains process-local. diff --git a/.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.zh.md b/.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.zh.md index 5f3632f437..6b8cc63f76 100644 --- a/.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.zh.md +++ b/.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.zh.md @@ -22,7 +22,7 @@ DeepSeek Harness 将 [`@deepseek-ai/dsh-tui`](../../../../packages/ui/tui/README TUI 从活跃的 `session.surface` 重建 transcript(文本记录),并在事件携带 `surfaceOp` 时重新投影,因此恢复或压缩后的历史与模型可见会话保持一致。TUI 渲染 Markdown 文本与推理、token 用量、最新 `todo/write` 计划,以及各工具定义通过 `presentCall` 和 `presentResult` 方法生成的工具卡片。较长的工具卡片正文会保留可配置的头尾预览,并显示隐藏行数;一个终端控制可以展开或收起全部卡片。进行中的分片与工具调用会更新同一组组件,随后由完成事件收束状态。 -agent 空闲时,编辑器输入调用 `agent.send()`;轮次运行中则调用 `agent.steer()`。取消、推理显隐、工具卡片展开、重绘、清空 transcript 和退出都只是终端控制。空闲态页脚根据 `tokenMeter` 得出上下文占用率,并将选中模型及其推理状态组合显示;agent 运行期间,该摘要会替换为带已用时长的活动指示和 Escape 中断提示。插件注册共享的 `userInteraction` 提供方,在左下角宽幅键盘操作面板中呈现排队的问题,面板显示批次进度、带编号的选项和对齐的描述;agent 行为和答案日志仍由既有服务负责。 +agent 空闲时,编辑器输入调用 `agent.send()`;轮次运行中则调用 `agent.steer()`。取消、推理显隐、工具卡片展开、重绘、清空 transcript 和退出都只是终端控制。空闲态页脚根据 `tokenMeter` 得出上下文占用率,并显示所选模型;agent 运行期间,该摘要会替换为带已用时长的活动指示和 Escape 中断提示。`/status` 在这两种状态下均可用,并会追加一份仅在终端显示的详细快照,其中包括会话标识与时间戳、所选模型及推理显隐状态、从事件日志归并得出的生命周期计数、与页脚一致的去重用量分项和 KV 缓存命中率,以及 `tokenMeter` 给出的上下文用量和所选模型公布的容量。插件注册共享的 `userInteraction` 提供方,在左下角宽幅键盘操作面板中呈现排队的问题,面板显示批次进度、带编号的选项和对齐的描述;agent 行为和答案日志仍由既有服务负责。 `/model` 命令将建议性的 `ctx.llm` 目录呈现为键盘选择器,并且只更改当前 TUI 会话的目标;带参数的形式仍可直接选择目标。agent 作用域内的 prompt 组装和请求两条 waterfall(瀑布式事件)会为每个 step 快照一次同一个提供方/模型字段组合,因此即使命令在组装期间到达,`{{provider}}` / `{{model}}` 插值与请求路由也不会分裂。系统通过日志中最新的请求头恢复已经使用过的目标;未被请求使用的选择只保留在当前进程中。 diff --git a/examples/tui-agent/README.md b/examples/tui-agent/README.md index e4197b22c4..ecae86750b 100644 --- a/examples/tui-agent/README.md +++ b/examples/tui-agent/README.md @@ -17,7 +17,7 @@ Type a coding task. The agent works through the `read`/`write`/`edit` filesystem The `todo_write` task tracker is opt-in and not in the shipped config: add `@deepseek-ai/dsh-tool-todo` to `cordis.yml` (or a personal-config overlay under `~/.dsh`) to expose it. Once loaded, the model records a whole-list plan to the session log and the TUI renders it. -The TUI renders Markdown history, reasoning, tool-owned terminal/diff/generic cards, token totals, and — when `todo_write` is loaded — the latest plan. Long tool bodies keep a head/tail preview; Ctrl+O expands or collapses every card. Enter submits or steers while the agent runs, Ctrl+R toggles reasoning, Escape cancels, and `/help` lists commands. `/model` opens a keyboard selector for the current provider catalog; use Up/Down and Enter, or `/model <model>` and `/model <provider>/<model>` for direct selection. `ask_user_question` opens a wide bottom-left keyboard panel with batch progress and numbered options. +The TUI renders Markdown history, reasoning, tool-owned terminal/diff/generic cards, token totals, and — when `todo_write` is loaded — the latest plan. Long tool bodies keep a head/tail preview; Ctrl+O expands or collapses every card. Enter submits or steers while the agent runs, Ctrl+R toggles reasoning, Escape cancels, and `/help` lists commands. `/status` expands the current session's identity, activity counts, exact token/cache buckets, context use, and timestamps without interrupting a running turn. `/model` opens a keyboard selector for the current provider catalog; use Up/Down and Enter, or `/model <model>` and `/model <provider>/<model>` for direct selection. `ask_user_question` opens a wide bottom-left keyboard panel with batch progress and numbered options. ### Resuming a prior session diff --git a/examples/tui-agent/tests/tui-keyless-smoke.e2e.ts b/examples/tui-agent/tests/tui-keyless-smoke.e2e.ts index 2fd1dd1467..78967abcd5 100644 --- a/examples/tui-agent/tests/tui-keyless-smoke.e2e.ts +++ b/examples/tui-agent/tests/tui-keyless-smoke.e2e.ts @@ -101,8 +101,10 @@ describe('tui-agent keyless smoke (real Loader tree in a PTY)', () => { // provider's tool-less title call; the scripted adapter answers it, the // accepted title lands in the log, and the TUI renders the terminal // window title as `<session title> — <configured title>` via OSC 0. - // Gating /exit on it keeps the assertion race-free. - { waitFor: 'scripted session title — DeepSeek Harness', send: '/exit\r' }, + // Gating /status on it keeps the assertion race-free; the diagnostics + // card is then exercised through the same real Loader/PTY composition. + { waitFor: 'scripted session title — DeepSeek Harness', send: '/status\r' }, + { waitFor: 'Session diagnostics', send: '/exit\r' }, ], }) expect(output).toContain('I need one decision before I continue.') @@ -114,6 +116,14 @@ describe('tui-agent keyless smoke (real Loader tree in a PTY)', () => { expect(output).not.toContain('\u009B31mMODEL_C1') expect(output).toContain('Safe') expect(output).toContain('\u001B]0;scripted session title — DeepSeek Harness\u0007') + expect(output).toContain('Session diagnostics') + expect(output).toContain('Title') + expect(output).toContain('scripted session title') + expect(output).toContain('Model') + expect(output).toContain('tui-scripted/tui-scripted-model-pro') + expect(output).toContain('KV cache hit') + expect(output).toContain('Context') + expect(output).toContain('128,000') expect(output).toContain('\u001B[?2004l') }, LOADER_SMOKE_TEST_TIMEOUT_MS) diff --git a/packages/ui/tui/README.md b/packages/ui/tui/README.md index bc6521d058..119803fd9c 100644 --- a/packages/ui/tui/README.md +++ b/packages/ui/tui/README.md @@ -14,7 +14,7 @@ An embedding may provide `TuiRuntime.formatCwd` when its logical workspace label Before model output, session events, tool presenters, questions, configuration, or diagnostics reach pi-tui's ANSI-aware renderers or the terminal title, the TUI renders C0 and C1 controls other than line feeds as visible `\xNN` text. Those sources cannot add terminal control sequences; the TUI and pi-tui retain ownership of terminal rendering and styling. -While the agent is running, ordinary editor submissions call `agent.steer()`; otherwise they call `agent.send()`. A slash at the start of the submitted line enters `ctx.commands` instead: known commands execute directly, unknown commands produce a warning, and neither path reaches the model. The TUI registers `/help`, `/model`, `/clear`, `/reasoning`, `/tools`, `/redraw`, `/reload`, `/resume`, and `/exit` as agent-scoped definitions; every other effective command joins autocomplete and `/help` dynamically, as do `/skill:` completions. A status line above the editor reports the turn phase the TUI derives from session events — waiting for the first token, thinking, responding, or executing tools — with the elapsed time in that phase and the running step total, refreshed each second, and ends with the `Enter sends steering, Esc cancels` hint; while steering messages wait to reach the model it inserts a `N queued ·` badge before the hint that clears as each drains. Ctrl+C or Escape cancels a running turn. Tool cards collapse long bodies into a configurable head/tail preview; Ctrl+O toggles every card between its preview and full output. Ctrl+R toggles reasoning, Ctrl+L redraws, and Ctrl+D exits while idle. +While the agent is running, ordinary editor submissions call `agent.steer()`; otherwise they call `agent.send()`. A slash at the start of the submitted line enters `ctx.commands` instead: known commands execute directly, unknown commands produce a warning, and neither path reaches the model. The TUI registers `/help`, `/model`, `/clear`, `/reasoning`, `/tools`, `/redraw`, `/reload`, `/resume`, `/status`, and `/exit` as agent-scoped definitions; every other effective command joins autocomplete and `/help` dynamically, as do `/skill:` completions. A status line above the editor reports the turn phase the TUI derives from session events — waiting for the first token, thinking, responding, or executing tools — with the elapsed time in that phase and the running step total, refreshed each second, and ends with the `Enter sends steering, Esc cancels` hint; while steering messages wait to reach the model it inserts a `N queued ·` badge before the hint that clears as each drains. Ctrl+C or Escape cancels a running turn. Tool cards collapse long bodies into a configurable head/tail preview; Ctrl+O toggles every card between its preview and full output. Ctrl+R toggles reasoning, Ctrl+L redraws, and Ctrl+D exits while idle. `/model` opens the advisory `ctx.llm` catalog as a keyboard selector: Up/Down moves, Enter selects, and Escape closes it. `/model <model>` still selects an unambiguous model id directly, while `/model <provider>/<model>` selects an exact target. The configured target or latest logged request header initializes the selector, and an unlisted current model remains visible because catalogs are advisory. Selection is local to this TUI session. Prompt assembly snapshots the target for one step, replaces `{{provider}}` and `{{model}}`, and applies the same pair through `agent/request`; a switch during assembly therefore starts with a later step. The request header durably records targets that reach the model, while an unused selection remains process-local. @@ -22,6 +22,8 @@ While the agent is running, ordinary editor submissions call `agent.steer()`; ot The footer sums the session's reported usage as `↑<uncached input> ↓<output>`, followed by `cache <rate>%` once any input has been billed — the share of billed prompt tokens (uncached input plus cache reads and writes) served from the provider cache, rounded to a percent. It also compares token-meter pressure with `ctx.llm.resolveModelContext()` for the current route (omitting the context share when the adapter has no capacity metadata) and shows the current model and tool-card mode; the right side clips first when the footer is narrow. +`/status` adds a point-in-time diagnostics card to the transcript and remains available while the agent runs. It reports the session id, title, working directory, selected provider/model, reasoning-block visibility, agent state, event/turn/step/tool-call counts, exact input/output/cache token buckets, KV-cache hit rate, token-meter context use and capacity, creation time, and latest event time. Missing titles, models, cache input, or context capacity are labeled instead of inferred. The card is terminal-only and does not duplicate the compact footer. + When `resumeCommand` is set and a `sessionPersistence` backend is mounted, exiting prints the resume command for the current session (once it has been persisted, so an abandoned session yields no hint), and `/resume` lists this workspace's persisted sessions newest-first, each with its resume command and a marker on the current one. `{session}` in the template expands to the session id; the TUI only prints commands to copy and never resumes in place. ## Config diff --git a/packages/ui/tui/src/index.ts b/packages/ui/tui/src/index.ts index 99e0ec2727..b54c3c2570 100644 --- a/packages/ui/tui/src/index.ts +++ b/packages/ui/tui/src/index.ts @@ -940,6 +940,14 @@ function sessionTokens(session: Session): SessionTokenTotals { return totals } +function formatDiagnosticNumber(value: number): string { + return value.toLocaleString('en-US') +} + +function formatDiagnosticTime(value: number): string { + return new Date(value).toISOString() +} + class FooterComponent implements Component { constructor( private readonly agent: Agent, @@ -1953,6 +1961,44 @@ export function createTuiChat( requestRender() } + const showStatus = (): void => { + const events = agent.session.events + const latestActivity = events.at(-1)?.time ?? agent.session.header.createdAt + const usedContext = Math.max(0, Math.round(ctx.tokenMeter.measure(agent.session).totalTokens)) + const context = contextWindow === undefined + ? `${formatDiagnosticNumber(usedContext)} used · capacity unknown` + : `${formatDiagnosticNumber(usedContext)} / ${formatDiagnosticNumber(contextWindow)} (${String(Math.round(usedContext / contextWindow * 100))}%)` + const rate = cacheHitRate(tokens) + const rows = [ + ['Session', agent.session.id], + ['Title', sessionTitle ?? 'untitled'], + ['Working dir', cwd], + ['Model', target.current === undefined ? 'unset' : targetLabel(target.current)], + ['Reasoning view', showReasoning ? 'shown' : 'hidden'], + ['Agent', agent.status], + ['Activity', [ + `events ${String(events.length)}`, + `turns ${String(events.filter(event => event.type === 'turn/start').length)}`, + `steps ${String(events.filter(event => event.type === 'step/start').length)}`, + `tool calls ${String(events.filter(event => event.type === 'tool/call').length)}`, + ].join(' · ')], + ['Tokens', `input ${formatDiagnosticNumber(tokens.input)} · output ${formatDiagnosticNumber(tokens.output)}`], + ['Cache tokens', `read ${formatDiagnosticNumber(tokens.cacheRead)} · write ${formatDiagnosticNumber(tokens.cacheWrite)}`], + ['KV cache hit', rate === undefined ? 'n/a' : `${String(rate)}%`], + ['Context', context], + ['Created', formatDiagnosticTime(agent.session.header.createdAt)], + ['Last active', formatDiagnosticTime(latestActivity)], + ] as const + const labelWidth = Math.max(...rows.map(([label]) => label.length)) + const card = new GutterBox(text => palette.accent(text), 0) + card.addChild(new Text(palette.bold(palette.accent('Session diagnostics')), 0, 0)) + card.addChild(new Text(rows.map(([label, value]) => + `${palette.muted(label.padEnd(labelWidth))} ${displayText(value)}`).join('\n'), 0, 0)) + chat.addChild(new Spacer(1)) + chat.addChild(card) + requestRender() + } + // Skill listing is async while `createTuiChat` is synchronous, so the // completions rebuild once the catalog resolves. Disabled-for-model skills // are absent from `list()`, so they never appear as completions; a user can @@ -2041,6 +2087,11 @@ export function createTuiChat( description: 'List this workspace\'s resumable sessions', handler: () => { showResume(); return { kind: 'success' } }, }) + commandCtx.commands.register({ + name: 'status', + description: 'Show detailed session diagnostics', + handler: () => { showStatus(); return { kind: 'success' } }, + }) commandCtx.commands.register({ name: 'exit', description: 'Exit after the active turn reaches idle', diff --git a/packages/ui/tui/tests/harness.ts b/packages/ui/tui/tests/harness.ts index 8210a3a423..97db25b7ae 100644 --- a/packages/ui/tui/tests/harness.ts +++ b/packages/ui/tui/tests/harness.ts @@ -24,6 +24,8 @@ interface FakeAgent extends Agent { export interface TuiHarnessOptions { status?: AgentStatus config?: Config + /** Leave the session event log empty instead of seeding one turn and step. */ + omitInitialLifecycle?: boolean /** Omit the harness's default `welcome`, exercising the banner sweep-reveal path. */ omitWelcome?: boolean tools?: Record<string, ToolDefinition> @@ -120,11 +122,13 @@ export async function createTuiTestHarness<TerminalType extends Terminal, Exit e sessionId, options.cwd === null ? undefined : { meta: { cwd: options.cwd ?? '/workspace' } }, ) - session.append('turn/start', { - turn: 1, - trigger: { kind: 'message', source: { kind: 'user' } }, - }) - session.append('step/start', { turn: 1, step: 1 }) + if (options.omitInitialLifecycle !== true) { + session.append('turn/start', { + turn: 1, + trigger: { kind: 'message', source: { kind: 'user' } }, + }) + session.append('step/start', { turn: 1, step: 1 }) + } options.beforeMount?.(session) const sent: ContentBlock[][] = [] const steered: ContentBlock[][] = [] diff --git a/packages/ui/tui/tests/snapshots/disposed-terminal.expected.txt b/packages/ui/tui/tests/snapshots/disposed-terminal.expected.txt index 4224beecc3..b6fb49135b 100644 --- a/packages/ui/tui/tests/snapshots/disposed-terminal.expected.txt +++ b/packages/ui/tui/tests/snapshots/disposed-terminal.expected.txt @@ -1,7 +1,7 @@ terminal 92x32 buffer=normal length=32 base=0 viewport=0 lifecycle started=1 stopped=1 progress=inactive title "DSH snapshot" -cursor visible column=0 viewportRow=30 bufferRow=30 +cursor visible column=0 viewportRow=31 bufferRow=31 buffer 0| " DEEPSEEK HARNESS" style 1-8 fg=bright-blue bold @@ -36,26 +36,28 @@ buffer style 1-88 fg=bright-black 16| " /resume — List this workspace's resumable sessions " style 1-50 fg=bright-black -17| " /tools — Expand or collapse all tool cards " +17| " /status — Show detailed session diagnostics " + style 1-43 fg=bright-black +18| " /tools — Expand or collapse all tool cards " style 1-42 fg=bright-black -18| " /skill:<name> [instructions] — load a skill into the conversation " +19| " /skill:<name> [instructions] — load a skill into the conversation " style 1-65 fg=bright-black -19| <blank> -20| " provider stream failed after partial output " +20| <blank> +21| " provider stream failed after partial output " style 1-43 fg=red -21| <blank> -22| " The previous process ended during this turn. " +22| <blank> +23| " The previous process ended during this turn. " style 1-44 fg=yellow -23| <blank> -24| " Unknown command: /unknown-advanced-command " +24| <blank> +25| " Unknown command: /unknown-advanced-command " style 1-42 fg=yellow -25| "────────────────────────────────────────────────────────────────────────────────────────────" +26| "────────────────────────────────────────────────────────────────────────────────────────────" style 0-91 dim -26| " " +27| " " style 1-1 inverse -27| "────────────────────────────────────────────────────────────────────────────────────────────" +28| "────────────────────────────────────────────────────────────────────────────────────────────" style 0-91 dim -28| "deepseek-v4-flash /workspace/project ↑0 ↓0 0% context tools:collapsed" +29| "deepseek-v4-flash /workspace/project ↑0 ↓0 0% context tools:collapsed" style 0-43 dim style 65-91 dim -29-31| <blank> +30-31| <blank> diff --git a/packages/ui/tui/tests/snapshots/errors-and-help.expected.txt b/packages/ui/tui/tests/snapshots/errors-and-help.expected.txt index b6bb78ce3a..05c35e32e1 100644 --- a/packages/ui/tui/tests/snapshots/errors-and-help.expected.txt +++ b/packages/ui/tui/tests/snapshots/errors-and-help.expected.txt @@ -1,7 +1,7 @@ terminal 92x32 buffer=normal length=32 base=0 viewport=0 lifecycle started=1 stopped=0 progress=inactive title "DSH snapshot" -cursor hidden column=1 viewportRow=26 bufferRow=26 +cursor hidden column=1 viewportRow=27 bufferRow=27 buffer 0| " DEEPSEEK HARNESS" style 1-8 fg=bright-blue bold @@ -36,26 +36,28 @@ buffer style 1-88 fg=bright-black 16| " /resume — List this workspace's resumable sessions " style 1-50 fg=bright-black -17| " /tools — Expand or collapse all tool cards " +17| " /status — Show detailed session diagnostics " + style 1-43 fg=bright-black +18| " /tools — Expand or collapse all tool cards " style 1-42 fg=bright-black -18| " /skill:<name> [instructions] — load a skill into the conversation " +19| " /skill:<name> [instructions] — load a skill into the conversation " style 1-65 fg=bright-black -19| <blank> -20| " provider stream failed after partial output " +20| <blank> +21| " provider stream failed after partial output " style 1-43 fg=red -21| <blank> -22| " The previous process ended during this turn. " +22| <blank> +23| " The previous process ended during this turn. " style 1-44 fg=yellow -23| <blank> -24| " Unknown command: /unknown-advanced-command " +24| <blank> +25| " Unknown command: /unknown-advanced-command " style 1-42 fg=yellow -25| "────────────────────────────────────────────────────────────────────────────────────────────" +26| "────────────────────────────────────────────────────────────────────────────────────────────" style 0-91 dim -26| " " +27| " " style 1-1 inverse -27| "────────────────────────────────────────────────────────────────────────────────────────────" +28| "────────────────────────────────────────────────────────────────────────────────────────────" style 0-91 dim -28| "deepseek-v4-flash /workspace/project ↑0 ↓0 0% context tools:collapsed" +29| "deepseek-v4-flash /workspace/project ↑0 ↓0 0% context tools:collapsed" style 0-43 dim style 65-91 dim -29-31| <blank> +30-31| <blank> diff --git a/packages/ui/tui/tests/snapshots/status-diagnostics.expected.txt b/packages/ui/tui/tests/snapshots/status-diagnostics.expected.txt new file mode 100644 index 0000000000..67fe587e4c --- /dev/null +++ b/packages/ui/tui/tests/snapshots/status-diagnostics.expected.txt @@ -0,0 +1,79 @@ +terminal 92x32 buffer=normal length=32 base=0 viewport=0 +lifecycle started=1 stopped=0 progress=inactive +title "Inspect session diagnostics — DSH snapshot" +cursor hidden column=1 viewportRow=27 bufferRow=27 +buffer +0| " DEEPSEEK HARNESS" + style 1-8 fg=bright-blue bold + style 10-16 bold +1| " Inspect session diagnostics" + style 1-27 fg=bright-black +2| " deepseek-v4-pro • main-session" + style 1-32 dim +3| <blank> +4| "▌ " + style 0-0 fg=bright-blue +5| "▌ You " + style 0-0 fg=bright-blue + style 2-4 fg=bright-blue bold +6| "▌ inspect this session " + style 0-0 fg=bright-blue +7| "▌ " + style 0-0 fg=bright-blue +8| <blank> +9| " Assistant " + style 1-9 fg=bright-magenta bold +10| " Session inspected. " +11| <blank> +12| "▌ Session diagnostics " + style 0-0 fg=bright-blue + style 2-20 fg=bright-blue bold +13| "▌ Session main-session " + style 0-0 fg=bright-blue + style 2-15 fg=bright-black +14| "▌ Title Inspect session diagnostics " + style 0-0 fg=bright-blue + style 2-15 fg=bright-black +15| "▌ Working dir /workspace/project " + style 0-0 fg=bright-blue + style 2-15 fg=bright-black +16| "▌ Model deepseek/deepseek-v4-pro " + style 0-0 fg=bright-blue + style 2-15 fg=bright-black +17| "▌ Reasoning view shown " + style 0-0 fg=bright-blue + style 2-15 fg=bright-black +18| "▌ Agent idle " + style 0-0 fg=bright-blue + style 2-15 fg=bright-black +19| "▌ Activity events 6 · turns 1 · steps 1 · tool calls 1 " + style 0-0 fg=bright-blue + style 2-15 fg=bright-black +20| "▌ Tokens input 1,250 · output 340 " + style 0-0 fg=bright-blue + style 2-15 fg=bright-black +21| "▌ Cache tokens read 3,000 · write 250 " + style 0-0 fg=bright-blue + style 2-15 fg=bright-black +22| "▌ KV cache hit 67% " + style 0-0 fg=bright-blue + style 2-15 fg=bright-black +23| "▌ Context 42,000 / 128,000 (33%) " + style 0-0 fg=bright-blue + style 2-15 fg=bright-black +24| "▌ Created 2026-07-22T09:10:11.000Z " + style 0-0 fg=bright-blue + style 2-15 fg=bright-black +25| "▌ Last active 2026-07-22T09:10:11.000Z " + style 0-0 fg=bright-blue + style 2-15 fg=bright-black +26| "────────────────────────────────────────────────────────────────────────────────────────────" + style 0-91 dim +27| " " + style 1-1 inverse +28| "────────────────────────────────────────────────────────────────────────────────────────────" + style 0-91 dim +29| "deepseek-v4-pro /workspace/project ↑1.3k ↓340 cache 67% 33% context tools:collapsed" + style 0-57 dim + style 64-91 dim +30-31| <blank> diff --git a/packages/ui/tui/tests/tui.snapshot.ts b/packages/ui/tui/tests/tui.snapshot.ts index d706ede1d9..e8a0897805 100644 --- a/packages/ui/tui/tests/tui.snapshot.ts +++ b/packages/ui/tui/tests/tui.snapshot.ts @@ -1,7 +1,7 @@ import { mkdir, readdir, writeFile } from 'node:fs/promises' import { dirname, join } from 'node:path' import { fileURLToPath } from 'node:url' -import { afterAll, describe, expect, it } from 'vitest' +import { afterAll, describe, expect, it, vi } from 'vitest' import type { Context } from 'cordis' import { agentEvents } from '@deepseek-ai/dsh-agent' import { CallId, type ContentBlock } from '@deepseek-ai/dsh-llm' @@ -48,6 +48,7 @@ const CHECKPOINTS = [ 'errors-and-help', 'disposed-terminal', 'resume-sessions', + 'status-diagnostics', ] as const type Checkpoint = typeof CHECKPOINTS[number] @@ -637,6 +638,43 @@ describe('TUI terminal-state snapshots', () => { await checkpoint('resume-sessions', harness.terminal, { includeScrollback: true }) await disposeSnapshot(harness) }) + + it('pins the detailed session diagnostics card', async () => { + const dateNow = vi.spyOn(Date, 'now').mockReturnValue(Date.parse('2026-07-22T09:10:11.000Z')) + const harness = await setupSnapshot({ + contextWindow: 128_000, + contextTokens: 42_000, + agentOptions: { provider: 'deepseek', model: 'deepseek-v4-pro' }, + beforeMount(session) { + appendUser(session, 'inspect this session') + appendAssistant(session, [{ type: 'text', text: 'Session inspected.' }], { + inputTokens: 1_250, + outputTokens: 340, + cacheReadTokens: 3_000, + cacheWriteTokens: 250, + }) + session.append('tool/call', { + turn: 1, + step: 1, + callId: CallId('status-call'), + name: 'read', + arguments: '{"path":"README.md"}', + }) + session.append('session/title', { + title: 'Inspect session diagnostics', + messageSeqs: [1], + source: { kind: 'fallback' }, + }) + }, + }, { columns: 92, rows: 32 }) + await renderAfter(harness, () => { + harness.terminal.send('/status') + harness.terminal.send('\r') + }) + await checkpoint('status-diagnostics', harness.terminal, { includeScrollback: true }) + await disposeSnapshot(harness) + dateNow.mockRestore() + }) }) afterAll(async () => { diff --git a/packages/ui/tui/tests/tui.spec.ts b/packages/ui/tui/tests/tui.spec.ts index a26ae0e3fa..02f7a0de64 100644 --- a/packages/ui/tui/tests/tui.spec.ts +++ b/packages/ui/tui/tests/tui.spec.ts @@ -845,6 +845,92 @@ describe('pi-tui chat lifecycle and transcript', () => { await dispose(result) }) + it('shows detailed session diagnostics while the agent is running', async () => { + const timestamp = Date.parse('2026-07-22T09:10:11.000Z') + const dateNow = vi.spyOn(Date, 'now').mockReturnValue(timestamp) + const result = await setup({ + cwd: '/workspace/status', + contextWindow: 128_000, + contextTokens: 42_000, + config: { showReasoning: false }, + agentOptions: { provider: 'deepseek', model: 'deepseek-v4-pro' }, + beforeMount(session) { + session.append('session/title', { + title: 'Inspect status \u001B]2;unsafe\u0007', + messageSeqs: [1], + source: { kind: 'fallback' }, + }) + appendAssistant(session, [{ type: 'text', text: 'measured' }], { + inputTokens: 1_250, + outputTokens: 340, + cacheReadTokens: 3_000, + cacheWriteTokens: 250, + }) + session.append('tool/call', { + turn: 1, step: 1, callId: 'status-call-1' as never, name: 'read', arguments: '{}', + }) + session.append('tool/call', { + turn: 1, step: 1, callId: 'status-call-2' as never, name: 'write', arguments: '{}', + }) + }, + }) + result.agent.status = 'running' + agentEvents(result.ctx, result.agent).emit('agent/status', 'running') + result.terminal.send('/status') + result.terminal.send('\r') + await tick() + + expect(result.terminal.output).toContain('Session diagnostics') + expect(result.terminal.output).toContain('Session main-session') + expect(result.terminal.output).toContain('Title Inspect status \\x1b]2;unsafe\\x07') + expect(result.terminal.output).toContain('Working dir /workspace/status') + expect(result.terminal.output).toContain('Model deepseek/deepseek-v4-pro') + expect(result.terminal.output).toContain('Reasoning view hidden') + expect(result.terminal.output).toContain('Agent running') + expect(result.terminal.output).toContain('Activity events 6 · turns 1 · steps 1 · tool calls 2') + expect(result.terminal.output).toContain('Tokens input 1,250 · output 340') + expect(result.terminal.output).toContain('Cache tokens read 3,000 · write 250') + expect(result.terminal.output).toContain('KV cache hit 67%') + expect(result.terminal.output).toContain('Context 42,000 / 128,000 (33%)') + expect(result.terminal.output).toContain('Created 2026-07-22T09:10:11.000Z') + expect(result.terminal.output).toContain('Last active 2026-07-22T09:10:11.000Z') + expect(result.terminal.output).not.toContain('\u001B]2;unsafe\u0007') + + await dispose(result) + dateNow.mockRestore() + }) + + it('labels unavailable status diagnostics without inventing values', async () => { + const timestamp = Date.parse('2026-07-22T10:11:12.000Z') + const dateNow = vi.spyOn(Date, 'now').mockReturnValue(timestamp) + const result = await setup({ + cwd: null, + omitInitialLifecycle: true, + contextTokens: 7, + agentOptions: {}, + catalog: { + providers: [], + models: [], + resolveModelContext: () => Promise.resolve(undefined), + }, + }) + result.terminal.send('/status') + result.terminal.send('\r') + await tick() + + expect(result.terminal.output).toContain('Title untitled') + expect(result.terminal.output).toContain('Model unset') + expect(result.terminal.output).toContain('Reasoning view shown') + expect(result.terminal.output).toContain('Agent idle') + expect(result.terminal.output).toContain('Activity events 0 · turns 0 · steps 0 · tool calls 0') + expect(result.terminal.output).toContain('KV cache hit n/a') + expect(result.terminal.output).toContain('Context 7 used · capacity unknown') + expect(result.terminal.output).toContain('Created 2026-07-22T10:11:12.000Z') + expect(result.terminal.output).toContain('Last active 2026-07-22T10:11:12.000Z') + await dispose(result) + dateNow.mockRestore() + }) + it('sends, steers, handles commands, global keys, and disposed-agent input', async () => { const result = await setup() From da5a1fc811e6043430b3f72a27bfc4e828cd0402 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Wed, 22 Jul 2026 18:17:06 +0800 Subject: [PATCH 80/90] ci: separate master serial and PR jobs Skipped matrix jobs never expand matrix display values, so let GitHub use stable job identifiers. Bind optimized jobs to pull requests and the three serial references to master pushes. --- ...rial-cross-platform-ci-reference.i18n.yaml | 4 +-- ...7-21-serial-cross-platform-ci-reference.md | 12 ++++----- ...1-serial-cross-platform-ci-reference.zh.md | 12 ++++----- .github/workflows/ci.yml | 26 ++++++++----------- 4 files changed, 25 insertions(+), 29 deletions(-) diff --git a/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.i18n.yaml b/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.i18n.yaml index 5871fa1b07..9922018569 100644 --- a/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.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 -2026-07-21-serial-cross-platform-ci-reference.md: e2cb9dbc5f8778b5a28236cb135f6dcbb979d38b -2026-07-21-serial-cross-platform-ci-reference.zh.md: 85c3403f1acb78e60e3a811503b5b264f84242c7 +2026-07-21-serial-cross-platform-ci-reference.md: ffc1fd5b37bc6c9e3427ee55a55300f93a1292f3 +2026-07-21-serial-cross-platform-ci-reference.zh.md: d7f87916865b83973abe6b0708203618cf536c8e diff --git a/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.md b/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.md index e2cb9dbc5f..ffc1fd5b37 100644 --- a/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.md +++ b/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.md @@ -6,7 +6,7 @@ English | [中文](2026-07-21-serial-cross-platform-ci-reference.zh.md) ## Problem -The pull-request workflow reaches its latency targets by partitioning static checks, lint, coverage, snapshot replay, and artifact validation across explicit GitHub jobs. Those partitions are exhaustively checked in code, but the optimized workflow still should not be its own only completeness oracle: a defect shared by shard selection and its inventory test could omit work while every optimized lane stays green. +The pull-request workflow reaches its latency targets by scheduling the complete primary Node inventory concurrently inside one larger runner. The optimized scheduler still should not be its own only completeness oracle: a defect in its gate inventory or dependency graph could omit work while the optimized job stays green. Encoding the one-minute non-Windows target and three-minute Windows target as job timeouts creates a separate failure mode. Hosted-runner startup and performance vary, so a correct gate can be cancelled at the target boundary before it emits useful diagnostics. The performance objective needs measurement against GitHub timestamps, while correctness needs enough time to finish. @@ -14,25 +14,25 @@ Reviewers also need a direct answer to a simpler question: what happens when the ## Decision -[CI](../../../../.github/workflows/ci.yml) accepts `workflow_dispatch` in addition to its normal push and pull-request events. A manual dispatch skips the optimized and compatibility jobs and exposes three explicit jobs named `serial / linux`, `serial / macos`, and `serial / windows`. They intentionally duplicate their short checkout, runtime setup, and immutable install sequences instead of hiding the operating systems behind a matrix or reusable workflow. +[CI](../../../../.github/workflows/ci.yml) gives pull-request and master-push events complementary responsibilities. Pull requests run only the optimized larger-runner and compatibility jobs. A push to `master` skips those jobs and runs three explicit references named `serial / linux`, `serial / macos`, and `serial / windows`. They intentionally duplicate their short checkout, runtime setup, and immutable install sequences instead of hiding the operating systems behind a matrix or reusable workflow. `workflow_dispatch` is reserved for runner benchmarks. Each reference job runs `pnpm run check:ci` without any shard selector. `DSH_GATE_CONCURRENCY=1` makes the top-level aggregate execute one ready gate at a time; coverage, snapshot replay, built-bin smoke, and publication validation also receive worker counts of one. The three operating-system jobs may run beside one another, but each host's repository gates are serial and complete. Linux installs bubblewrap before replaying snapshots, and Windows enables Developer Mode before installing the symlinked workspace. -Manual reference jobs are diagnostic and do not participate in the required `all checks passed` result. Pull-request and push events continue to run only the optimized lanes. The one-minute non-Windows and three-minute Windows objectives are evaluated from completed hosted-job timestamps and reported as measurements; they are not `timeout-minutes` values. +Master reference jobs are diagnostic and do not participate in the pull request's required `all checks passed` result. A pull request runs only the optimized jobs; a master push runs only the three serial references. The one-minute non-Windows and three-minute Windows objectives are evaluated from completed hosted-job timestamps and reported as measurements; they are not `timeout-minutes` values. The portable reference uses GitHub's standard `ubuntu-latest`, `macos-latest`, and `windows-2025` labels. A higher-core hosted runner remains a possible future benchmark, but it is not the default: larger runners require organization-owned labels and provisioning, while a reference oracle should remain runnable without repository-external runner configuration. Provisioning one later can change the performance experiment without changing this correctness baseline. ## Alternatives considered - **Set each timeout equal to its latency target** - rejected because scheduling variance would cancel correct work and suppress the evidence needed to diagnose a regression. -- **Trust only the optimized shard inventory** - rejected because selection and validation share implementation assumptions; an unsharded aggregate is an independent completeness check. +- **Trust only the concurrent primary inventory** - rejected because scheduling and validation share implementation assumptions; a serial aggregate is an independent completeness check. - **Run the serial references on every pull request** - rejected because they deliberately trade wall time and runner consumption for simplicity and are not needed in the fast feedback loop. - **Use one operating-system matrix** - rejected because three named jobs make the reference surface visible without another selection mechanism. -- **Move the fast workflow to larger runners now** - rejected as the portable default because it would couple ordinary CI to organization-specific runner capacity. It remains an opt-in experiment after such capacity has an owned label and budget. +- **Run the serial reference on larger runners** - rejected because the reference is the portable fallback for the organization-specific pull-request topology. The fast pull-request path uses provisioned larger runners; the serial master path keeps standard labels. ## Consequences -The workflow contains duplicated setup steps and a manual reference run can take much longer than the optimized pull-request path. That duplication is deliberate: reviewers can inspect each operating system's complete command without resolving a matrix or shard inventory. +The workflow contains duplicated setup steps and a master reference run can take much longer than the optimized pull-request path. That duplication is deliberate: reviewers can inspect each operating system's complete command without resolving a matrix or concurrent scheduler. The reference may expose platform failures that the optimized blocking set does not yet claim to support, especially on Windows. Such a failure is evidence about current cross-platform behavior rather than a reason to weaken or silently skip the aggregate. diff --git a/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.zh.md b/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.zh.md index 85c3403f1a..d7f8791686 100644 --- a/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.zh.md +++ b/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.zh.md @@ -6,7 +6,7 @@ Status: implemented ## 问题 -拉取请求工作流通过把静态检查、lint、覆盖率、快照回放和产物验证拆分到显式的 GitHub 作业中来达到延迟目标。这些分区由代码穷举校验,但优化工作流仍不应成为自身唯一的完整性判定基准:如果分片选择逻辑及其清单测试存在同一缺陷,即使所有优化通道都保持绿灯,也可能漏掉部分工作。 +拉取请求工作流通过在一台更大型运行器内并发调度完整的主 Node 门禁清单来达到延迟目标。优化调度器仍不应成为自身唯一的完整性判定基准:如果其门禁清单或依赖图存在缺陷,即使优化作业保持绿灯,也可能漏掉部分工作。 将非 Windows 作业的 1 分钟目标和 Windows 作业的 3 分钟目标写成作业超时,会引入另一种失败模式。托管运行器的启动时间和性能会波动,因此即使门禁本身正确,也可能在到达目标时间边界时被取消,来不及输出有用的诊断信息。性能目标需要根据 GitHub 时间戳衡量,而正确性验证需要给门禁留足完成时间。 @@ -14,25 +14,25 @@ Status: implemented ## 决策 -[CI](../../../../.github/workflows/ci.yml) 除正常的 push 和拉取请求事件外,也接受 `workflow_dispatch`。手动触发时会跳过优化作业和兼容性作业,只提供三个名为 `serial / linux`、`serial / macos` 和 `serial / windows` 的显式作业。这些作业有意分别重复简短的代码检出、运行时设置和依赖锁定的安装步骤,不用矩阵或可复用工作流把操作系统差异隐藏起来。 +[CI](../../../../.github/workflows/ci.yml) 为拉取请求事件与 master 推送事件赋予互补的职责。拉取请求只运行使用更大型运行器的优化作业和兼容性作业。向 `master` 推送时会跳过这些作业,改为运行三个显式参考作业,名称分别为 `serial / linux`、`serial / macos` 和 `serial / windows`。这些作业有意分别重复简短的代码检出、运行时设置和依赖锁定的安装步骤,不用矩阵或可复用工作流把操作系统差异隐藏起来。`workflow_dispatch` 仅用于运行器基准测试。 每个参考作业均在不设置任何分片选择器的情况下运行 `pnpm run check:ci`。`DSH_GATE_CONCURRENCY=1` 使顶层聚合每次只执行一个已经就绪的门禁;覆盖率、快照回放、built-bin 冒烟测试和发布验证的并发数也设为 1。三种操作系统的作业可以彼此并行,但每台主机上的仓库门禁都串行运行且完整执行。Linux 在回放快照前安装 bubblewrap,Windows 则在安装采用符号链接的工作区前启用开发人员模式。 -手动参考作业仅用于诊断,不参与必需的 `all checks passed` 结果。拉取请求和 push 事件仍只运行优化通道。系统根据已完成托管作业的时间戳评估非 Windows 作业的 1 分钟目标和 Windows 作业的 3 分钟目标,并将其报告为测量结果,而不是写成 `timeout-minutes` 值。 +master 分支的参考作业仅用于诊断,不参与拉取请求所要求的 `all checks passed` 结果。拉取请求只运行优化作业;向 master 推送时只运行三个串行参考作业。系统根据已完成托管作业的时间戳评估非 Windows 作业的 1 分钟目标和 Windows 作业的 3 分钟目标,并将其报告为测量结果,而不是写成 `timeout-minutes` 值。 可移植的参考流程使用 GitHub 标准的 `ubuntu-latest`、`macos-latest` 和 `windows-2025` 标签。仍可将更高核心数的托管运行器作为未来的基准测试,但不将其设为默认选择:更大型运行器需要组织自有的标签和预配,而参考判定基准应无需仓库外部的运行器配置即可运行。日后完成这类预配,可以改变性能实验而无需改变该正确性基线。 ## 曾考虑的替代方案 - **将每个超时值设为相应延迟目标**:不予采纳,因为调度波动会中止原本正确的执行,并使诊断回归所需的证据无法产生。 -- **仅信任优化分片清单**:不予采纳,因为选择逻辑与校验逻辑共享实现假设;无分片的聚合流程是一项独立的完整性检查。 +- **仅信任并发执行的主门禁清单**:不予采纳,因为调度逻辑与校验逻辑共享实现假设;串行聚合流程是一项独立的完整性检查。 - **在每个拉取请求上运行串行参考作业**:不予采纳,因为这些作业有意以更长的总耗时和更多运行器用量换取简单性,快速反馈循环不需要它们。 - **使用一个操作系统矩阵**:不予采纳,因为三个具名作业无需另一套选择机制,就能让参考流程的构成清晰可见。 -- **立即把快速工作流迁移到更大型运行器**:不作为可移植的默认方案,因为这会让常规 CI 与特定组织的运行器容量耦合。等到这类容量拥有明确归属的标签和预算后,仍可将其作为一项可选实验。 +- **在更大型运行器上运行串行参考流程**:不予采纳,因为该参考流程是特定组织拉取请求拓扑的可移植后备方案。快速拉取请求路径使用已预配的更大型运行器;串行 master 路径保留标准标签。 ## 后果 -工作流包含重复的设置步骤,手动参考运行也可能比优化后的拉取请求路径耗时长得多。这些重复是有意保留的:评审人无需解析矩阵或分片清单,就能直接检查每种操作系统执行的完整命令。 +工作流包含重复的设置步骤,master 参考运行也可能比优化后的拉取请求路径耗时长得多。这些重复是有意保留的:评审人无需解析矩阵或并发调度器,就能直接检查每种操作系统执行的完整命令。 参考流程可能暴露某些平台上的故障,而优化后的阻塞门禁集合尚未声明支持这些平台,Windows 尤其如此。这类失败反映了当前的跨平台行为,不应成为削弱或静默跳过该聚合流程的理由。 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c6bb9fe627..a7ea422056 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,20 +2,18 @@ name: CI on: push: - branches: [main, master] + branches: [master] pull_request: workflow_dispatch: inputs: suite: description: Manual CI suite to run required: true - default: serial-reference + default: larger-runner-benchmark type: choice options: - - serial-reference - larger-runner-benchmark - consolidated-runner-benchmark - - optimized-larger-runners concurrency: group: ${{ github.workflow }}-${{ github.ref }} @@ -33,7 +31,7 @@ jobs: # overlaps the complete unsharded primary Node inventory. Build starts eagerly; # only consumers of emitted output wait for it. node-24: - if: github.event_name != 'workflow_dispatch' || inputs.suite == 'optimized-larger-runners' + if: github.event_name == 'pull_request' runs-on: dsh-ubuntu-24-04-96core name: node 24 / complete env: @@ -88,7 +86,7 @@ jobs: node-compat: - if: github.event_name != 'workflow_dispatch' || inputs.suite == 'optimized-larger-runners' + if: github.event_name == 'pull_request' # Distinct larger-runner pools avoid both standard-runner setup outliers and # delayed allocation when independent environment contracts share one pool. runs-on: ${{ matrix.runner }} @@ -135,7 +133,7 @@ jobs: run: pnpm run check:node-compat python-sdk: - if: github.event_name != 'workflow_dispatch' || inputs.suite == 'optimized-larger-runners' + if: github.event_name == 'pull_request' runs-on: dsh-ubuntu-24-04-8core name: python 3.10 / keyless SDK steps: @@ -156,7 +154,7 @@ jobs: # complete observational portability inventory. run-gates reports failures # from observational gates without allowing them to fail the required job. windows: - if: github.event_name != 'workflow_dispatch' || inputs.suite == 'optimized-larger-runners' + if: github.event_name == 'pull_request' runs-on: dsh-windows-2025-32core name: windows node 24 / complete env: @@ -208,12 +206,12 @@ jobs: shell: pwsh run: pnpm run check:ci:windows-complete - # Manual reference runs deliberately avoid the optimized jobs above. + # Master pushes run only the serial reference jobs below. # Each host executes the complete, unsharded primary Node aggregate with one # gate worker, giving reviewers a simple cross-platform oracle for completeness # and timing. serial-linux: - if: github.event_name == 'workflow_dispatch' && inputs.suite == 'serial-reference' + if: github.event_name == 'push' && github.ref == 'refs/heads/master' name: serial / linux runs-on: ubuntu-latest steps: @@ -242,7 +240,7 @@ jobs: run: pnpm run check:ci serial-macos: - if: github.event_name == 'workflow_dispatch' && inputs.suite == 'serial-reference' + if: github.event_name == 'push' && github.ref == 'refs/heads/master' name: serial / macos runs-on: macos-latest steps: @@ -268,7 +266,7 @@ jobs: run: pnpm run check:ci serial-windows: - if: github.event_name == 'workflow_dispatch' && inputs.suite == 'serial-reference' + if: github.event_name == 'push' && github.ref == 'refs/heads/master' name: serial / windows runs-on: windows-2025 steps: @@ -306,7 +304,6 @@ jobs: # The named pools are restricted at the organization level to this repository. larger-runner-benchmark: if: github.event_name == 'workflow_dispatch' && inputs.suite == 'larger-runner-benchmark' - name: larger runner / ${{ matrix.platform }} / ${{ matrix.cores }} cores / ${{ matrix.workload }} runs-on: ${{ matrix.runner }} timeout-minutes: 15 strategy: @@ -407,7 +404,6 @@ jobs: # Windows runs both blocking build surfaces concurrently through run-gates. consolidated-runner-benchmark: if: github.event_name == 'workflow_dispatch' && inputs.suite == 'consolidated-runner-benchmark' - name: consolidated / ${{ matrix.platform }} / ${{ matrix.cores }} cores runs-on: ${{ matrix.runner }} timeout-minutes: 15 strategy: @@ -556,7 +552,7 @@ jobs: name: all checks passed runs-on: ubuntu-latest needs: [node-24, node-compat, python-sdk, windows] - if: always() && (github.event_name != 'workflow_dispatch' || inputs.suite == 'optimized-larger-runners') + if: always() && github.event_name == 'pull_request' steps: - name: Fail if any needed job did not succeed if: contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') || contains(needs.*.result, 'skipped') From e844125d7d70317b81e2e4663c3d17c1585be2ab Mon Sep 17 00:00:00 2001 From: Yichen Jiang <jyc132780208@sina.com> Date: Wed, 22 Jul 2026 18:17:42 +0800 Subject: [PATCH 81/90] docs: enforce canonical website projection --- ...026-07-13-documentation-site-projection.md | 4 +- .agents/skills/dsh-doc-site-sync/SKILL.md | 2 +- scripts/project-doc-site.spec.ts | 33 ++- website/AGENTS.md | 13 ++ website/docs.ts | 2 + website/zh-CN/api/harness/commands.md | 91 -------- website/zh-CN/api/harness/goals.md | 203 ------------------ website/zh-CN/api/harness/invariants.md | 32 --- 8 files changed, 50 insertions(+), 330 deletions(-) create mode 100644 website/AGENTS.md delete mode 100644 website/zh-CN/api/harness/commands.md delete mode 100644 website/zh-CN/api/harness/goals.md delete mode 100644 website/zh-CN/api/harness/invariants.md diff --git a/.agents/notes/implemented/process/2026-07-13-documentation-site-projection.md b/.agents/notes/implemented/process/2026-07-13-documentation-site-projection.md index 44d1b17a5b..a5c15686ae 100644 --- a/.agents/notes/implemented/process/2026-07-13-documentation-site-projection.md +++ b/.agents/notes/implemented/process/2026-07-13-documentation-site-projection.md @@ -18,6 +18,8 @@ Locale home projections retain only the canonical YAML frontmatter. The reposito The projector parses Markdown links without reserializing the document. A link to another published source becomes a site-relative route; a link to an unpublished repository file becomes a GitHub source link; a repository image becomes a raw GitHub URL. Missing relative targets fail projection. Unit tests pin these transformations, and `docs:check` runs the projector tests plus a production VitePress build as part of `doc-sync` and the parallel documentation gates. +`website/AGENTS.md` is the only maintained Markdown file in the website subtree. The projector test enumerates tracked and unignored files and rejects any other website Markdown, so site-specific locale, route, API, or generated source copies cannot bypass the publication manifest. + Mermaid renders the canonical diagrams. The website workspace explicitly declares the five packages that `vitepress-plugin-mermaid` asks Vite to prebundle because pnpm's strict dependency isolation otherwise makes those transitive packages unavailable to the local development server; Knip records this runtime-only use as an intentional dependency exception. Site publication remains separate from site construction. A dedicated GitHub Actions workflow runs the existing documentation gates, uploads `website/.dist` as a Pages artifact, and deploys only after the build succeeds. `actions/configure-pages` supplies the destination's base path to VitePress at build time, so the private Pages origin, a later public project path, and a custom domain do not require distinct checked-in configurations. Pages visibility remains a repository hosting setting rather than a workflow permission. @@ -38,6 +40,6 @@ Site publication remains separate from site construction. A dedicated GitHub Act ## Consequences -Documentation facts have one editable home, public routes remain stable across source moves, and the site can include generated references without committing another generated copy. Local development watches canonical inputs and regenerates the disposable projection. Merges that affect the documentation site deploy the checked result to Pages, while manual dispatch provides a recovery and validation entry point. +Documentation facts have one editable home, public routes remain stable across source moves, and the site can include generated references without committing another generated copy. Local development watches canonical inputs and regenerates the disposable projection. The layout gate makes an obsolete site-specific Markdown tree a merge failure instead of ignored build input. Merges that affect the documentation site deploy the checked result to Pages, while manual dispatch provides a recovery and validation entry point. The publication manifest is a maintained allowlist, and link projection adds a small repository-specific build adapter. A new kind of Markdown link behavior needs a projector test. Mermaid support also increases the client bundle size, but preserves diagrams already used by the canonical documentation. diff --git a/.agents/skills/dsh-doc-site-sync/SKILL.md b/.agents/skills/dsh-doc-site-sync/SKILL.md index bee1b0dfe8..4d88d3f04f 100644 --- a/.agents/skills/dsh-doc-site-sync/SKILL.md +++ b/.agents/skills/dsh-doc-site-sync/SKILL.md @@ -24,7 +24,7 @@ Repository translations follow the sibling pairing contract: English `foo.md`, C - **Publish a generated catalog:** map the generated `docs/` file, but change its generator or source metadata rather than editing the catalog by hand. - **Change site structure:** update the manifest for ordinary pages; update VitePress configuration only when the existing sidebar, section, or locale model cannot express the change. -Never edit or commit `website/.generated/`, `website/.cache/`, or `website/.dist/`. Never copy a maintained `docs/` page into `website/`. +Never edit or commit `website/.generated/`, `website/.cache/`, or `website/.dist/`. Except for `website/AGENTS.md`, never add Markdown under `website/`; locale and route directories such as `website/zh-CN/`, `website/en/`, and `website/api/` are invalid source layouts. Keep generated catalogs under `docs/`, freshness-gate them there, and publish them through the manifest. ## Add or update a manifest entry diff --git a/scripts/project-doc-site.spec.ts b/scripts/project-doc-site.spec.ts index bd6cfb14c7..7d1c3c1550 100644 --- a/scripts/project-doc-site.spec.ts +++ b/scripts/project-doc-site.spec.ts @@ -1,13 +1,19 @@ /** Tests for the documentation website projection adapter. */ -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { execFileSync } from 'node:child_process' +import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' -import { join } from 'node:path' +import { join, resolve } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' import { docsPages, type DocsPage } from '../website/docs.ts' import { addProjectionFrontmatter, projectedPageContent, rewriteMarkdown } from './project-doc-site.ts' const roots: string[] = [] +const repositoryRoot = resolve(import.meta.dirname, '..') + +function unexpectedWebsiteMarkdown(files: readonly string[]): string[] { + return files.filter(file => file.endsWith('.md') && file !== 'website/AGENTS.md').sort() +} afterEach(() => { for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }) @@ -34,6 +40,29 @@ function fixture(): { root: string; pages: DocsPage[] } { } } +describe('website source layout', () => { + it('rejects Markdown outside the subtree instructions', () => { + expect(unexpectedWebsiteMarkdown([ + 'website/AGENTS.md', + 'website/docs.ts', + 'website/zh-CN/api/harness/service.md', + ])).toEqual(['website/zh-CN/api/harness/service.md']) + }) + + it('contains no tracked or unignored documentation copies', () => { + const files = execFileSync( + 'git', + ['ls-files', '--cached', '--others', '--exclude-standard', '--', 'website'], + { cwd: repositoryRoot, encoding: 'utf8' }, + ).split('\n').filter(file => file !== '' && existsSync(resolve(repositoryRoot, file))) + + expect( + unexpectedWebsiteMarkdown(files), + 'Keep canonical Markdown under docs/ and publish it through website/docs.ts.', + ).toEqual([]) + }) +}) + describe('rewriteMarkdown', () => { it('maps published pages and pins unpublished source links', () => { const { root, pages } = fixture() diff --git a/website/AGENTS.md b/website/AGENTS.md new file mode 100644 index 0000000000..333041fd28 --- /dev/null +++ b/website/AGENTS.md @@ -0,0 +1,13 @@ +# AGENTS.md — Documentation website adapter + +Follow the [root instructions](../AGENTS.md), the [documentation standard](../docs/AGENTS.md), and the [documentation-site sync workflow](../.agents/skills/dsh-doc-site-sync/SKILL.md). + +## Keep documentation content out of this tree + +`website/` owns only VitePress configuration, presentation assets, and the publication manifest. This file is the only maintained Markdown file in this subtree. + +Keep canonical prose and generated catalogs in their owning `docs/` tier, then expose selected pages through [docs.ts](docs.ts). Never add locale, route, API, or copied documentation trees such as `website/zh-CN/`, `website/en/`, or `website/api/`. + +The projector writes disposable Markdown to the ignored `website/.generated/` directory. Never edit or commit `.generated/`, `.cache/`, or `.dist/`. + +Run `pnpm docs:check` after changing this subtree; the gate rejects additional non-ignored Markdown under `website/`. diff --git a/website/docs.ts b/website/docs.ts index 528fa36110..51a8576b91 100644 --- a/website/docs.ts +++ b/website/docs.ts @@ -256,6 +256,7 @@ const reference = mirroredPages([ ['core.md', '核心数据结构', 'Core data structures'], ['scope.md', '作用域', 'Scopes'], ['session.md', '会话', 'Sessions'], + ['goal.md', '目标', 'Goals'], ['system-prompt.md', '系统提示词', 'System prompts'], ['tools.md', '工具', 'Tools'], ['llm-streaming.md', 'LLM 流式响应', 'LLM streaming'], @@ -268,6 +269,7 @@ const reference = mirroredPages([ ['skills.md', '技能', 'Skills'], ['approval.md', '审批', 'Approvals'], ['user-interaction.md', '用户交互', 'User interaction'], + ['commands.md', '命令', 'Human commands'], ['sandbox.md', '沙箱', 'Sandboxing'], ['web.md', 'Web 访问', 'Web access'], ['persistence.md', '会话持久化', 'Session persistence'], diff --git a/website/zh-CN/api/harness/commands.md b/website/zh-CN/api/harness/commands.md deleted file mode 100644 index 3b92682dac..0000000000 --- a/website/zh-CN/api/harness/commands.md +++ /dev/null @@ -1,91 +0,0 @@ -<!-- Generated by scripts/gen-website-api.ts — do not edit by hand. Run `pnpm run gen-website-api` to regenerate. --> - -# ctx.commands - -`CommandService` — provided by `@deepseek-ai/dsh-commands`. - -Human-command registry. Plain-context definitions are global; definitions registered through a command-injected child of an agent context shadow globals for that agent. - -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/ui/commands/src/index.ts#L207) - -### ctx.commands.register(definition) - -```ts website-api -/** - * Register a global or calling-agent-scoped command. - * @param definition - discovery metadata and direct UI handler. - * @returns the exact effect disposer that unregisters this definition. - */ -register(definition: CommandDefinition): () => void -``` - -Register a global or calling-agent-scoped command. - -- `definition` — discovery metadata and direct UI handler. - -**Returns** the exact effect disposer that unregisters this definition. - -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/ui/commands/src/index.ts#L220) - -### ctx.commands.list(agent) - -```ts website-api -/** - * List the effective immutable command descriptors for one agent. - * @param agent - exact receiving agent and scoped-layer key. - * @returns name-sorted descriptors after scoped shadowing. - */ -list(agent: Agent): readonly CommandDescriptor[] -``` - -List the effective immutable command descriptors for one agent. - -- `agent` — exact receiving agent and scoped-layer key. - -**Returns** name-sorted descriptors after scoped shadowing. - -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/ui/commands/src/index.ts#L247) - -### ctx.commands.find(agent, name) - -```ts website-api -/** - * Resolve one effective command definition. - * @param agent - exact receiving agent and scoped-layer key. - * @param name - command name without a slash. - * @returns the scoped shadow or global definition. - */ -find(agent: Agent, name: string): CommandDefinition | undefined -``` - -Resolve one effective command definition. - -- `agent` — exact receiving agent and scoped-layer key. -- `name` — command name without a slash. - -**Returns** the scoped shadow or global definition. - -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/ui/commands/src/index.ts#L260) - -### ctx.commands.execute(agent, line, signal) - -```ts website-api -/** - * Parse and execute a known command without sending it to the model. - * @param agent - exact receiving agent. - * @param line - complete slash-command line. - * @param signal - cancellation signal owned by the UI request. - * @returns a detached result, or `undefined` when syntax or name does not resolve. - */ -async execute( agent: Agent, line: string, signal: AbortSignal, ): Promise<CommandResult | undefined> -``` - -Parse and execute a known command without sending it to the model. - -- `agent` — exact receiving agent. -- `line` — complete slash-command line. -- `signal` — cancellation signal owned by the UI request. - -**Returns** a detached result, or `undefined` when syntax or name does not resolve. - -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/ui/commands/src/index.ts#L271) diff --git a/website/zh-CN/api/harness/goals.md b/website/zh-CN/api/harness/goals.md deleted file mode 100644 index 828a5eced3..0000000000 --- a/website/zh-CN/api/harness/goals.md +++ /dev/null @@ -1,203 +0,0 @@ -<!-- Generated by scripts/gen-website-api.ts — do not edit by hand. Run `pnpm run gen-website-api` to regenerate. --> - -# ctx.goals - -`GoalService` — provided by `@deepseek-ai/dsh-goal`. - -Goal service (`ctx.goals`) backed exclusively by the owning session log. - -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/goal/goal/src/index.ts#L135) - -### ctx.goals.get(agent) - -```ts website-api -/** - * Read the current goal for one exact live agent. - * @param agent - owning live agent. - * @returns a fresh view or `undefined` when no goal is current. - * @throws {@link GoalError} when the agent is not the registry's live instance. - */ -get(agent: Agent): GoalView | undefined -``` - -Read the current goal for one exact live agent. - -- `agent` — owning live agent. - -**Returns** a fresh view or `undefined` when no goal is current. - -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/goal/goal/src/index.ts#L161) - -### ctx.goals.disarm(agent) - -```ts website-api -/** - * Remove process-local continuation authority without changing durable goal - * phase or revision. Lifecycle owners use this before unloading a driver; - * a later human-authorized {@link resume} records the new activation edge. - * @param agent - owning live agent. - * @returns a fresh disarmed view, or `undefined` when no goal is current. - */ -disarm(agent: Agent): GoalView | undefined -``` - -Remove process-local continuation authority without changing durable goal phase or revision. Lifecycle owners use this before unloading a driver; a later human-authorized resume records the new activation edge. - -- `agent` — owning live agent. - -**Returns** a fresh disarmed view, or `undefined` when no goal is current. - -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/goal/goal/src/index.ts#L175) - -### ctx.goals.create(agent, request) - -```ts website-api -/** - * Create and arm a goal. A completed goal may be replaced; every other - * current phase must be cleared or resumed instead. - * @param agent - owning live agent. - * @param request - objective and optional round cap. - * @returns the created live view. - */ -create(agent: Agent, request: CreateGoalRequest): GoalView -``` - -Create and arm a goal. A completed goal may be replaced; every other current phase must be cleared or resumed instead. - -- `agent` — owning live agent. -- `request` — objective and optional round cap. - -**Returns** the created live view. - -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/goal/goal/src/index.ts#L190) - -### ctx.goals.edit(agent, ref, request) - -```ts website-api -/** - * Edit objective and/or round cap without changing phase. - * @param agent - owning live agent. - * @param ref - expected current revision. - * @param request - at least one replacement field. - * @returns the edited view. - */ -edit(agent: Agent, ref: GoalRef, request: EditGoalRequest): GoalView -``` - -Edit objective and/or round cap without changing phase. - -- `agent` — owning live agent. -- `ref` — expected current revision. -- `request` — at least one replacement field. - -**Returns** the edited view. - -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/goal/goal/src/index.ts#L215) - -### ctx.goals.pause(agent, ref) - -```ts website-api -/** - * Pause an active goal and disarm automatic continuation. - * @param agent - owning live agent. - * @param ref - expected current revision. - * @returns the paused view. - */ -pause(agent: Agent, ref: GoalRef): GoalView -``` - -Pause an active goal and disarm automatic continuation. - -- `agent` — owning live agent. -- `ref` — expected current revision. - -**Returns** the paused view. - -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/goal/goal/src/index.ts#L236) - -### ctx.goals.resume(agent, ref) - -```ts website-api -/** - * Resume and arm a stopped goal, or rearm an active goal after a - * session-start edge, while its round budget still has capacity. - * @param agent - owning live agent. - * @param ref - expected current revision. - * @returns the active view. - */ -resume(agent: Agent, ref: GoalRef): GoalView -``` - -Resume and arm a stopped goal, or rearm an active goal after a session-start edge, while its round budget still has capacity. - -- `agent` — owning live agent. -- `ref` — expected current revision. - -**Returns** the active view. - -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/goal/goal/src/index.ts#L247) - -### ctx.goals.complete(agent, ref) - -```ts website-api -/** - * Mark a current non-complete goal complete and disarm it. - * @param agent - owning live agent. - * @param ref - expected current revision. - * @returns the completed view. - */ -complete(agent: Agent, ref: GoalRef): GoalView -``` - -Mark a current non-complete goal complete and disarm it. - -- `agent` — owning live agent. -- `ref` — expected current revision. - -**Returns** the completed view. - -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/goal/goal/src/index.ts#L272) - -### ctx.goals.block(agent, ref, reason) - -```ts website-api -/** - * Mark an active goal blocked and disarm it. - * @param agent - owning live agent. - * @param ref - expected current revision. - * @param reason - policy-owned stable code and human-readable explanation. - * @returns the blocked view with its durable reason. - */ -block(agent: Agent, ref: GoalRef, reason: GoalBlockReason): GoalView -``` - -Mark an active goal blocked and disarm it. - -- `agent` — owning live agent. -- `ref` — expected current revision. -- `reason` — policy-owned stable code and human-readable explanation. - -**Returns** the blocked view with its durable reason. - -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/goal/goal/src/index.ts#L290) - -### ctx.goals.clear(agent, ref) - -```ts website-api -/** - * Clear the current goal while retaining a durable tombstone and history. - * @param agent - owning live agent. - * @param ref - expected current revision. - * @returns the tombstone ref whose revision is one past the cleared snapshot. - */ -clear(agent: Agent, ref: GoalRef): GoalRef -``` - -Clear the current goal while retaining a durable tombstone and history. - -- `agent` — owning live agent. -- `ref` — expected current revision. - -**Returns** the tombstone ref whose revision is one past the cleared snapshot. - -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/goal/goal/src/index.ts#L311) diff --git a/website/zh-CN/api/harness/invariants.md b/website/zh-CN/api/harness/invariants.md deleted file mode 100644 index e2f582a566..0000000000 --- a/website/zh-CN/api/harness/invariants.md +++ /dev/null @@ -1,32 +0,0 @@ -<!-- Generated by scripts/gen-website-api.ts — do not edit by hand. Run `pnpm run gen-website-api` to regenerate. --> - -# ctx.invariants - -`InvariantService` — provided by `@deepseek-ai/dsh-invariants`. - -Package-owned invariant registry with global and regex-based selection. - -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/support/invariants/src/index.ts#L388) - -### ctx.invariants.register(packageName, installer) - -```ts website-api -/** - * Register one package's invariant installer. The package name is reserved - * even when filtering disables its checks. Enabled installers run in a child - * fiber; failure disposes that fiber and releases the reservation. - * @param packageName - full npm package name that owns the contribution. - * @param installer - listener or startup-check installer for the child context. - * @returns an effect-scoped disposer for the registration. - */ -register(packageName: string, installer: InvariantInstaller): () => void -``` - -Register one package's invariant installer. The package name is reserved even when filtering disables its checks. Enabled installers run in a child fiber; failure disposes that fiber and releases the reservation. - -- `packageName` — full npm package name that owns the contribution. -- `installer` — listener or startup-check installer for the child context. - -**Returns** an effect-scoped disposer for the registration. - -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/support/invariants/src/index.ts#L430) From 1167e914092dac485997c7f948c9f36428c66760 Mon Sep 17 00:00:00 2001 From: ZiyaZhang <199893125+ZiyaZhang@users.noreply.github.com> Date: Wed, 22 Jul 2026 03:29:59 -0700 Subject: [PATCH 82/90] fix(tui): polish /status diagnostics card --- .../tui-agent/tests/tui-keyless-smoke.e2e.ts | 6 +- packages/ui/tui/src/index.ts | 131 ++++++++++++++---- .../status-diagnostics-narrow.expected.txt | 110 +++++++++++++++ .../snapshots/status-diagnostics.expected.txt | 116 +++++++++------- packages/ui/tui/tests/tui.snapshot.ts | 3 + packages/ui/tui/tests/tui.spec.ts | 44 +++--- 6 files changed, 307 insertions(+), 103 deletions(-) create mode 100644 packages/ui/tui/tests/snapshots/status-diagnostics-narrow.expected.txt diff --git a/examples/tui-agent/tests/tui-keyless-smoke.e2e.ts b/examples/tui-agent/tests/tui-keyless-smoke.e2e.ts index 78967abcd5..e9991b1404 100644 --- a/examples/tui-agent/tests/tui-keyless-smoke.e2e.ts +++ b/examples/tui-agent/tests/tui-keyless-smoke.e2e.ts @@ -104,7 +104,7 @@ describe('tui-agent keyless smoke (real Loader tree in a PTY)', () => { // Gating /status on it keeps the assertion race-free; the diagnostics // card is then exercised through the same real Loader/PTY composition. { waitFor: 'scripted session title — DeepSeek Harness', send: '/status\r' }, - { waitFor: 'Session diagnostics', send: '/exit\r' }, + { waitFor: 'Session status', send: '/exit\r' }, ], }) expect(output).toContain('I need one decision before I continue.') @@ -116,12 +116,12 @@ describe('tui-agent keyless smoke (real Loader tree in a PTY)', () => { expect(output).not.toContain('\u009B31mMODEL_C1') expect(output).toContain('Safe') expect(output).toContain('\u001B]0;scripted session title — DeepSeek Harness\u0007') - expect(output).toContain('Session diagnostics') + expect(output).toContain('Session status') expect(output).toContain('Title') expect(output).toContain('scripted session title') expect(output).toContain('Model') expect(output).toContain('tui-scripted/tui-scripted-model-pro') - expect(output).toContain('KV cache hit') + expect(output).toContain('KV cache') expect(output).toContain('Context') expect(output).toContain('128,000') expect(output).toContain('\u001B[?2004l') diff --git a/packages/ui/tui/src/index.ts b/packages/ui/tui/src/index.ts index b54c3c2570..cd6ab99f2b 100644 --- a/packages/ui/tui/src/index.ts +++ b/packages/ui/tui/src/index.ts @@ -945,7 +945,70 @@ function formatDiagnosticNumber(value: number): string { } function formatDiagnosticTime(value: number): string { - return new Date(value).toISOString() + return new Date(value).toISOString().replace('T', ' ').replace(/\.\d{3}Z$/u, ' UTC') +} + +function formatDiagnosticCount(value: number, singular: string): string { + return `${String(value)} ${singular}${value === 1 ? '' : 's'}` +} + +function diagnosticMeter(percent: number, palette: Palette): string { + const width = 16 + const filled = Math.round(Math.min(100, Math.max(0, percent)) / 100 * width) + return `${palette.dim('[')}${palette.accent('█'.repeat(filled))}${palette.dim(`${'░'.repeat(width - filled)}]`)}` +} + +type StatusCardRow = readonly [label: string, value: string] + +/** Bordered, grouped field card for one point-in-time status snapshot. */ +class StatusCardComponent implements Component { + constructor( + private readonly groups: readonly (readonly StatusCardRow[])[], + private readonly palette: Palette, + ) {} + + invalidate(): void {} + + render(width: number): string[] { + const labels = this.groups.flatMap(group => group.map(([label]) => `${label}:`)) + const naturalLabelWidth = Math.max(...labels.map(label => label.length)) + const naturalBodyWidth = Math.max(...this.groups.flatMap(group => group.map(([, value]) => + 1 + naturalLabelWidth + 2 + visibleWidth(value)))) + const cardWidth = Math.min( + Math.max(8, width), + Math.max('Session status'.length + 5, naturalBodyWidth + 4), + ) + const innerWidth = Math.max(1, cardWidth - 4) + const labelWidth = Math.min( + naturalLabelWidth, + Math.max(1, Math.floor(innerWidth / 3)), + ) + const body: string[] = [] + for (const [groupIndex, group] of this.groups.entries()) { + if (groupIndex > 0) body.push('') + for (const [label, value] of group) { + const plainLabel = truncateToWidth(`${label}:`, labelWidth, '') + const prefix = ` ${this.palette.muted(plainLabel.padEnd(labelWidth))} ` + const continuation = ' '.repeat(1 + labelWidth + 2) + const valueWidth = Math.max(1, innerWidth - visibleWidth(prefix)) + const wrapped = wrapTextWithAnsi(value, valueWidth) + for (const [lineIndex, line] of wrapped.entries()) { + body.push(`${lineIndex === 0 ? prefix : continuation}${line}`) + } + } + } + + const title = truncateToWidth('Session status', Math.max(1, cardWidth - 5), '') + const topTail = '─'.repeat(Math.max(0, cardWidth - visibleWidth(title) - 5)) + const top = `${this.palette.dim('╭─ ')}${this.palette.bold(this.palette.accent(title))}${this.palette.dim(` ${topTail}╮`)}` + const lines = [top] + for (const line of body) { + const clipped = truncateToWidth(line, innerWidth, '') + lines.push(`${this.palette.dim('│')} ${clipped}${' '.repeat(Math.max(0, innerWidth - visibleWidth(clipped)))} ${this.palette.dim('│')}`) + } + lines.push(this.palette.dim(`╰${'─'.repeat(Math.max(0, cardWidth - 2))}╯`)) + return lines + } } class FooterComponent implements Component { @@ -1965,35 +2028,45 @@ export function createTuiChat( const events = agent.session.events const latestActivity = events.at(-1)?.time ?? agent.session.header.createdAt const usedContext = Math.max(0, Math.round(ctx.tokenMeter.measure(agent.session).totalTokens)) - const context = contextWindow === undefined - ? `${formatDiagnosticNumber(usedContext)} used · capacity unknown` - : `${formatDiagnosticNumber(usedContext)} / ${formatDiagnosticNumber(contextWindow)} (${String(Math.round(usedContext / contextWindow * 100))}%)` + let context = `${formatDiagnosticNumber(usedContext)} used · capacity unknown` + if (contextWindow !== undefined) { + const contextPercent = Math.round(usedContext / contextWindow * 100) + context = `${diagnosticMeter(contextPercent, palette)} ${String(contextPercent)}% used (${formatDiagnosticNumber(usedContext)} / ${formatDiagnosticNumber(contextWindow)})` + } const rate = cacheHitRate(tokens) - const rows = [ - ['Session', agent.session.id], - ['Title', sessionTitle ?? 'untitled'], - ['Working dir', cwd], - ['Model', target.current === undefined ? 'unset' : targetLabel(target.current)], - ['Reasoning view', showReasoning ? 'shown' : 'hidden'], - ['Agent', agent.status], - ['Activity', [ - `events ${String(events.length)}`, - `turns ${String(events.filter(event => event.type === 'turn/start').length)}`, - `steps ${String(events.filter(event => event.type === 'step/start').length)}`, - `tool calls ${String(events.filter(event => event.type === 'tool/call').length)}`, - ].join(' · ')], - ['Tokens', `input ${formatDiagnosticNumber(tokens.input)} · output ${formatDiagnosticNumber(tokens.output)}`], - ['Cache tokens', `read ${formatDiagnosticNumber(tokens.cacheRead)} · write ${formatDiagnosticNumber(tokens.cacheWrite)}`], - ['KV cache hit', rate === undefined ? 'n/a' : `${String(rate)}%`], - ['Context', context], - ['Created', formatDiagnosticTime(agent.session.header.createdAt)], - ['Last active', formatDiagnosticTime(latestActivity)], - ] as const - const labelWidth = Math.max(...rows.map(([label]) => label.length)) - const card = new GutterBox(text => palette.accent(text), 0) - card.addChild(new Text(palette.bold(palette.accent('Session diagnostics')), 0, 0)) - card.addChild(new Text(rows.map(([label, value]) => - `${palette.muted(label.padEnd(labelWidth))} ${displayText(value)}`).join('\n'), 0, 0)) + const turns = events.filter(event => event.type === 'turn/start').length + const steps = events.filter(event => event.type === 'step/start').length + const toolCalls = events.filter(event => event.type === 'tool/call').length + const model = target.current === undefined ? 'unset' : displayText(targetLabel(target.current)) + const groups: readonly (readonly StatusCardRow[])[] = [ + [ + ['Session', displayText(agent.session.id)], + ['Title', displayText(sessionTitle ?? 'untitled')], + ['Directory', displayText(cwd)], + ['Model', `${model} ${palette.dim(`(reasoning ${showReasoning ? 'shown' : 'hidden'})`)}`], + ], + [ + ['Agent', [ + agent.status, + formatDiagnosticCount(events.length, 'event'), + formatDiagnosticCount(turns, 'turn'), + formatDiagnosticCount(steps, 'step'), + formatDiagnosticCount(toolCalls, 'tool call'), + ].join(' · ')], + ], + [ + ['Tokens', `${formatDiagnosticNumber(tokens.input)} input + ${formatDiagnosticNumber(tokens.output)} output`], + ['KV cache', rate === undefined + ? `n/a (${formatDiagnosticNumber(tokens.cacheRead)} read + ${formatDiagnosticNumber(tokens.cacheWrite)} write)` + : `${diagnosticMeter(rate, palette)} ${String(rate)}% hit (${formatDiagnosticNumber(tokens.cacheRead)} read + ${formatDiagnosticNumber(tokens.cacheWrite)} write)`], + ['Context', context], + ], + [ + ['Created', formatDiagnosticTime(agent.session.header.createdAt)], + ['Active', formatDiagnosticTime(latestActivity)], + ], + ] + const card = new StatusCardComponent(groups, palette) chat.addChild(new Spacer(1)) chat.addChild(card) requestRender() diff --git a/packages/ui/tui/tests/snapshots/status-diagnostics-narrow.expected.txt b/packages/ui/tui/tests/snapshots/status-diagnostics-narrow.expected.txt new file mode 100644 index 0000000000..de46d59543 --- /dev/null +++ b/packages/ui/tui/tests/snapshots/status-diagnostics-narrow.expected.txt @@ -0,0 +1,110 @@ +terminal 56x36 buffer=normal length=36 base=0 viewport=0 +lifecycle started=1 stopped=0 progress=inactive +title "Inspect session diagnostics — DSH snapshot" +cursor hidden column=1 viewportRow=32 bufferRow=32 +buffer +0| " DEEPSEEK HARNESS" + style 1-8 fg=bright-blue bold + style 10-16 bold +1| " Inspect session diagnostics" + style 1-27 fg=bright-black +2| " deepseek-v4-pro • main-session" + style 1-32 dim +3| <blank> +4| "▌ " + style 0-0 fg=bright-blue +5| "▌ You " + style 0-0 fg=bright-blue + style 2-4 fg=bright-blue bold +6| "▌ inspect this session " + style 0-0 fg=bright-blue +7| "▌ " + style 0-0 fg=bright-blue +8| <blank> +9| " Assistant " + style 1-9 fg=bright-magenta bold +10| " Session inspected. " +11| <blank> +12| "╭─ Session status ─────────────────────────────────────╮" + style 0-2 dim + style 3-16 fg=bright-blue bold + style 17-55 dim +13| "│ Session: main-session │" + style 0-0 dim + style 3-12 fg=bright-black + style 55-55 dim +14| "│ Title: Inspect session diagnostics │" + style 0-0 dim + style 3-12 fg=bright-black + style 55-55 dim +15| "│ Directory: /workspace/project │" + style 0-0 dim + style 3-12 fg=bright-black + style 55-55 dim +16| "│ Model: deepseek/deepseek-v4-pro (reasoning │" + style 0-0 dim + style 3-12 fg=bright-black + style 40-55 dim +17| "│ shown) │" + style 0-0 dim + style 15-20 dim + style 55-55 dim +18| "│ │" + style 0-0 dim + style 55-55 dim +19| "│ Agent: idle · 6 events · 1 turn · 1 step · 1 │" + style 0-0 dim + style 3-12 fg=bright-black + style 55-55 dim +20| "│ tool call │" + style 0-0 dim + style 55-55 dim +21| "│ │" + style 0-0 dim + style 55-55 dim +22| "│ Tokens: 1,250 input + 340 output │" + style 0-0 dim + style 3-12 fg=bright-black + style 55-55 dim +23| "│ KV cache: [███████████░░░░░] 67% hit (3,000 read │" + style 0-0 dim + style 3-12 fg=bright-black + style 15-15 dim + style 16-26 fg=bright-blue + style 27-32 dim + style 55-55 dim +24| "│ + 250 write) │" + style 0-0 dim + style 55-55 dim +25| "│ Context: [█████░░░░░░░░░░░] 33% used (42,000 / │" + style 0-0 dim + style 3-12 fg=bright-black + style 15-15 dim + style 16-20 fg=bright-blue + style 21-32 dim + style 55-55 dim +26| "│ 128,000) │" + style 0-0 dim + style 55-55 dim +27| "│ │" + style 0-0 dim + style 55-55 dim +28| "│ Created: 2026-07-22 09:10:11 UTC │" + style 0-0 dim + style 3-12 fg=bright-black + style 55-55 dim +29| "│ Active: 2026-07-22 09:10:11 UTC │" + style 0-0 dim + style 3-12 fg=bright-black + style 55-55 dim +30| "╰──────────────────────────────────────────────────────╯" + style 0-55 dim +31| "────────────────────────────────────────────────────────" + style 0-55 dim +32| " " + style 1-1 inverse +33| "────────────────────────────────────────────────────────" + style 0-55 dim +34| "deepseek-v4-pro /workspace/project ↑1.3k ↓340 cache 6" + style 0-55 dim +35| <blank> diff --git a/packages/ui/tui/tests/snapshots/status-diagnostics.expected.txt b/packages/ui/tui/tests/snapshots/status-diagnostics.expected.txt index 67fe587e4c..73d98953f6 100644 --- a/packages/ui/tui/tests/snapshots/status-diagnostics.expected.txt +++ b/packages/ui/tui/tests/snapshots/status-diagnostics.expected.txt @@ -1,7 +1,7 @@ terminal 92x32 buffer=normal length=32 base=0 viewport=0 lifecycle started=1 stopped=0 progress=inactive title "Inspect session diagnostics — DSH snapshot" -cursor hidden column=1 viewportRow=27 bufferRow=27 +cursor hidden column=1 viewportRow=28 bufferRow=28 buffer 0| " DEEPSEEK HARNESS" style 1-8 fg=bright-blue bold @@ -25,55 +25,75 @@ buffer style 1-9 fg=bright-magenta bold 10| " Session inspected. " 11| <blank> -12| "▌ Session diagnostics " - style 0-0 fg=bright-blue - style 2-20 fg=bright-blue bold -13| "▌ Session main-session " - style 0-0 fg=bright-blue - style 2-15 fg=bright-black -14| "▌ Title Inspect session diagnostics " - style 0-0 fg=bright-blue - style 2-15 fg=bright-black -15| "▌ Working dir /workspace/project " - style 0-0 fg=bright-blue - style 2-15 fg=bright-black -16| "▌ Model deepseek/deepseek-v4-pro " - style 0-0 fg=bright-blue - style 2-15 fg=bright-black -17| "▌ Reasoning view shown " - style 0-0 fg=bright-blue - style 2-15 fg=bright-black -18| "▌ Agent idle " - style 0-0 fg=bright-blue - style 2-15 fg=bright-black -19| "▌ Activity events 6 · turns 1 · steps 1 · tool calls 1 " - style 0-0 fg=bright-blue - style 2-15 fg=bright-black -20| "▌ Tokens input 1,250 · output 340 " - style 0-0 fg=bright-blue - style 2-15 fg=bright-black -21| "▌ Cache tokens read 3,000 · write 250 " - style 0-0 fg=bright-blue - style 2-15 fg=bright-black -22| "▌ KV cache hit 67% " - style 0-0 fg=bright-blue - style 2-15 fg=bright-black -23| "▌ Context 42,000 / 128,000 (33%) " - style 0-0 fg=bright-blue - style 2-15 fg=bright-black -24| "▌ Created 2026-07-22T09:10:11.000Z " - style 0-0 fg=bright-blue - style 2-15 fg=bright-black -25| "▌ Last active 2026-07-22T09:10:11.000Z " - style 0-0 fg=bright-blue - style 2-15 fg=bright-black -26| "────────────────────────────────────────────────────────────────────────────────────────────" +12| "╭─ Session status ─────────────────────────────────────────────────╮" + style 0-2 dim + style 3-16 fg=bright-blue bold + style 17-67 dim +13| "│ Session: main-session │" + style 0-0 dim + style 3-12 fg=bright-black + style 67-67 dim +14| "│ Title: Inspect session diagnostics │" + style 0-0 dim + style 3-12 fg=bright-black + style 67-67 dim +15| "│ Directory: /workspace/project │" + style 0-0 dim + style 3-12 fg=bright-black + style 67-67 dim +16| "│ Model: deepseek/deepseek-v4-pro (reasoning shown) │" + style 0-0 dim + style 3-12 fg=bright-black + style 40-56 dim + style 67-67 dim +17| "│ │" + style 0-0 dim + style 67-67 dim +18| "│ Agent: idle · 6 events · 1 turn · 1 step · 1 tool call │" + style 0-0 dim + style 3-12 fg=bright-black + style 67-67 dim +19| "│ │" + style 0-0 dim + style 67-67 dim +20| "│ Tokens: 1,250 input + 340 output │" + style 0-0 dim + style 3-12 fg=bright-black + style 67-67 dim +21| "│ KV cache: [███████████░░░░░] 67% hit (3,000 read + 250 write) │" + style 0-0 dim + style 3-12 fg=bright-black + style 15-15 dim + style 16-26 fg=bright-blue + style 27-32 dim + style 67-67 dim +22| "│ Context: [█████░░░░░░░░░░░] 33% used (42,000 / 128,000) │" + style 0-0 dim + style 3-12 fg=bright-black + style 15-15 dim + style 16-20 fg=bright-blue + style 21-32 dim + style 67-67 dim +23| "│ │" + style 0-0 dim + style 67-67 dim +24| "│ Created: 2026-07-22 09:10:11 UTC │" + style 0-0 dim + style 3-12 fg=bright-black + style 67-67 dim +25| "│ Active: 2026-07-22 09:10:11 UTC │" + style 0-0 dim + style 3-12 fg=bright-black + style 67-67 dim +26| "╰──────────────────────────────────────────────────────────────────╯" + style 0-67 dim +27| "────────────────────────────────────────────────────────────────────────────────────────────" style 0-91 dim -27| " " +28| " " style 1-1 inverse -28| "────────────────────────────────────────────────────────────────────────────────────────────" +29| "────────────────────────────────────────────────────────────────────────────────────────────" style 0-91 dim -29| "deepseek-v4-pro /workspace/project ↑1.3k ↓340 cache 67% 33% context tools:collapsed" +30| "deepseek-v4-pro /workspace/project ↑1.3k ↓340 cache 67% 33% context tools:collapsed" style 0-57 dim style 64-91 dim -30-31| <blank> +31| <blank> diff --git a/packages/ui/tui/tests/tui.snapshot.ts b/packages/ui/tui/tests/tui.snapshot.ts index e8a0897805..97617508ec 100644 --- a/packages/ui/tui/tests/tui.snapshot.ts +++ b/packages/ui/tui/tests/tui.snapshot.ts @@ -49,6 +49,7 @@ const CHECKPOINTS = [ 'disposed-terminal', 'resume-sessions', 'status-diagnostics', + 'status-diagnostics-narrow', ] as const type Checkpoint = typeof CHECKPOINTS[number] @@ -672,6 +673,8 @@ describe('TUI terminal-state snapshots', () => { harness.terminal.send('\r') }) await checkpoint('status-diagnostics', harness.terminal, { includeScrollback: true }) + await renderAfter(harness, () => { harness.terminal.resize(56, 36) }) + await checkpoint('status-diagnostics-narrow', harness.terminal, { includeScrollback: true }) await disposeSnapshot(harness) dateNow.mockRestore() }) diff --git a/packages/ui/tui/tests/tui.spec.ts b/packages/ui/tui/tests/tui.spec.ts index 02f7a0de64..d48289f46d 100644 --- a/packages/ui/tui/tests/tui.spec.ts +++ b/packages/ui/tui/tests/tui.spec.ts @@ -880,22 +880,23 @@ describe('pi-tui chat lifecycle and transcript', () => { result.terminal.send('\r') await tick() - expect(result.terminal.output).toContain('Session diagnostics') - expect(result.terminal.output).toContain('Session main-session') - expect(result.terminal.output).toContain('Title Inspect status \\x1b]2;unsafe\\x07') - expect(result.terminal.output).toContain('Working dir /workspace/status') - expect(result.terminal.output).toContain('Model deepseek/deepseek-v4-pro') - expect(result.terminal.output).toContain('Reasoning view hidden') - expect(result.terminal.output).toContain('Agent running') - expect(result.terminal.output).toContain('Activity events 6 · turns 1 · steps 1 · tool calls 2') - expect(result.terminal.output).toContain('Tokens input 1,250 · output 340') - expect(result.terminal.output).toContain('Cache tokens read 3,000 · write 250') - expect(result.terminal.output).toContain('KV cache hit 67%') - expect(result.terminal.output).toContain('Context 42,000 / 128,000 (33%)') - expect(result.terminal.output).toContain('Created 2026-07-22T09:10:11.000Z') - expect(result.terminal.output).toContain('Last active 2026-07-22T09:10:11.000Z') + expect(result.terminal.output).toContain('Session status') + expect(result.terminal.output).toContain('main-session') + expect(result.terminal.output).toContain('Inspect status \\x1b]2;unsafe\\x07') + expect(result.terminal.output).toContain('/workspace/status') + expect(result.terminal.output).toContain('deepseek/deepseek-v4-pro (reasoning hidden)') + expect(result.terminal.output).toContain('running · 6 events · 1 turn · 1 step · 2 tool calls') + expect(result.terminal.output).toContain('1,250 input + 340 output') + expect(result.terminal.output).toContain('[███████████░░░░░] 67% hit (3,000 read + 250 write)') + expect(result.terminal.output).toContain('[█████░░░░░░░░░░░] 33% used (42,000 / 128,000)') + expect(result.terminal.output).toContain('2026-07-22 09:10:11 UTC') expect(result.terminal.output).not.toContain('\u001B]2;unsafe\u0007') + result.terminal.resize(56) + result.terminal.send('/redraw') + result.terminal.send('\r') + await tick() + await dispose(result) dateNow.mockRestore() }) @@ -918,15 +919,12 @@ describe('pi-tui chat lifecycle and transcript', () => { result.terminal.send('\r') await tick() - expect(result.terminal.output).toContain('Title untitled') - expect(result.terminal.output).toContain('Model unset') - expect(result.terminal.output).toContain('Reasoning view shown') - expect(result.terminal.output).toContain('Agent idle') - expect(result.terminal.output).toContain('Activity events 0 · turns 0 · steps 0 · tool calls 0') - expect(result.terminal.output).toContain('KV cache hit n/a') - expect(result.terminal.output).toContain('Context 7 used · capacity unknown') - expect(result.terminal.output).toContain('Created 2026-07-22T10:11:12.000Z') - expect(result.terminal.output).toContain('Last active 2026-07-22T10:11:12.000Z') + expect(result.terminal.output).toContain('untitled') + expect(result.terminal.output).toContain('unset (reasoning shown)') + expect(result.terminal.output).toContain('idle · 0 events · 0 turns · 0 steps · 0 tool calls') + expect(result.terminal.output).toContain('n/a (0 read + 0 write)') + expect(result.terminal.output).toContain('7 used · capacity unknown') + expect(result.terminal.output).toContain('2026-07-22 10:11:12 UTC') await dispose(result) dateNow.mockRestore() }) From cbbef79fa3374386e9f3639ca3d4dd947cef111a Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Wed, 22 Jul 2026 19:00:55 +0800 Subject: [PATCH 83/90] ci: retune consolidated jobs for stacked workload --- ...ence-based-larger-hosted-runners.i18n.yaml | 4 +- ...22-evidence-based-larger-hosted-runners.md | 8 +- ...evidence-based-larger-hosted-runners.zh.md | 8 +- .github/workflows/ci.yml | 77 ++++++++++++------- 4 files changed, 61 insertions(+), 36 deletions(-) diff --git a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.i18n.yaml b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.i18n.yaml index d2114f1bd0..d151088a0a 100644 --- a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.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 -2026-07-22-evidence-based-larger-hosted-runners.md: bb10a53058a7ae1a46d493933868887bb2f55265 -2026-07-22-evidence-based-larger-hosted-runners.zh.md: ffaf958560dae6e044da58f910c0f53e21789344 +2026-07-22-evidence-based-larger-hosted-runners.md: 6170aca3c2675069a74d951b11273ed5d2731de8 +2026-07-22-evidence-based-larger-hosted-runners.zh.md: bd2095b17bdb954382b455bd160405a10be56ddb diff --git a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.md b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.md index bb10a53058..6170aca3c2 100644 --- a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.md +++ b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.md @@ -16,9 +16,9 @@ The organization keeps twelve x64 larger-runner pools in the repo-restricted `ds Production CI uses five larger-runner executions and one standard-runner aggregator. The primary Node inventory is not sharded: -- `node 24 / complete` uses one 96-core Linux runner. One checkout, setup, cache restore, and install feeds all 42 primary gates. `run-gates` starts up to 32 independent gates; ESLint uses 32 workers, coverage uses at most 12, and snapshot replay uses at most 16. Build starts immediately beside typecheck, coverage, lint, and documentation work, while snapshot replay and publication consumers retain explicit dependencies on emitted `lib/` output. +- `node 24 / complete` uses one 96-core Linux runner. One checkout, setup, cache restore, and install feeds all 42 primary gates. `run-gates` starts up to 16 independent gates; ESLint uses 16 workers, coverage uses at most 12, and snapshot replay uses at most 8. Build starts as soon as the first short gates release scheduler slots, while snapshot replay and publication consumers retain explicit dependencies on emitted `lib/` output. Pull requests restore pnpm and ESLint caches without saving them, so cache compression and upload do not extend the required job; the master serial references refresh those caches outside the pull-request critical path. - Node 22.19 and Node 26 use the 4- and 32-core Linux pools for their runtime compatibility smokes. Python 3.10 uses the 8-core Linux pool for the complete keyless SDK suite. These are environment contracts, not slices of the primary Node gate inventory. -- `windows node 24 / complete` uses one 32-core Windows runner. One setup feeds the required package build, required production site build, and complete observational portability inventory. Required failures fail the job; observational failures are reported as non-blocking. ESLint stays single-threaded because 16 ESLint workers took 174.54 seconds, while the outer scheduler retains 32 slots. +- `windows node 24 / complete` uses one 32-core Windows runner. One setup feeds the required package build, required production site build, and complete observational portability inventory. Required failures fail the job; observational failures are reported as non-blocking. ESLint stays single-threaded because 16 ESLint workers took 174.54 seconds, coverage uses at most 12 workers, and the outer scheduler retains 16 slots. The job restores only the small master-refreshed ESLint cache and performs a clean pnpm install instead of restoring or saving the many-file package store. The former gate-level and coarse primary shard jobs are absent from the workflow. Their static, lint, coverage, snapshot, and scenario shard selectors are also absent from the repository, so an unused diagnostic path cannot preserve a second CI architecture. @@ -38,11 +38,13 @@ The same benchmark measured the required Windows build surfaces across every pro Repository work gains little above 16 Windows cores, but the 32-core pool can start the complete outer inventory together. A [retargeted production validation](https://github.com/deepseek-harness/deepseek-harness/actions/runs/29907581119/attempts/2) completed the full one-box Windows inventory in 173 seconds, including coverage and snapshot replay, so Windows remains consolidated. +The larger client package graph makes cache mechanics and scheduler pressure part of the measured workload. In [one exact-head production run](https://github.com/deepseek-harness/deepseek-harness/actions/runs/29912577681), Linux spent 39 seconds in repository gates but 69 seconds in the complete job, while Windows spent 117 seconds in repository gates and 228 seconds in the complete job. The Windows pnpm cache downloaded its 154 MB archive in about two seconds but spent 27 seconds extracting it, followed by a 23-second install and a 14-second post-job save. A [cacheless all-size trace](https://github.com/deepseek-harness/deepseek-harness/actions/runs/29913033155) completed the same 32-core Windows install in 27 seconds. Production therefore avoids the Windows package-store cache, uses restore-only caches on latency-critical pull-request jobs, and bounds outer concurrency so typecheck, lint, coverage, and build do not oversubscribe one host. + Two host effects remain part of the decision. A standard Node 26 job once spent 36 of its 67 seconds in `Set up job`, which is why environment contracts use distinct larger-runner pools instead of standard capacity. A Linux candidate spent 18 seconds registering a 50 KB Bubblewrap package because the hosted image scanned 202,507 package-database files. [`scripts/prepare-ci-bubblewrap.sh`](../../../../scripts/prepare-ci-bubblewrap.sh) instead verifies and extracts the pinned payload into the ephemeral runner directory, runs a functional confinement probe, and overlaps that preparation with dependency installation. Coverage remains capped at 12 forks. Thirty-two forks crashed Node 24's CJS lexer twice, and a later 16-fork run reproduced the worker loss and invalid coverage result. Twelve forks preserve process headroom without becoming the single-host critical path. -The workflow retains three manual measurement suites. `suite=larger-runner-benchmark` compares isolated critical lanes across every size, `suite=consolidated-runner-benchmark` compares whole aggregates, and `suite=serial-reference` remains the unsharded cross-platform completeness oracle. `suite=optimized-larger-runners` dispatches the exact production topology against a branch ref when a pull request cannot form a merge commit. +The workflow retains two manual measurement suites. `suite=larger-runner-benchmark` compares isolated critical lanes across every size, and `suite=consolidated-runner-benchmark` compares whole aggregates. Complete serial Linux, macOS, and Windows references run only when `master` moves; pull requests run only the optimized jobs. ## Alternatives considered diff --git a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.zh.md b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.zh.md index ffaf958560..bd2095b17b 100644 --- a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.zh.md +++ b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.zh.md @@ -16,9 +16,9 @@ Status: implemented 生产 CI 包含 5 次大型运行器执行和 1 个标准运行器聚合作业。主 Node 门禁清单不再分片: -- `node 24 / complete` 使用一台 96 核 Linux 运行器。只需执行一次代码检出、设置、缓存恢复和安装,即可供全部 42 项主门禁使用。`run-gates` 最多同时启动 32 项相互独立的门禁;ESLint 使用 32 个工作线程,覆盖率最多使用 12 个,快照回放最多使用 16 个。构建会立即与类型检查、覆盖率、lint 和文档工作同时启动,而快照回放和发布消费方仍显式依赖生成的 `lib/` 输出。 +- `node 24 / complete` 使用一台 96 核 Linux 运行器。只需执行一次代码检出、设置、缓存恢复和安装,即可供全部 42 项主门禁使用。`run-gates` 最多同时启动 16 项相互独立的门禁;ESLint 使用 16 个工作线程,覆盖率最多使用 12 个,快照回放最多使用 8 个。第一批短门禁释放调度器槽位后,构建会立即启动,而快照回放和发布消费方仍显式依赖生成的 `lib/` 输出。拉取请求会恢复 pnpm 和 ESLint 缓存但不保存,因此缓存压缩和上传不会延长必需作业;master 上的串行参考会在拉取请求关键路径之外刷新这些缓存。 - Node 22.19 和 Node 26 分别使用 4 核和 32 核 Linux 池运行各自的运行时兼容性冒烟测试。Python 3.10 使用 8 核 Linux 池运行完整的无密钥 SDK 套件。这些作业属于环境契约,并非主 Node 门禁清单的分片。 -- `windows node 24 / complete` 使用一台 32 核 Windows 运行器。一次设置供必需的包构建、必需的生产网站构建以及完整的观测性可移植性清单共用。任何必需项失败都会使作业失败;观测项失败则报告为非阻塞。ESLint 保持单线程,因为 16 个 ESLint 工作线程耗时 174.54 秒,外层调度器则保留 32 个槽位。 +- `windows node 24 / complete` 使用一台 32 核 Windows 运行器。一次设置供必需的包构建、必需的生产网站构建以及完整的观测性可移植性清单共用。任何必需项失败都会使作业失败;观测项失败则报告为非阻塞。ESLint 保持单线程,因为 16 个 ESLint 工作线程耗时 174.54 秒;覆盖率最多使用 12 个工作线程,外层调度器则保留 16 个槽位。该作业仅恢复由 master 刷新的较小 ESLint 缓存,并在干净环境中执行 pnpm 安装,而不恢复或保存包含大量文件的包存储。 原有的门禁级和粗粒度主流程分片作业已从工作流中移除。相应的静态、lint、覆盖率、快照和场景分片选择器也已从仓库中移除,因此未使用的诊断路径无法继续维系第二套 CI 架构。 @@ -38,11 +38,13 @@ Status: implemented Windows 仓库工作在超过 16 核后收益很小,但 32 核池可以让完整的外层清单同时启动。一次[重新定向的生产验证](https://github.com/deepseek-harness/deepseek-harness/actions/runs/29907581119/attempts/2)在 173 秒内完成了单机 Windows 完整清单,其中包括覆盖率和快照回放,因此 Windows 继续采用合并执行方式。 +客户端包依赖图增大后,缓存机制和调度器压力也成为实测工作负载的一部分。在[一次分支头精确的生产运行](https://github.com/deepseek-harness/deepseek-harness/actions/runs/29912577681)中,Linux 的仓库门禁耗时 39 秒,完整作业耗时 69 秒;Windows 的仓库门禁耗时 117 秒,完整作业耗时 228 秒。Windows pnpm 缓存的 154 MB 归档下载耗时约 2 秒,但解压耗时 27 秒,随后安装耗时 23 秒,作业结束后的保存又耗时 14 秒。一次[无缓存的全规格运行轨迹](https://github.com/deepseek-harness/deepseek-harness/actions/runs/29913033155)在 27 秒内完成了同一台 32 核 Windows 运行器上的安装。因此,生产环境不使用 Windows 包存储缓存,在对延迟敏感的拉取请求作业中使用只恢复不保存的缓存,并限制外层并发度,以免类型检查、lint、覆盖率和构建在同一台主机上过度争用资源。 + 两项主机效应仍构成这项决策的依据。一个标准 Node 26 作业曾在总共 67 秒的耗时中,把 36 秒用在 `Set up job` 上,因此各项环境契约使用不同的大型运行器池,而非标准容量。一个 Linux 候选作业在注册 50 KB 的 Bubblewrap 包时耗时 18 秒,因为托管映像扫描了 202,507 个包数据库文件。[`scripts/prepare-ci-bubblewrap.sh`](../../../../scripts/prepare-ci-bubblewrap.sh) 改为验证固定包内容并将其解压到临时运行器目录,执行功能性隔离探针,并让这项准备工作与依赖安装重叠执行。 覆盖率仍限制为 12 个 fork。32 个 fork 曾两次导致 Node 24 的 CJS 词法分析器崩溃,后来一次使用 16 个 fork 的运行又复现了工作进程丢失和无效的覆盖率结果。12 个 fork 能保留进程余量,同时不会成为单机关键路径。 -工作流保留 3 项手动测量套件。`suite=larger-runner-benchmark` 比较所有规格下相互独立的关键通道,`suite=consolidated-runner-benchmark` 比较完整聚合流程,`suite=serial-reference` 则继续作为未分片的跨平台完整性判定基准。当拉取请求无法生成合并提交时,`suite=optimized-larger-runners` 会直接针对分支引用触发与生产环境完全相同的拓扑。 +工作流保留 2 项手动测量套件。`suite=larger-runner-benchmark` 比较所有规格下相互独立的关键通道,`suite=consolidated-runner-benchmark` 比较完整聚合流程。只有在 `master` 移动时,才运行完整的 Linux、macOS 和 Windows 串行参考;拉取请求只运行优化后的作业。 ## 曾考虑的替代方案 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a7ea422056..deba685f81 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -37,10 +37,10 @@ jobs: env: DSH_COVERAGE_MAX_WORKERS: '12' DSH_ESLINT_CACHE: '1' - DSH_ESLINT_CONCURRENCY: '32' - DSH_GATE_CONCURRENCY: '32' - DSH_PUBLINT_CONCURRENCY: '32' - DSH_SNAPSHOT_MAX_CONCURRENCY: '16' + DSH_ESLINT_CONCURRENCY: '16' + DSH_GATE_CONCURRENCY: '16' + DSH_PUBLINT_CONCURRENCY: '16' + DSH_SNAPSHOT_MAX_CONCURRENCY: '8' steps: - uses: actions/checkout@v6 @@ -54,14 +54,16 @@ jobs: corepack enable echo "path=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT" - - uses: actions/cache@v4 + # Pull requests consume the default-branch cache but do not put cache + # compression and upload on the paid latency-critical path. + - uses: actions/cache/restore@v4 with: path: ${{ steps.pnpm-store.outputs.path }} key: ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm-${{ hashFiles('pnpm-lock.yaml') }} restore-keys: | ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm- - - uses: actions/cache@v4 + - uses: actions/cache/restore@v4 with: path: .cache/eslint key: ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-eslint-full-${{ hashFiles('pnpm-lock.yaml', 'eslint.config.mjs', 'tsconfig.json', 'packages/*/*/tsconfig.json', 'examples/*/tsconfig.json') }} @@ -160,10 +162,10 @@ jobs: env: # Keep ESLint itself single-threaded: 16 ESLint workers took 174 seconds on # this image. The outer scheduler still overlaps lint with the other gates. - DSH_COVERAGE_MAX_WORKERS: '4' + DSH_COVERAGE_MAX_WORKERS: '12' DSH_ESLINT_CACHE: '1' - DSH_GATE_CONCURRENCY: '32' - DSH_PUBLINT_CONCURRENCY: '32' + DSH_GATE_CONCURRENCY: '16' + DSH_PUBLINT_CONCURRENCY: '16' steps: - uses: actions/checkout@v6 @@ -177,30 +179,20 @@ jobs: with: node-version: ${{ env.PRIMARY_NODE_VERSION }} - - name: Enable corepack and resolve pnpm store path - id: pnpm-store - shell: pwsh - run: | - corepack enable - "path=$(pnpm store path --silent)" >> $env:GITHUB_OUTPUT - - - uses: actions/cache@v4 - with: - path: ${{ steps.pnpm-store.outputs.path }} - key: ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm-${{ hashFiles('pnpm-lock.yaml') }} - restore-keys: | - ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm- - - - uses: actions/cache@v4 + - uses: actions/cache/restore@v4 with: path: .cache/eslint key: ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-eslint-full-${{ hashFiles('pnpm-lock.yaml', 'eslint.config.mjs', 'tsconfig.json', 'packages/*/*/tsconfig.json', 'examples/*/tsconfig.json') }} restore-keys: | ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-eslint-full- - - name: Install (immutable) + # Extracting the many-file pnpm store cache is slower on this image than + # a clean parallel install, and saving it adds more latency after gates. + - name: Enable corepack and install (immutable) shell: pwsh - run: pnpm install --frozen-lockfile + run: | + corepack enable + pnpm install --frozen-lockfile - name: Run blocking and observational Windows gates concurrently shell: pwsh @@ -221,8 +213,26 @@ jobs: with: node-version: ${{ env.PRIMARY_NODE_VERSION }} - - name: Enable corepack (pnpm) - run: corepack enable + - name: Enable corepack and resolve pnpm store path + id: pnpm-store + run: | + corepack enable + echo "path=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT" + + # Master refreshes the caches that pull requests restore without saving. + - uses: actions/cache@v4 + with: + path: ${{ steps.pnpm-store.outputs.path }} + key: ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm-${{ hashFiles('pnpm-lock.yaml') }} + restore-keys: | + ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm- + + - uses: actions/cache@v4 + with: + path: .cache/eslint + key: ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-eslint-full-${{ hashFiles('pnpm-lock.yaml', 'eslint.config.mjs', 'tsconfig.json', 'packages/*/*/tsconfig.json', 'examples/*/tsconfig.json') }} + restore-keys: | + ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-eslint-full- - name: Install (immutable) run: pnpm install --frozen-lockfile @@ -234,6 +244,7 @@ jobs: env: DSH_COVERAGE_MAX_WORKERS: '1' DSH_E2E_MAX_WORKERS: '1' + DSH_ESLINT_CACHE: '1' DSH_GATE_CONCURRENCY: '1' DSH_PUBLINT_CONCURRENCY: '1' DSH_SNAPSHOT_MAX_CONCURRENCY: '1' @@ -286,6 +297,15 @@ jobs: shell: pwsh run: corepack enable + # Master refreshes the small cache that pull requests restore without + # putting package-store extraction back on the Windows critical path. + - uses: actions/cache@v4 + with: + path: .cache/eslint + key: ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-eslint-full-${{ hashFiles('pnpm-lock.yaml', 'eslint.config.mjs', 'tsconfig.json', 'packages/*/*/tsconfig.json', 'examples/*/tsconfig.json') }} + restore-keys: | + ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-eslint-full- + - name: Install (immutable) shell: pwsh run: pnpm install --frozen-lockfile @@ -295,6 +315,7 @@ jobs: env: DSH_COVERAGE_MAX_WORKERS: '1' DSH_E2E_MAX_WORKERS: '1' + DSH_ESLINT_CACHE: '1' DSH_GATE_CONCURRENCY: '1' DSH_PUBLINT_CONCURRENCY: '1' DSH_SNAPSHOT_MAX_CONCURRENCY: '1' From 02f6d919c8f00afd156e5089c122bff02e98412f Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Wed, 22 Jul 2026 19:13:07 +0800 Subject: [PATCH 84/90] ci: remove primary setup overhead --- ...evidence-based-larger-hosted-runners.i18n.yaml | 4 ++-- ...-07-22-evidence-based-larger-hosted-runners.md | 2 +- ...-22-evidence-based-larger-hosted-runners.zh.md | 2 +- .github/workflows/ci.yml | 15 +++++++-------- 4 files changed, 11 insertions(+), 12 deletions(-) diff --git a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.i18n.yaml b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.i18n.yaml index d151088a0a..b53f173de2 100644 --- a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.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 -2026-07-22-evidence-based-larger-hosted-runners.md: 6170aca3c2675069a74d951b11273ed5d2731de8 -2026-07-22-evidence-based-larger-hosted-runners.zh.md: bd2095b17bdb954382b455bd160405a10be56ddb +2026-07-22-evidence-based-larger-hosted-runners.md: f395a2d702e7d0bf2ad74fe0125952da220f799c +2026-07-22-evidence-based-larger-hosted-runners.zh.md: 476c13573b2fc9484e5c20adb0c398be4dfe80d6 diff --git a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.md b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.md index 6170aca3c2..f395a2d702 100644 --- a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.md +++ b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.md @@ -16,7 +16,7 @@ The organization keeps twelve x64 larger-runner pools in the repo-restricted `ds Production CI uses five larger-runner executions and one standard-runner aggregator. The primary Node inventory is not sharded: -- `node 24 / complete` uses one 96-core Linux runner. One checkout, setup, cache restore, and install feeds all 42 primary gates. `run-gates` starts up to 16 independent gates; ESLint uses 16 workers, coverage uses at most 12, and snapshot replay uses at most 8. Build starts as soon as the first short gates release scheduler slots, while snapshot replay and publication consumers retain explicit dependencies on emitted `lib/` output. Pull requests restore pnpm and ESLint caches without saving them, so cache compression and upload do not extend the required job; the master serial references refresh those caches outside the pull-request critical path. +- `node 24 / complete` uses one 96-core Linux runner. One checkout, setup, cache restore, and install feeds all 42 primary gates. `run-gates` starts up to 12 independent gates; ESLint uses 16 workers, coverage uses at most 12, and snapshot replay uses at most 8. Build starts as soon as the first short gates release scheduler slots, while snapshot replay and publication consumers retain explicit dependencies on emitted `lib/` output. Pull requests restore pnpm and ESLint caches without saving them, so cache compression and upload do not extend the required job; the master serial references refresh those caches outside the pull-request critical path. The read-only job does not persist checkout credentials and restores the stable pnpm store root directly, avoiding a separate store-discovery step. - Node 22.19 and Node 26 use the 4- and 32-core Linux pools for their runtime compatibility smokes. Python 3.10 uses the 8-core Linux pool for the complete keyless SDK suite. These are environment contracts, not slices of the primary Node gate inventory. - `windows node 24 / complete` uses one 32-core Windows runner. One setup feeds the required package build, required production site build, and complete observational portability inventory. Required failures fail the job; observational failures are reported as non-blocking. ESLint stays single-threaded because 16 ESLint workers took 174.54 seconds, coverage uses at most 12 workers, and the outer scheduler retains 16 slots. The job restores only the small master-refreshed ESLint cache and performs a clean pnpm install instead of restoring or saving the many-file package store. diff --git a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.zh.md b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.zh.md index bd2095b17b..476c13573b 100644 --- a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.zh.md +++ b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.zh.md @@ -16,7 +16,7 @@ Status: implemented 生产 CI 包含 5 次大型运行器执行和 1 个标准运行器聚合作业。主 Node 门禁清单不再分片: -- `node 24 / complete` 使用一台 96 核 Linux 运行器。只需执行一次代码检出、设置、缓存恢复和安装,即可供全部 42 项主门禁使用。`run-gates` 最多同时启动 16 项相互独立的门禁;ESLint 使用 16 个工作线程,覆盖率最多使用 12 个,快照回放最多使用 8 个。第一批短门禁释放调度器槽位后,构建会立即启动,而快照回放和发布消费方仍显式依赖生成的 `lib/` 输出。拉取请求会恢复 pnpm 和 ESLint 缓存但不保存,因此缓存压缩和上传不会延长必需作业;master 上的串行参考会在拉取请求关键路径之外刷新这些缓存。 +- `node 24 / complete` 使用一台 96 核 Linux 运行器。只需执行一次代码检出、设置、缓存恢复和安装,即可供全部 42 项主门禁使用。`run-gates` 最多同时启动 12 项相互独立的门禁;ESLint 使用 16 个工作线程,覆盖率最多使用 12 个,快照回放最多使用 8 个。第一批短门禁释放调度器槽位后,构建会立即启动,而快照回放和发布消费方仍显式依赖生成的 `lib/` 输出。拉取请求会恢复 pnpm 和 ESLint 缓存但不保存,因此缓存压缩和上传不会延长必需作业;master 上的串行参考会在拉取请求关键路径之外刷新这些缓存。该只读作业不会持久化代码检出凭据,并直接恢复固定的 pnpm 存储根目录,因此无需再单独执行存储路径探测步骤。 - Node 22.19 和 Node 26 分别使用 4 核和 32 核 Linux 池运行各自的运行时兼容性冒烟测试。Python 3.10 使用 8 核 Linux 池运行完整的无密钥 SDK 套件。这些作业属于环境契约,并非主 Node 门禁清单的分片。 - `windows node 24 / complete` 使用一台 32 核 Windows 运行器。一次设置供必需的包构建、必需的生产网站构建以及完整的观测性可移植性清单共用。任何必需项失败都会使作业失败;观测项失败则报告为非阻塞。ESLint 保持单线程,因为 16 个 ESLint 工作线程耗时 174.54 秒;覆盖率最多使用 12 个工作线程,外层调度器则保留 16 个槽位。该作业仅恢复由 master 刷新的较小 ESLint 缓存,并在干净环境中执行 pnpm 安装,而不恢复或保存包含大量文件的包存储。 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index deba685f81..ffc95ea849 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -38,27 +38,25 @@ jobs: DSH_COVERAGE_MAX_WORKERS: '12' DSH_ESLINT_CACHE: '1' DSH_ESLINT_CONCURRENCY: '16' - DSH_GATE_CONCURRENCY: '16' + DSH_GATE_CONCURRENCY: '12' DSH_PUBLINT_CONCURRENCY: '16' DSH_SNAPSHOT_MAX_CONCURRENCY: '8' steps: - uses: actions/checkout@v6 + with: + persist-credentials: false - uses: actions/setup-node@v6 with: node-version: ${{ env.PRIMARY_NODE_VERSION }} - - name: Enable corepack and resolve pnpm store path - id: pnpm-store - run: | - corepack enable - echo "path=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT" - # Pull requests consume the default-branch cache but do not put cache # compression and upload on the paid latency-critical path. - uses: actions/cache/restore@v4 with: - path: ${{ steps.pnpm-store.outputs.path }} + # Cache the versioned store through its stable parent so selecting + # the current pnpm store does not require a separate runner step. + path: ~/.local/share/pnpm/store key: ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm-${{ hashFiles('pnpm-lock.yaml') }} restore-keys: | ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm- @@ -72,6 +70,7 @@ jobs: - name: Install dependencies and prepare bubblewrap run: | + corepack enable pnpm install --frozen-lockfile & install_pid=$! bash scripts/prepare-ci-bubblewrap.sh & From 17983d72bb525dc44852ede33b20cf41c23dce15 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Wed, 22 Jul 2026 19:37:45 +0800 Subject: [PATCH 85/90] ci: use process-safe coverage pools --- ...ence-based-larger-hosted-runners.i18n.yaml | 4 +- ...22-evidence-based-larger-hosted-runners.md | 4 +- ...evidence-based-larger-hosted-runners.zh.md | 4 +- .github/workflows/ci.yml | 11 +++-- vitest.config.ts | 44 ++++++++++++++++++- 5 files changed, 56 insertions(+), 11 deletions(-) diff --git a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.i18n.yaml b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.i18n.yaml index b53f173de2..3561f39a33 100644 --- a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.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 -2026-07-22-evidence-based-larger-hosted-runners.md: f395a2d702e7d0bf2ad74fe0125952da220f799c -2026-07-22-evidence-based-larger-hosted-runners.zh.md: 476c13573b2fc9484e5c20adb0c398be4dfe80d6 +2026-07-22-evidence-based-larger-hosted-runners.md: 2fac4d1c14e3f1eabef9f915e53ff737c0e7ee20 +2026-07-22-evidence-based-larger-hosted-runners.zh.md: 33ca3b823d2bf235f9896b92868bd7a1f0688a76 diff --git a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.md b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.md index f395a2d702..2fac4d1c14 100644 --- a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.md +++ b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.md @@ -16,7 +16,7 @@ The organization keeps twelve x64 larger-runner pools in the repo-restricted `ds Production CI uses five larger-runner executions and one standard-runner aggregator. The primary Node inventory is not sharded: -- `node 24 / complete` uses one 96-core Linux runner. One checkout, setup, cache restore, and install feeds all 42 primary gates. `run-gates` starts up to 12 independent gates; ESLint uses 16 workers, coverage uses at most 12, and snapshot replay uses at most 8. Build starts as soon as the first short gates release scheduler slots, while snapshot replay and publication consumers retain explicit dependencies on emitted `lib/` output. Pull requests restore pnpm and ESLint caches without saving them, so cache compression and upload do not extend the required job; the master serial references refresh those caches outside the pull-request critical path. The read-only job does not persist checkout credentials and restores the stable pnpm store root directly, avoiding a separate store-discovery step. +- `node 24 / complete` uses one 96-core Linux runner. One checkout, setup, cache restore, and install feeds all 42 primary gates. `run-gates` starts up to 12 independent gates; ESLint uses 16 workers, coverage uses at most 12, and snapshot replay uses at most 8. Build starts as soon as the first short gates release scheduler slots, while snapshot replay and publication consumers retain explicit dependencies on emitted `lib/` output. Pull requests restore pnpm and ESLint caches without saving them, so cache compression and upload do not extend the required job; the master serial references refresh those caches outside the pull-request critical path. The read-only job does not persist checkout credentials. - Node 22.19 and Node 26 use the 4- and 32-core Linux pools for their runtime compatibility smokes. Python 3.10 uses the 8-core Linux pool for the complete keyless SDK suite. These are environment contracts, not slices of the primary Node gate inventory. - `windows node 24 / complete` uses one 32-core Windows runner. One setup feeds the required package build, required production site build, and complete observational portability inventory. Required failures fail the job; observational failures are reported as non-blocking. ESLint stays single-threaded because 16 ESLint workers took 174.54 seconds, coverage uses at most 12 workers, and the outer scheduler retains 16 slots. The job restores only the small master-refreshed ESLint cache and performs a clean pnpm install instead of restoring or saving the many-file package store. @@ -42,7 +42,7 @@ The larger client package graph makes cache mechanics and scheduler pressure par Two host effects remain part of the decision. A standard Node 26 job once spent 36 of its 67 seconds in `Set up job`, which is why environment contracts use distinct larger-runner pools instead of standard capacity. A Linux candidate spent 18 seconds registering a 50 KB Bubblewrap package because the hosted image scanned 202,507 package-database files. [`scripts/prepare-ci-bubblewrap.sh`](../../../../scripts/prepare-ci-bubblewrap.sh) instead verifies and extracts the pinned payload into the ephemeral runner directory, runs a functional confinement probe, and overlaps that preparation with dependency installation. -Coverage remains capped at 12 forks. Thirty-two forks crashed Node 24's CJS lexer twice, and a later 16-fork run reproduced the worker loss and invalid coverage result. Twelve forks preserve process headroom without becoming the single-host critical path. +Each coverage project is capped at 12 workers, and the process-bound project contains exactly four suite files. Thirty-two forks crashed Node 24's CJS lexer twice, and a later 16-fork run reproduced the worker loss and invalid coverage result. The single Vitest invocation therefore uses threads for the broad inventory and reserves forks for suites that exercise process-global state or `process` APIs. That narrow fork inventory includes the local bash process-plumbing suite: under aggregate gate contention its thread worker completed every test but intermittently missed the stdin-error callback needed for per-file function coverage. This preserves the suites' isolation contracts and deterministic coverage while avoiding forked execution for ordinary test files. The workflow retains two manual measurement suites. `suite=larger-runner-benchmark` compares isolated critical lanes across every size, and `suite=consolidated-runner-benchmark` compares whole aggregates. Complete serial Linux, macOS, and Windows references run only when `master` moves; pull requests run only the optimized jobs. diff --git a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.zh.md b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.zh.md index 476c13573b..33ca3b823d 100644 --- a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.zh.md +++ b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.zh.md @@ -16,7 +16,7 @@ Status: implemented 生产 CI 包含 5 次大型运行器执行和 1 个标准运行器聚合作业。主 Node 门禁清单不再分片: -- `node 24 / complete` 使用一台 96 核 Linux 运行器。只需执行一次代码检出、设置、缓存恢复和安装,即可供全部 42 项主门禁使用。`run-gates` 最多同时启动 12 项相互独立的门禁;ESLint 使用 16 个工作线程,覆盖率最多使用 12 个,快照回放最多使用 8 个。第一批短门禁释放调度器槽位后,构建会立即启动,而快照回放和发布消费方仍显式依赖生成的 `lib/` 输出。拉取请求会恢复 pnpm 和 ESLint 缓存但不保存,因此缓存压缩和上传不会延长必需作业;master 上的串行参考会在拉取请求关键路径之外刷新这些缓存。该只读作业不会持久化代码检出凭据,并直接恢复固定的 pnpm 存储根目录,因此无需再单独执行存储路径探测步骤。 +- `node 24 / complete` 使用一台 96 核 Linux 运行器。只需执行一次代码检出、设置、缓存恢复和安装,即可供全部 42 项主门禁使用。`run-gates` 最多同时启动 12 项相互独立的门禁;ESLint 使用 16 个工作线程,覆盖率最多使用 12 个,快照回放最多使用 8 个。第一批短门禁释放调度器槽位后,构建会立即启动,而快照回放和发布消费方仍显式依赖生成的 `lib/` 输出。拉取请求会恢复 pnpm 和 ESLint 缓存但不保存,因此缓存压缩和上传不会延长必需作业;master 上的串行参考会在拉取请求关键路径之外刷新这些缓存。该只读作业不会持久化代码检出凭据。 - Node 22.19 和 Node 26 分别使用 4 核和 32 核 Linux 池运行各自的运行时兼容性冒烟测试。Python 3.10 使用 8 核 Linux 池运行完整的无密钥 SDK 套件。这些作业属于环境契约,并非主 Node 门禁清单的分片。 - `windows node 24 / complete` 使用一台 32 核 Windows 运行器。一次设置供必需的包构建、必需的生产网站构建以及完整的观测性可移植性清单共用。任何必需项失败都会使作业失败;观测项失败则报告为非阻塞。ESLint 保持单线程,因为 16 个 ESLint 工作线程耗时 174.54 秒;覆盖率最多使用 12 个工作线程,外层调度器则保留 16 个槽位。该作业仅恢复由 master 刷新的较小 ESLint 缓存,并在干净环境中执行 pnpm 安装,而不恢复或保存包含大量文件的包存储。 @@ -42,7 +42,7 @@ Windows 仓库工作在超过 16 核后收益很小,但 32 核池可以让完 两项主机效应仍构成这项决策的依据。一个标准 Node 26 作业曾在总共 67 秒的耗时中,把 36 秒用在 `Set up job` 上,因此各项环境契约使用不同的大型运行器池,而非标准容量。一个 Linux 候选作业在注册 50 KB 的 Bubblewrap 包时耗时 18 秒,因为托管映像扫描了 202,507 个包数据库文件。[`scripts/prepare-ci-bubblewrap.sh`](../../../../scripts/prepare-ci-bubblewrap.sh) 改为验证固定包内容并将其解压到临时运行器目录,执行功能性隔离探针,并让这项准备工作与依赖安装重叠执行。 -覆盖率仍限制为 12 个 fork。32 个 fork 曾两次导致 Node 24 的 CJS 词法分析器崩溃,后来一次使用 16 个 fork 的运行又复现了工作进程丢失和无效的覆盖率结果。12 个 fork 能保留进程余量,同时不会成为单机关键路径。 +每个覆盖率项目均将工作线程上限设为 12 个,其中进程约束项目恰好包含 4 个套件文件。32 个 fork 曾两次导致 Node 24 的 CJS 词法分析器崩溃,后来一次使用 16 个 fork 的运行又复现了工作进程丢失和无效的覆盖率结果。因此,单次 Vitest 调用会对大范围测试清单使用线程,只为涉及进程全局状态或 `process` API 的套件保留 fork。这份有限的 fork 清单还包含本地 bash 进程通路套件:在聚合门禁争用资源时,该套件的工作线程虽然完成并通过了所有测试,却会间歇性漏记逐文件函数覆盖率所需的 stdin 错误回调。这样既能保留这些套件的隔离契约和覆盖率结果的确定性,又能避免以 fork 方式执行普通测试文件。 工作流保留 2 项手动测量套件。`suite=larger-runner-benchmark` 比较所有规格下相互独立的关键通道,`suite=consolidated-runner-benchmark` 比较完整聚合流程。只有在 `master` 移动时,才运行完整的 Linux、macOS 和 Windows 串行参考;拉取请求只运行优化后的作业。 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ffc95ea849..4a0476c824 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -50,13 +50,17 @@ jobs: with: node-version: ${{ env.PRIMARY_NODE_VERSION }} + - name: Enable corepack and resolve pnpm store path + id: pnpm-store + run: | + corepack enable + echo "path=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT" + # Pull requests consume the default-branch cache but do not put cache # compression and upload on the paid latency-critical path. - uses: actions/cache/restore@v4 with: - # Cache the versioned store through its stable parent so selecting - # the current pnpm store does not require a separate runner step. - path: ~/.local/share/pnpm/store + path: ${{ steps.pnpm-store.outputs.path }} key: ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm-${{ hashFiles('pnpm-lock.yaml') }} restore-keys: | ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm- @@ -70,7 +74,6 @@ jobs: - name: Install dependencies and prepare bubblewrap run: | - corepack enable pnpm install --frozen-lockfile & install_pid=$! bash scripts/prepare-ci-bubblewrap.sh & diff --git a/vitest.config.ts b/vitest.config.ts index dc9061c45a..51ced4b19f 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -22,6 +22,21 @@ const windowsCoverageExclusions = process.platform === 'win32' ] : [] +const testIncludes = [ + 'packages/*/*/tests/**/*.spec.{ts,tsx}', + 'examples/*/tests/**/*.spec.ts', + 'scripts/**/*.spec.ts', +] + +// These suites exercise process-global state or process APIs that worker threads cannot isolate. +// Keep the narrow exception in forks while the rest of the inventory avoids per-file processes. +const processBoundTests = [ + 'packages/bash/bash-local/tests/run.spec.ts', + 'packages/context/time-context/tests/time-context.spec.ts', + 'packages/ui/app-boot/tests/app-boot.spec.ts', + 'packages/workflow/workflow-workerthread/tests/session.spec.ts', +] + export default defineConfig({ // Native path resolution reads each package's nearest tsconfig, but only the root defines // workspace paths. Keep this plugin pinned to the root map so bare package imports resolve @@ -32,8 +47,35 @@ export default defineConfig({ test: { setupFiles: ['./scripts/test-invariants.ts'], // .tsx: client component specs (jsdom via per-file @vitest-environment pragma). - include: ['packages/*/*/tests/**/*.spec.{ts,tsx}', 'examples/*/tests/**/*.spec.ts', 'scripts/**/*.spec.ts'], + include: testIncludes, exclude: windowsUnsupportedPackages.map(path => `${path}/tests/**/*.spec.ts`), + // One coverage invocation aggregates both projects. Most suites use threads + // for lower startup/IPC overhead; only explicit process-bound suites fork. + projects: [ + { + plugins: [tsconfigPaths({ projects: ['./tsconfig.vitest.json'] })], + test: { + name: 'thread-safe', + pool: 'threads', + setupFiles: ['./scripts/test-invariants.ts'], + include: testIncludes, + exclude: [ + ...windowsUnsupportedPackages.map(path => `${path}/tests/**/*.spec.ts`), + ...processBoundTests, + ], + }, + }, + { + plugins: [tsconfigPaths({ projects: ['./tsconfig.vitest.json'] })], + test: { + name: 'process-bound', + pool: 'forks', + setupFiles: ['./scripts/test-invariants.ts'], + include: processBoundTests, + exclude: windowsUnsupportedPackages.map(path => `${path}/tests/**/*.spec.ts`), + }, + }, + ], coverage: { provider: 'v8', // Coverage measures OUR runtime source. Types-only files carry no From a952aaed72de940e27dcf44db9e142a9ee3db366 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Wed, 22 Jul 2026 20:00:15 +0800 Subject: [PATCH 86/90] ci: trim remaining hosted setup --- ...vidence-based-larger-hosted-runners.i18n.yaml | 4 ++-- ...07-22-evidence-based-larger-hosted-runners.md | 6 +++--- ...22-evidence-based-larger-hosted-runners.zh.md | 6 +++--- .github/workflows/ci.yml | 16 +--------------- 4 files changed, 9 insertions(+), 23 deletions(-) diff --git a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.i18n.yaml b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.i18n.yaml index 3561f39a33..7ad1361050 100644 --- a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.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 -2026-07-22-evidence-based-larger-hosted-runners.md: 2fac4d1c14e3f1eabef9f915e53ff737c0e7ee20 -2026-07-22-evidence-based-larger-hosted-runners.zh.md: 33ca3b823d2bf235f9896b92868bd7a1f0688a76 +2026-07-22-evidence-based-larger-hosted-runners.md: 959ee8516ca4a4990ca4565966d637cfa84d8d7a +2026-07-22-evidence-based-larger-hosted-runners.zh.md: 52f5394d99c4fe9897653b43f9ba6f8ef2cbadfe diff --git a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.md b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.md index 2fac4d1c14..959ee8516c 100644 --- a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.md +++ b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.md @@ -16,9 +16,9 @@ The organization keeps twelve x64 larger-runner pools in the repo-restricted `ds Production CI uses five larger-runner executions and one standard-runner aggregator. The primary Node inventory is not sharded: -- `node 24 / complete` uses one 96-core Linux runner. One checkout, setup, cache restore, and install feeds all 42 primary gates. `run-gates` starts up to 12 independent gates; ESLint uses 16 workers, coverage uses at most 12, and snapshot replay uses at most 8. Build starts as soon as the first short gates release scheduler slots, while snapshot replay and publication consumers retain explicit dependencies on emitted `lib/` output. Pull requests restore pnpm and ESLint caches without saving them, so cache compression and upload do not extend the required job; the master serial references refresh those caches outside the pull-request critical path. The read-only job does not persist checkout credentials. +- `node 24 / complete` uses one 96-core Linux runner. One checkout, setup, pnpm-cache restore, and install feeds all 42 primary gates. `run-gates` starts up to 12 independent gates; ESLint and coverage use at most 16 workers, and snapshot replay uses at most 8. Build starts as soon as the first short gates release scheduler slots, while snapshot replay and publication consumers retain explicit dependencies on emitted `lib/` output. Pull requests restore the pnpm cache without saving it, so cache compression and upload do not extend the required job; the master serial reference refreshes that cache outside the pull-request critical path. ESLint remains well below the critical path without a separate cache action. The read-only job does not persist checkout credentials. - Node 22.19 and Node 26 use the 4- and 32-core Linux pools for their runtime compatibility smokes. Python 3.10 uses the 8-core Linux pool for the complete keyless SDK suite. These are environment contracts, not slices of the primary Node gate inventory. -- `windows node 24 / complete` uses one 32-core Windows runner. One setup feeds the required package build, required production site build, and complete observational portability inventory. Required failures fail the job; observational failures are reported as non-blocking. ESLint stays single-threaded because 16 ESLint workers took 174.54 seconds, coverage uses at most 12 workers, and the outer scheduler retains 16 slots. The job restores only the small master-refreshed ESLint cache and performs a clean pnpm install instead of restoring or saving the many-file package store. +- `windows node 24 / complete` uses one 32-core Windows runner. One setup feeds the required package build, required production site build, and complete observational portability inventory. Required failures fail the job; observational failures are reported as non-blocking. ESLint stays single-threaded because 16 ESLint workers took 174.54 seconds, coverage uses at most 12 workers, and the outer scheduler retains 16 slots. The job restores only the small master-refreshed ESLint cache and performs a clean pnpm install instead of restoring or saving the many-file package store. All six Windows larger-runner sizes completed install and the production-site benchmark without mutating the machine-wide Developer Mode registry key, so the pull-request critical path omits that redundant step. The former gate-level and coarse primary shard jobs are absent from the workflow. Their static, lint, coverage, snapshot, and scenario shard selectors are also absent from the repository, so an unused diagnostic path cannot preserve a second CI architecture. @@ -42,7 +42,7 @@ The larger client package graph makes cache mechanics and scheduler pressure par Two host effects remain part of the decision. A standard Node 26 job once spent 36 of its 67 seconds in `Set up job`, which is why environment contracts use distinct larger-runner pools instead of standard capacity. A Linux candidate spent 18 seconds registering a 50 KB Bubblewrap package because the hosted image scanned 202,507 package-database files. [`scripts/prepare-ci-bubblewrap.sh`](../../../../scripts/prepare-ci-bubblewrap.sh) instead verifies and extracts the pinned payload into the ephemeral runner directory, runs a functional confinement probe, and overlaps that preparation with dependency installation. -Each coverage project is capped at 12 workers, and the process-bound project contains exactly four suite files. Thirty-two forks crashed Node 24's CJS lexer twice, and a later 16-fork run reproduced the worker loss and invalid coverage result. The single Vitest invocation therefore uses threads for the broad inventory and reserves forks for suites that exercise process-global state or `process` APIs. That narrow fork inventory includes the local bash process-plumbing suite: under aggregate gate contention its thread worker completed every test but intermittently missed the stdin-error callback needed for per-file function coverage. This preserves the suites' isolation contracts and deterministic coverage while avoiding forked execution for ordinary test files. +Linux coverage caps each project at 16 workers, while Windows keeps the 12-worker cap. The process-bound project contains exactly four suite files, so its fork count cannot reach either cap. Thirty-two forks crashed Node 24's CJS lexer twice, and a later 16-fork run reproduced the worker loss and invalid coverage result. The single Vitest invocation therefore uses threads for the broad inventory and reserves forks for suites that exercise process-global state or `process` APIs. That narrow fork inventory includes the local bash process-plumbing suite: under aggregate gate contention its thread worker completed every test but intermittently missed the stdin-error callback needed for per-file function coverage. A 32-worker all-gate run on the 96-core host slowed coverage to 44.6 seconds and made a compute-budget regression cross its one-second threshold, so production stops at 16. This preserves the suites' isolation contracts and deterministic coverage while avoiding forked execution for ordinary test files. The workflow retains two manual measurement suites. `suite=larger-runner-benchmark` compares isolated critical lanes across every size, and `suite=consolidated-runner-benchmark` compares whole aggregates. Complete serial Linux, macOS, and Windows references run only when `master` moves; pull requests run only the optimized jobs. diff --git a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.zh.md b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.zh.md index 33ca3b823d..52f5394d99 100644 --- a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.zh.md +++ b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.zh.md @@ -16,9 +16,9 @@ Status: implemented 生产 CI 包含 5 次大型运行器执行和 1 个标准运行器聚合作业。主 Node 门禁清单不再分片: -- `node 24 / complete` 使用一台 96 核 Linux 运行器。只需执行一次代码检出、设置、缓存恢复和安装,即可供全部 42 项主门禁使用。`run-gates` 最多同时启动 12 项相互独立的门禁;ESLint 使用 16 个工作线程,覆盖率最多使用 12 个,快照回放最多使用 8 个。第一批短门禁释放调度器槽位后,构建会立即启动,而快照回放和发布消费方仍显式依赖生成的 `lib/` 输出。拉取请求会恢复 pnpm 和 ESLint 缓存但不保存,因此缓存压缩和上传不会延长必需作业;master 上的串行参考会在拉取请求关键路径之外刷新这些缓存。该只读作业不会持久化代码检出凭据。 +- `node 24 / complete` 使用一台 96 核 Linux 运行器。只需执行一次代码检出、设置、pnpm 缓存恢复和安装,即可供全部 42 项主门禁使用。`run-gates` 最多同时启动 12 项相互独立的门禁;ESLint 和覆盖率最多使用 16 个工作线程,快照回放最多使用 8 个。第一批短门禁释放调度器槽位后,构建会立即启动,而快照回放和发布消费方仍显式依赖生成的 `lib/` 输出。拉取请求会恢复 pnpm 缓存但不保存,因此缓存压缩和上传不会延长必需作业;master 上的串行参考会在拉取请求关键路径之外刷新该缓存。无需单独执行缓存操作,ESLint 的耗时仍远低于关键路径耗时。该只读作业不会持久化代码检出凭据。 - Node 22.19 和 Node 26 分别使用 4 核和 32 核 Linux 池运行各自的运行时兼容性冒烟测试。Python 3.10 使用 8 核 Linux 池运行完整的无密钥 SDK 套件。这些作业属于环境契约,并非主 Node 门禁清单的分片。 -- `windows node 24 / complete` 使用一台 32 核 Windows 运行器。一次设置供必需的包构建、必需的生产网站构建以及完整的观测性可移植性清单共用。任何必需项失败都会使作业失败;观测项失败则报告为非阻塞。ESLint 保持单线程,因为 16 个 ESLint 工作线程耗时 174.54 秒;覆盖率最多使用 12 个工作线程,外层调度器则保留 16 个槽位。该作业仅恢复由 master 刷新的较小 ESLint 缓存,并在干净环境中执行 pnpm 安装,而不恢复或保存包含大量文件的包存储。 +- `windows node 24 / complete` 使用一台 32 核 Windows 运行器。一次设置供必需的包构建、必需的生产网站构建以及完整的观测性可移植性清单共用。任何必需项失败都会使作业失败;观测项失败则报告为非阻塞。ESLint 保持单线程,因为 16 个 ESLint 工作线程耗时 174.54 秒;覆盖率最多使用 12 个工作线程,外层调度器则保留 16 个槽位。该作业仅恢复由 master 刷新的较小 ESLint 缓存,并在干净环境中执行 pnpm 安装,而不恢复或保存包含大量文件的包存储。全部 6 种 Windows 大型运行器规格都在未修改系统级 Developer Mode 注册表项的情况下完成了安装和生产网站基准测试,因此拉取请求关键路径省略了这个多余步骤。 原有的门禁级和粗粒度主流程分片作业已从工作流中移除。相应的静态、lint、覆盖率、快照和场景分片选择器也已从仓库中移除,因此未使用的诊断路径无法继续维系第二套 CI 架构。 @@ -42,7 +42,7 @@ Windows 仓库工作在超过 16 核后收益很小,但 32 核池可以让完 两项主机效应仍构成这项决策的依据。一个标准 Node 26 作业曾在总共 67 秒的耗时中,把 36 秒用在 `Set up job` 上,因此各项环境契约使用不同的大型运行器池,而非标准容量。一个 Linux 候选作业在注册 50 KB 的 Bubblewrap 包时耗时 18 秒,因为托管映像扫描了 202,507 个包数据库文件。[`scripts/prepare-ci-bubblewrap.sh`](../../../../scripts/prepare-ci-bubblewrap.sh) 改为验证固定包内容并将其解压到临时运行器目录,执行功能性隔离探针,并让这项准备工作与依赖安装重叠执行。 -每个覆盖率项目均将工作线程上限设为 12 个,其中进程约束项目恰好包含 4 个套件文件。32 个 fork 曾两次导致 Node 24 的 CJS 词法分析器崩溃,后来一次使用 16 个 fork 的运行又复现了工作进程丢失和无效的覆盖率结果。因此,单次 Vitest 调用会对大范围测试清单使用线程,只为涉及进程全局状态或 `process` API 的套件保留 fork。这份有限的 fork 清单还包含本地 bash 进程通路套件:在聚合门禁争用资源时,该套件的工作线程虽然完成并通过了所有测试,却会间歇性漏记逐文件函数覆盖率所需的 stdin 错误回调。这样既能保留这些套件的隔离契约和覆盖率结果的确定性,又能避免以 fork 方式执行普通测试文件。 +Linux 覆盖率把每个项目的工作线程上限设为 16 个,Windows 则保留 12 个工作线程的上限。进程约束项目恰好包含 4 个套件文件,因此它的 fork 数量不可能达到任一上限。32 个 fork 曾两次导致 Node 24 的 CJS 词法分析器崩溃,后来一次使用 16 个 fork 的运行又复现了工作进程丢失和无效的覆盖率结果。因此,单次 Vitest 调用会对大范围测试清单使用线程,只为涉及进程全局状态或 `process` API 的套件保留 fork。这份有限的 fork 清单还包含本地 bash 进程通路套件:在聚合门禁争用资源时,该套件的工作线程虽然完成了所有测试,却会间歇性漏记逐文件函数覆盖率所需的 stdin 错误回调。在 96 核主机上使用 32 个工作线程运行全部门禁时,覆盖率耗时变慢至 44.6 秒,还使一项计算预算回归超过其 1 秒阈值,因此生产环境将工作线程数限制在 16 个以内。这样既能保留这些套件的隔离契约和覆盖率结果的确定性,又能避免以 fork 方式执行普通测试文件。 工作流保留 2 项手动测量套件。`suite=larger-runner-benchmark` 比较所有规格下相互独立的关键通道,`suite=consolidated-runner-benchmark` 比较完整聚合流程。只有在 `master` 移动时,才运行完整的 Linux、macOS 和 Windows 串行参考;拉取请求只运行优化后的作业。 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4a0476c824..3d3f329018 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -35,8 +35,7 @@ jobs: runs-on: dsh-ubuntu-24-04-96core name: node 24 / complete env: - DSH_COVERAGE_MAX_WORKERS: '12' - DSH_ESLINT_CACHE: '1' + DSH_COVERAGE_MAX_WORKERS: '16' DSH_ESLINT_CONCURRENCY: '16' DSH_GATE_CONCURRENCY: '12' DSH_PUBLINT_CONCURRENCY: '16' @@ -65,13 +64,6 @@ jobs: restore-keys: | ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm- - - uses: actions/cache/restore@v4 - with: - path: .cache/eslint - key: ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-eslint-full-${{ hashFiles('pnpm-lock.yaml', 'eslint.config.mjs', 'tsconfig.json', 'packages/*/*/tsconfig.json', 'examples/*/tsconfig.json') }} - restore-keys: | - ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-eslint-full- - - name: Install dependencies and prepare bubblewrap run: | pnpm install --frozen-lockfile & @@ -171,12 +163,6 @@ jobs: steps: - uses: actions/checkout@v6 - - name: Enable Developer Mode (symlink support) - shell: pwsh - run: >- - reg add "HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\AppModelUnlock" - /t REG_DWORD /f /v "AllowDevelopmentWithoutDevLicense" /d "1" - - uses: actions/setup-node@v6 with: node-version: ${{ env.PRIMARY_NODE_VERSION }} From 9b8320cb941ac07c92e9e2937e6e32951b2d9d88 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Wed, 22 Jul 2026 20:06:50 +0800 Subject: [PATCH 87/90] ci: remove the final Linux lint tail --- .../2026-07-22-evidence-based-larger-hosted-runners.i18n.yaml | 4 ++-- .../2026-07-22-evidence-based-larger-hosted-runners.md | 2 +- .../2026-07-22-evidence-based-larger-hosted-runners.zh.md | 2 +- .github/workflows/ci.yml | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.i18n.yaml b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.i18n.yaml index 7ad1361050..f0fdb86b15 100644 --- a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.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 -2026-07-22-evidence-based-larger-hosted-runners.md: 959ee8516ca4a4990ca4565966d637cfa84d8d7a -2026-07-22-evidence-based-larger-hosted-runners.zh.md: 52f5394d99c4fe9897653b43f9ba6f8ef2cbadfe +2026-07-22-evidence-based-larger-hosted-runners.md: d0894b8ba239026046602e7909bb521e848ed0bd +2026-07-22-evidence-based-larger-hosted-runners.zh.md: a308fc76f37f3ea831266164d99ca9184464d895 diff --git a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.md b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.md index 959ee8516c..d0894b8ba2 100644 --- a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.md +++ b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.md @@ -16,7 +16,7 @@ The organization keeps twelve x64 larger-runner pools in the repo-restricted `ds Production CI uses five larger-runner executions and one standard-runner aggregator. The primary Node inventory is not sharded: -- `node 24 / complete` uses one 96-core Linux runner. One checkout, setup, pnpm-cache restore, and install feeds all 42 primary gates. `run-gates` starts up to 12 independent gates; ESLint and coverage use at most 16 workers, and snapshot replay uses at most 8. Build starts as soon as the first short gates release scheduler slots, while snapshot replay and publication consumers retain explicit dependencies on emitted `lib/` output. Pull requests restore the pnpm cache without saving it, so cache compression and upload do not extend the required job; the master serial reference refreshes that cache outside the pull-request critical path. ESLint remains well below the critical path without a separate cache action. The read-only job does not persist checkout credentials. +- `node 24 / complete` uses one 96-core Linux runner. One checkout, setup, pnpm-cache restore, and install feeds all 42 primary gates. `run-gates` starts up to 12 independent gates; ESLint uses at most 32 workers, coverage uses at most 16, and snapshot replay uses at most 8. Build starts as soon as the first short gates release scheduler slots, while snapshot replay and publication consumers retain explicit dependencies on emitted `lib/` output. Pull requests restore the pnpm cache without saving it, so cache compression and upload do not extend the required job; the master serial reference refreshes that cache outside the pull-request critical path. The earlier 96-core trace measured uncached 32-worker ESLint at 33.30 seconds, below the coverage critical path, so a separate ESLint cache action is unnecessary. The read-only job does not persist checkout credentials. - Node 22.19 and Node 26 use the 4- and 32-core Linux pools for their runtime compatibility smokes. Python 3.10 uses the 8-core Linux pool for the complete keyless SDK suite. These are environment contracts, not slices of the primary Node gate inventory. - `windows node 24 / complete` uses one 32-core Windows runner. One setup feeds the required package build, required production site build, and complete observational portability inventory. Required failures fail the job; observational failures are reported as non-blocking. ESLint stays single-threaded because 16 ESLint workers took 174.54 seconds, coverage uses at most 12 workers, and the outer scheduler retains 16 slots. The job restores only the small master-refreshed ESLint cache and performs a clean pnpm install instead of restoring or saving the many-file package store. All six Windows larger-runner sizes completed install and the production-site benchmark without mutating the machine-wide Developer Mode registry key, so the pull-request critical path omits that redundant step. diff --git a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.zh.md b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.zh.md index 52f5394d99..a308fc76f3 100644 --- a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.zh.md +++ b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.zh.md @@ -16,7 +16,7 @@ Status: implemented 生产 CI 包含 5 次大型运行器执行和 1 个标准运行器聚合作业。主 Node 门禁清单不再分片: -- `node 24 / complete` 使用一台 96 核 Linux 运行器。只需执行一次代码检出、设置、pnpm 缓存恢复和安装,即可供全部 42 项主门禁使用。`run-gates` 最多同时启动 12 项相互独立的门禁;ESLint 和覆盖率最多使用 16 个工作线程,快照回放最多使用 8 个。第一批短门禁释放调度器槽位后,构建会立即启动,而快照回放和发布消费方仍显式依赖生成的 `lib/` 输出。拉取请求会恢复 pnpm 缓存但不保存,因此缓存压缩和上传不会延长必需作业;master 上的串行参考会在拉取请求关键路径之外刷新该缓存。无需单独执行缓存操作,ESLint 的耗时仍远低于关键路径耗时。该只读作业不会持久化代码检出凭据。 +- `node 24 / complete` 使用一台 96 核 Linux 运行器。只需执行一次代码检出、设置、pnpm 缓存恢复和安装,即可供全部 42 项主门禁使用。`run-gates` 最多同时启动 12 项相互独立的门禁;ESLint 最多使用 32 个工作线程,覆盖率最多使用 16 个,快照回放最多使用 8 个。第一批短门禁释放调度器槽位后,构建会立即启动,而快照回放和发布消费方仍显式依赖生成的 `lib/` 输出。拉取请求会恢复 pnpm 缓存但不保存,因此缓存压缩和上传不会延长必需作业;master 上的串行参考会在拉取请求关键路径之外刷新该缓存。早先的 96 核运行轨迹测得,在未使用缓存且启动 32 个工作线程的情况下,ESLint 耗时 33.30 秒,短于覆盖率关键路径,因此无需为 ESLint 单独执行缓存操作。该只读作业不会持久化代码检出凭据。 - Node 22.19 和 Node 26 分别使用 4 核和 32 核 Linux 池运行各自的运行时兼容性冒烟测试。Python 3.10 使用 8 核 Linux 池运行完整的无密钥 SDK 套件。这些作业属于环境契约,并非主 Node 门禁清单的分片。 - `windows node 24 / complete` 使用一台 32 核 Windows 运行器。一次设置供必需的包构建、必需的生产网站构建以及完整的观测性可移植性清单共用。任何必需项失败都会使作业失败;观测项失败则报告为非阻塞。ESLint 保持单线程,因为 16 个 ESLint 工作线程耗时 174.54 秒;覆盖率最多使用 12 个工作线程,外层调度器则保留 16 个槽位。该作业仅恢复由 master 刷新的较小 ESLint 缓存,并在干净环境中执行 pnpm 安装,而不恢复或保存包含大量文件的包存储。全部 6 种 Windows 大型运行器规格都在未修改系统级 Developer Mode 注册表项的情况下完成了安装和生产网站基准测试,因此拉取请求关键路径省略了这个多余步骤。 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3d3f329018..f6c00943e1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -36,7 +36,7 @@ jobs: name: node 24 / complete env: DSH_COVERAGE_MAX_WORKERS: '16' - DSH_ESLINT_CONCURRENCY: '16' + DSH_ESLINT_CONCURRENCY: '32' DSH_GATE_CONCURRENCY: '12' DSH_PUBLINT_CONCURRENCY: '16' DSH_SNAPSHOT_MAX_CONCURRENCY: '8' From dcf9eee3c597ed4a5fffd109943f89fbed3c418c Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Wed, 22 Jul 2026 20:19:56 +0800 Subject: [PATCH 88/90] ci: bound Linux gate contention --- ...22-evidence-based-larger-hosted-runners.i18n.yaml | 4 ++-- ...026-07-22-evidence-based-larger-hosted-runners.md | 4 +++- ...-07-22-evidence-based-larger-hosted-runners.zh.md | 4 +++- .github/workflows/ci.yml | 12 ++++++++++-- 4 files changed, 18 insertions(+), 6 deletions(-) diff --git a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.i18n.yaml b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.i18n.yaml index f0fdb86b15..06d52fb36a 100644 --- a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.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 -2026-07-22-evidence-based-larger-hosted-runners.md: d0894b8ba239026046602e7909bb521e848ed0bd -2026-07-22-evidence-based-larger-hosted-runners.zh.md: a308fc76f37f3ea831266164d99ca9184464d895 +2026-07-22-evidence-based-larger-hosted-runners.md: 360c8f58f9650b466e7fbc8455a4a234cfc86e70 +2026-07-22-evidence-based-larger-hosted-runners.zh.md: 2a82c5fd0124c9d8d1dba72b2ca6eed92415580c diff --git a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.md b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.md index d0894b8ba2..360c8f58f9 100644 --- a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.md +++ b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.md @@ -16,7 +16,7 @@ The organization keeps twelve x64 larger-runner pools in the repo-restricted `ds Production CI uses five larger-runner executions and one standard-runner aggregator. The primary Node inventory is not sharded: -- `node 24 / complete` uses one 96-core Linux runner. One checkout, setup, pnpm-cache restore, and install feeds all 42 primary gates. `run-gates` starts up to 12 independent gates; ESLint uses at most 32 workers, coverage uses at most 16, and snapshot replay uses at most 8. Build starts as soon as the first short gates release scheduler slots, while snapshot replay and publication consumers retain explicit dependencies on emitted `lib/` output. Pull requests restore the pnpm cache without saving it, so cache compression and upload do not extend the required job; the master serial reference refreshes that cache outside the pull-request critical path. The earlier 96-core trace measured uncached 32-worker ESLint at 33.30 seconds, below the coverage critical path, so a separate ESLint cache action is unnecessary. The read-only job does not persist checkout credentials. +- `node 24 / complete` uses one 96-core Linux runner. One checkout, setup, pnpm- and ESLint-cache restore, and install feeds all 42 primary gates. `run-gates` starts up to 8 independent gates; ESLint and coverage use at most 16 workers, and snapshot replay uses at most 8. Build starts as soon as the first short gates release scheduler slots, while snapshot replay and publication consumers retain explicit dependencies on emitted `lib/` output. Pull requests restore both caches without saving them, so cache compression and upload do not extend the required job; the master serial reference refreshes those caches outside the pull-request critical path. An uncached exact-head trace put ESLint at 38.11 seconds and coverage at 37.10 seconds, so the small ESLint restore remains useful on the critical path. The read-only job does not persist checkout credentials. - Node 22.19 and Node 26 use the 4- and 32-core Linux pools for their runtime compatibility smokes. Python 3.10 uses the 8-core Linux pool for the complete keyless SDK suite. These are environment contracts, not slices of the primary Node gate inventory. - `windows node 24 / complete` uses one 32-core Windows runner. One setup feeds the required package build, required production site build, and complete observational portability inventory. Required failures fail the job; observational failures are reported as non-blocking. ESLint stays single-threaded because 16 ESLint workers took 174.54 seconds, coverage uses at most 12 workers, and the outer scheduler retains 16 slots. The job restores only the small master-refreshed ESLint cache and performs a clean pnpm install instead of restoring or saving the many-file package store. All six Windows larger-runner sizes completed install and the production-site benchmark without mutating the machine-wide Developer Mode registry key, so the pull-request critical path omits that redundant step. @@ -42,6 +42,8 @@ The larger client package graph makes cache mechanics and scheduler pressure par Two host effects remain part of the decision. A standard Node 26 job once spent 36 of its 67 seconds in `Set up job`, which is why environment contracts use distinct larger-runner pools instead of standard capacity. A Linux candidate spent 18 seconds registering a 50 KB Bubblewrap package because the hosted image scanned 202,507 package-database files. [`scripts/prepare-ci-bubblewrap.sh`](../../../../scripts/prepare-ci-bubblewrap.sh) instead verifies and extracts the pinned payload into the ephemeral runner directory, runs a functional confinement probe, and overlaps that preparation with dependency installation. +Inner and outer worker limits are separate controls. An [exact-head 32-worker ESLint experiment](https://github.com/deepseek-harness/deepseek-harness/actions/runs/29918329463) slowed lint to 52.28 seconds and coverage to 42.71 seconds, where an adapter idle-timeout test failed. Production therefore retains 16 ESLint workers and admits only 8 independent repository gates at once, leaving capacity for the worker pools owned by those gates. + Linux coverage caps each project at 16 workers, while Windows keeps the 12-worker cap. The process-bound project contains exactly four suite files, so its fork count cannot reach either cap. Thirty-two forks crashed Node 24's CJS lexer twice, and a later 16-fork run reproduced the worker loss and invalid coverage result. The single Vitest invocation therefore uses threads for the broad inventory and reserves forks for suites that exercise process-global state or `process` APIs. That narrow fork inventory includes the local bash process-plumbing suite: under aggregate gate contention its thread worker completed every test but intermittently missed the stdin-error callback needed for per-file function coverage. A 32-worker all-gate run on the 96-core host slowed coverage to 44.6 seconds and made a compute-budget regression cross its one-second threshold, so production stops at 16. This preserves the suites' isolation contracts and deterministic coverage while avoiding forked execution for ordinary test files. The workflow retains two manual measurement suites. `suite=larger-runner-benchmark` compares isolated critical lanes across every size, and `suite=consolidated-runner-benchmark` compares whole aggregates. Complete serial Linux, macOS, and Windows references run only when `master` moves; pull requests run only the optimized jobs. diff --git a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.zh.md b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.zh.md index a308fc76f3..2a82c5fd01 100644 --- a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.zh.md +++ b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.zh.md @@ -16,7 +16,7 @@ Status: implemented 生产 CI 包含 5 次大型运行器执行和 1 个标准运行器聚合作业。主 Node 门禁清单不再分片: -- `node 24 / complete` 使用一台 96 核 Linux 运行器。只需执行一次代码检出、设置、pnpm 缓存恢复和安装,即可供全部 42 项主门禁使用。`run-gates` 最多同时启动 12 项相互独立的门禁;ESLint 最多使用 32 个工作线程,覆盖率最多使用 16 个,快照回放最多使用 8 个。第一批短门禁释放调度器槽位后,构建会立即启动,而快照回放和发布消费方仍显式依赖生成的 `lib/` 输出。拉取请求会恢复 pnpm 缓存但不保存,因此缓存压缩和上传不会延长必需作业;master 上的串行参考会在拉取请求关键路径之外刷新该缓存。早先的 96 核运行轨迹测得,在未使用缓存且启动 32 个工作线程的情况下,ESLint 耗时 33.30 秒,短于覆盖率关键路径,因此无需为 ESLint 单独执行缓存操作。该只读作业不会持久化代码检出凭据。 +- `node 24 / complete` 使用一台 96 核 Linux 运行器。只需执行一次代码检出、设置、pnpm 和 ESLint 缓存恢复以及安装,即可供全部 42 项主门禁使用。`run-gates` 最多同时启动 8 项相互独立的门禁;ESLint 和覆盖率最多使用 16 个工作线程,快照回放最多使用 8 个。第一批短门禁释放调度器槽位后,构建会立即启动,而快照回放和发布消费方仍显式依赖生成的 `lib/` 输出。拉取请求会恢复这两项缓存但不保存,因此缓存压缩和上传不会延长必需作业;master 上的串行参考会在拉取请求关键路径之外刷新这两项缓存。一次未使用缓存的分支头精确运行轨迹显示,ESLint 耗时 38.11 秒,覆盖率耗时 37.10 秒,因此在关键路径上恢复这个较小的 ESLint 缓存仍有价值。该只读作业不会持久化代码检出凭据。 - Node 22.19 和 Node 26 分别使用 4 核和 32 核 Linux 池运行各自的运行时兼容性冒烟测试。Python 3.10 使用 8 核 Linux 池运行完整的无密钥 SDK 套件。这些作业属于环境契约,并非主 Node 门禁清单的分片。 - `windows node 24 / complete` 使用一台 32 核 Windows 运行器。一次设置供必需的包构建、必需的生产网站构建以及完整的观测性可移植性清单共用。任何必需项失败都会使作业失败;观测项失败则报告为非阻塞。ESLint 保持单线程,因为 16 个 ESLint 工作线程耗时 174.54 秒;覆盖率最多使用 12 个工作线程,外层调度器则保留 16 个槽位。该作业仅恢复由 master 刷新的较小 ESLint 缓存,并在干净环境中执行 pnpm 安装,而不恢复或保存包含大量文件的包存储。全部 6 种 Windows 大型运行器规格都在未修改系统级 Developer Mode 注册表项的情况下完成了安装和生产网站基准测试,因此拉取请求关键路径省略了这个多余步骤。 @@ -42,6 +42,8 @@ Windows 仓库工作在超过 16 核后收益很小,但 32 核池可以让完 两项主机效应仍构成这项决策的依据。一个标准 Node 26 作业曾在总共 67 秒的耗时中,把 36 秒用在 `Set up job` 上,因此各项环境契约使用不同的大型运行器池,而非标准容量。一个 Linux 候选作业在注册 50 KB 的 Bubblewrap 包时耗时 18 秒,因为托管映像扫描了 202,507 个包数据库文件。[`scripts/prepare-ci-bubblewrap.sh`](../../../../scripts/prepare-ci-bubblewrap.sh) 改为验证固定包内容并将其解压到临时运行器目录,执行功能性隔离探针,并让这项准备工作与依赖安装重叠执行。 +内层与外层工作线程上限是相互独立的控制机制。一次[分支头精确、使用 32 个工作线程的 ESLint 实验](https://github.com/deepseek-harness/deepseek-harness/actions/runs/29918329463)使 lint 耗时增至 52.28 秒、覆盖率耗时增至 42.71 秒;同一次运行中,一项适配器空闲超时测试失败。因此,生产环境将 ESLint 工作线程上限维持在 16 个,并且同时最多只运行 8 项相互独立的仓库门禁,为这些门禁自身的工作线程池留出容量。 + Linux 覆盖率把每个项目的工作线程上限设为 16 个,Windows 则保留 12 个工作线程的上限。进程约束项目恰好包含 4 个套件文件,因此它的 fork 数量不可能达到任一上限。32 个 fork 曾两次导致 Node 24 的 CJS 词法分析器崩溃,后来一次使用 16 个 fork 的运行又复现了工作进程丢失和无效的覆盖率结果。因此,单次 Vitest 调用会对大范围测试清单使用线程,只为涉及进程全局状态或 `process` API 的套件保留 fork。这份有限的 fork 清单还包含本地 bash 进程通路套件:在聚合门禁争用资源时,该套件的工作线程虽然完成了所有测试,却会间歇性漏记逐文件函数覆盖率所需的 stdin 错误回调。在 96 核主机上使用 32 个工作线程运行全部门禁时,覆盖率耗时变慢至 44.6 秒,还使一项计算预算回归超过其 1 秒阈值,因此生产环境将工作线程数限制在 16 个以内。这样既能保留这些套件的隔离契约和覆盖率结果的确定性,又能避免以 fork 方式执行普通测试文件。 工作流保留 2 项手动测量套件。`suite=larger-runner-benchmark` 比较所有规格下相互独立的关键通道,`suite=consolidated-runner-benchmark` 比较完整聚合流程。只有在 `master` 移动时,才运行完整的 Linux、macOS 和 Windows 串行参考;拉取请求只运行优化后的作业。 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f6c00943e1..42d1c2b1f6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -36,8 +36,9 @@ jobs: name: node 24 / complete env: DSH_COVERAGE_MAX_WORKERS: '16' - DSH_ESLINT_CONCURRENCY: '32' - DSH_GATE_CONCURRENCY: '12' + DSH_ESLINT_CACHE: '1' + DSH_ESLINT_CONCURRENCY: '16' + DSH_GATE_CONCURRENCY: '8' DSH_PUBLINT_CONCURRENCY: '16' DSH_SNAPSHOT_MAX_CONCURRENCY: '8' steps: @@ -64,6 +65,13 @@ jobs: restore-keys: | ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm- + - uses: actions/cache/restore@v4 + with: + path: .cache/eslint + key: ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-eslint-full-${{ hashFiles('pnpm-lock.yaml', 'eslint.config.mjs', 'tsconfig.json', 'packages/*/*/tsconfig.json', 'examples/*/tsconfig.json') }} + restore-keys: | + ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-eslint-full- + - name: Install dependencies and prepare bubblewrap run: | pnpm install --frozen-lockfile & From 3c397e594fc921ea93b739a3f1d56671d7a3d91a Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Wed, 22 Jul 2026 20:29:33 +0800 Subject: [PATCH 89/90] ci: remove critical setup variance --- ...ence-based-larger-hosted-runners.i18n.yaml | 4 +- ...22-evidence-based-larger-hosted-runners.md | 10 ++-- ...evidence-based-larger-hosted-runners.zh.md | 10 ++-- .github/workflows/ci.yml | 47 ++++++++++++------- vitest.config.ts | 4 +- 5 files changed, 44 insertions(+), 31 deletions(-) diff --git a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.i18n.yaml b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.i18n.yaml index 06d52fb36a..6277617a73 100644 --- a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.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 -2026-07-22-evidence-based-larger-hosted-runners.md: 360c8f58f9650b466e7fbc8455a4a234cfc86e70 -2026-07-22-evidence-based-larger-hosted-runners.zh.md: 2a82c5fd0124c9d8d1dba72b2ca6eed92415580c +2026-07-22-evidence-based-larger-hosted-runners.md: c0fae2841f21c431d6416cd5d421929d70197abb +2026-07-22-evidence-based-larger-hosted-runners.zh.md: 51c73a8a631af4f1254c795d09585770fc4e68bb diff --git a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.md b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.md index 360c8f58f9..c0fae2841f 100644 --- a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.md +++ b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.md @@ -16,9 +16,9 @@ The organization keeps twelve x64 larger-runner pools in the repo-restricted `ds Production CI uses five larger-runner executions and one standard-runner aggregator. The primary Node inventory is not sharded: -- `node 24 / complete` uses one 96-core Linux runner. One checkout, setup, pnpm- and ESLint-cache restore, and install feeds all 42 primary gates. `run-gates` starts up to 8 independent gates; ESLint and coverage use at most 16 workers, and snapshot replay uses at most 8. Build starts as soon as the first short gates release scheduler slots, while snapshot replay and publication consumers retain explicit dependencies on emitted `lib/` output. Pull requests restore both caches without saving them, so cache compression and upload do not extend the required job; the master serial reference refreshes those caches outside the pull-request critical path. An uncached exact-head trace put ESLint at 38.11 seconds and coverage at 37.10 seconds, so the small ESLint restore remains useful on the critical path. The read-only job does not persist checkout credentials. +- `node 24 / complete` uses one 96-core Linux runner. One checkout, direct selection of the image's preinstalled Node 24 toolcache, pnpm- and ESLint-cache restore, and install feeds all 42 primary gates. `run-gates` starts up to 10 independent gates; ESLint and coverage use at most 16 workers, and snapshot replay uses at most 8. Build starts as soon as the first short gates release scheduler slots, while snapshot replay and publication consumers retain explicit dependencies on emitted `lib/` output. Pull requests restore both caches without saving them, so cache compression and upload do not extend the required job; the master serial reference refreshes those caches outside the pull-request critical path. An uncached exact-head trace put ESLint at 38.11 seconds and coverage at 37.10 seconds, so the small ESLint restore remains useful on the critical path. The read-only job does not persist checkout credentials. - Node 22.19 and Node 26 use the 4- and 32-core Linux pools for their runtime compatibility smokes. Python 3.10 uses the 8-core Linux pool for the complete keyless SDK suite. These are environment contracts, not slices of the primary Node gate inventory. -- `windows node 24 / complete` uses one 32-core Windows runner. One setup feeds the required package build, required production site build, and complete observational portability inventory. Required failures fail the job; observational failures are reported as non-blocking. ESLint stays single-threaded because 16 ESLint workers took 174.54 seconds, coverage uses at most 12 workers, and the outer scheduler retains 16 slots. The job restores only the small master-refreshed ESLint cache and performs a clean pnpm install instead of restoring or saving the many-file package store. All six Windows larger-runner sizes completed install and the production-site benchmark without mutating the machine-wide Developer Mode registry key, so the pull-request critical path omits that redundant step. +- `windows node 24 / complete` uses one 32-core Windows runner. One preparation wave feeds the required package build, required production site build, and complete observational portability inventory. Required failures fail the job; observational failures are reported as non-blocking. ESLint stays single-threaded because 16 ESLint workers took 174.54 seconds, coverage uses at most 12 workers, and the outer scheduler retains 16 slots. The job restores only the small master-refreshed ESLint cache and performs a clean pnpm install instead of restoring or saving the many-file package store. All six Windows larger-runner sizes completed install and the production-site benchmark without mutating the machine-wide Developer Mode registry key, so the pull-request critical path omits that redundant step. The former gate-level and coarse primary shard jobs are absent from the workflow. Their static, lint, coverage, snapshot, and scenario shard selectors are also absent from the repository, so an unused diagnostic path cannot preserve a second CI architecture. @@ -40,11 +40,11 @@ Repository work gains little above 16 Windows cores, but the 32-core pool can st The larger client package graph makes cache mechanics and scheduler pressure part of the measured workload. In [one exact-head production run](https://github.com/deepseek-harness/deepseek-harness/actions/runs/29912577681), Linux spent 39 seconds in repository gates but 69 seconds in the complete job, while Windows spent 117 seconds in repository gates and 228 seconds in the complete job. The Windows pnpm cache downloaded its 154 MB archive in about two seconds but spent 27 seconds extracting it, followed by a 23-second install and a 14-second post-job save. A [cacheless all-size trace](https://github.com/deepseek-harness/deepseek-harness/actions/runs/29913033155) completed the same 32-core Windows install in 27 seconds. Production therefore avoids the Windows package-store cache, uses restore-only caches on latency-critical pull-request jobs, and bounds outer concurrency so typecheck, lint, coverage, and build do not oversubscribe one host. -Two host effects remain part of the decision. A standard Node 26 job once spent 36 of its 67 seconds in `Set up job`, which is why environment contracts use distinct larger-runner pools instead of standard capacity. A Linux candidate spent 18 seconds registering a 50 KB Bubblewrap package because the hosted image scanned 202,507 package-database files. [`scripts/prepare-ci-bubblewrap.sh`](../../../../scripts/prepare-ci-bubblewrap.sh) instead verifies and extracts the pinned payload into the ephemeral runner directory, runs a functional confinement probe, and overlaps that preparation with dependency installation. +Three host effects remain part of the decision. A standard Node 26 job once spent 36 of its 67 seconds in `Set up job`, which is why environment contracts use distinct larger-runner pools instead of standard capacity. The setup-node action later spent 3.68 seconds printing cached Linux environment details and 46.56 seconds doing the same on Windows after both had already found Node 24.18.0 in the hosted toolcache. The two latency-critical jobs select the newest preinstalled 24.x directory directly, verify its major, and fail loud if the image no longer carries it; compatibility jobs retain setup-node because selecting a non-default runtime is their contract. A Linux candidate also spent 18 seconds registering a 50 KB Bubblewrap package because the hosted image scanned 202,507 package-database files. [`scripts/prepare-ci-bubblewrap.sh`](../../../../scripts/prepare-ci-bubblewrap.sh) instead verifies and extracts the pinned payload into the ephemeral runner directory, runs a functional confinement probe, and overlaps that preparation with dependency installation. -Inner and outer worker limits are separate controls. An [exact-head 32-worker ESLint experiment](https://github.com/deepseek-harness/deepseek-harness/actions/runs/29918329463) slowed lint to 52.28 seconds and coverage to 42.71 seconds, where an adapter idle-timeout test failed. Production therefore retains 16 ESLint workers and admits only 8 independent repository gates at once, leaving capacity for the worker pools owned by those gates. +Inner and outer worker limits are separate controls. An [exact-head 32-worker ESLint experiment](https://github.com/deepseek-harness/deepseek-harness/actions/runs/29918329463) slowed lint to 52.28 seconds and coverage to 42.71 seconds, where an adapter idle-timeout test failed. A later 8-gate trace reduced coverage to 35.17 seconds but delayed the production-site build until the aggregate reached 41.06 seconds. Production therefore retains 16 ESLint workers and admits 10 independent repository gates at once, leaving capacity for the worker pools owned by those gates without starving later independent work. -Linux coverage caps each project at 16 workers, while Windows keeps the 12-worker cap. The process-bound project contains exactly four suite files, so its fork count cannot reach either cap. Thirty-two forks crashed Node 24's CJS lexer twice, and a later 16-fork run reproduced the worker loss and invalid coverage result. The single Vitest invocation therefore uses threads for the broad inventory and reserves forks for suites that exercise process-global state or `process` APIs. That narrow fork inventory includes the local bash process-plumbing suite: under aggregate gate contention its thread worker completed every test but intermittently missed the stdin-error callback needed for per-file function coverage. A 32-worker all-gate run on the 96-core host slowed coverage to 44.6 seconds and made a compute-budget regression cross its one-second threshold, so production stops at 16. This preserves the suites' isolation contracts and deterministic coverage while avoiding forked execution for ordinary test files. +Linux coverage caps each project at 16 workers, while Windows keeps the 12-worker cap. The process-bound project contains exactly five suite files, so its fork count cannot reach either cap. Thirty-two forks crashed Node 24's CJS lexer twice, and a later 16-fork run reproduced the worker loss and invalid coverage result. The single Vitest invocation therefore uses threads for the broad inventory and reserves forks for suites that exercise process-global state, `process` APIs, or timing-sensitive process I/O. That narrow fork inventory includes the local bash process-plumbing suite: under aggregate gate contention its thread worker completed every test but intermittently missed the stdin-error callback needed for per-file function coverage. It also includes the pi-ai adapter suite after two hosted aggregate runs delayed an idle-watchdog socket-close observation past its 100-millisecond test deadline. A 32-worker all-gate run on the 96-core host slowed coverage to 44.6 seconds and made a compute-budget regression cross its one-second threshold, so production stops at 16. This preserves the suites' isolation contracts and deterministic coverage while avoiding forked execution for ordinary test files. The workflow retains two manual measurement suites. `suite=larger-runner-benchmark` compares isolated critical lanes across every size, and `suite=consolidated-runner-benchmark` compares whole aggregates. Complete serial Linux, macOS, and Windows references run only when `master` moves; pull requests run only the optimized jobs. diff --git a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.zh.md b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.zh.md index 2a82c5fd01..51c73a8a63 100644 --- a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.zh.md +++ b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.zh.md @@ -16,9 +16,9 @@ Status: implemented 生产 CI 包含 5 次大型运行器执行和 1 个标准运行器聚合作业。主 Node 门禁清单不再分片: -- `node 24 / complete` 使用一台 96 核 Linux 运行器。只需执行一次代码检出、设置、pnpm 和 ESLint 缓存恢复以及安装,即可供全部 42 项主门禁使用。`run-gates` 最多同时启动 8 项相互独立的门禁;ESLint 和覆盖率最多使用 16 个工作线程,快照回放最多使用 8 个。第一批短门禁释放调度器槽位后,构建会立即启动,而快照回放和发布消费方仍显式依赖生成的 `lib/` 输出。拉取请求会恢复这两项缓存但不保存,因此缓存压缩和上传不会延长必需作业;master 上的串行参考会在拉取请求关键路径之外刷新这两项缓存。一次未使用缓存的分支头精确运行轨迹显示,ESLint 耗时 38.11 秒,覆盖率耗时 37.10 秒,因此在关键路径上恢复这个较小的 ESLint 缓存仍有价值。该只读作业不会持久化代码检出凭据。 +- `node 24 / complete` 使用一台 96 核 Linux 运行器。只需执行一次代码检出、直接选择托管映像中预装的 Node 24 toolcache、恢复 pnpm 和 ESLint 缓存以及安装,即可供全部 42 项主门禁使用。`run-gates` 最多同时启动 10 项相互独立的门禁;ESLint 和覆盖率最多使用 16 个工作线程,快照回放最多使用 8 个。第一批短门禁释放调度器槽位后,构建会立即启动,而快照回放和发布消费方仍显式依赖生成的 `lib/` 输出。拉取请求会恢复这两项缓存但不保存,因此缓存压缩和上传不会延长必需作业;master 上的串行参考会在拉取请求关键路径之外刷新这两项缓存。一次未使用缓存的分支头精确运行轨迹显示,ESLint 耗时 38.11 秒,覆盖率耗时 37.10 秒,因此在关键路径上恢复这个较小的 ESLint 缓存仍有价值。该只读作业不会持久化代码检出凭据。 - Node 22.19 和 Node 26 分别使用 4 核和 32 核 Linux 池运行各自的运行时兼容性冒烟测试。Python 3.10 使用 8 核 Linux 池运行完整的无密钥 SDK 套件。这些作业属于环境契约,并非主 Node 门禁清单的分片。 -- `windows node 24 / complete` 使用一台 32 核 Windows 运行器。一次设置供必需的包构建、必需的生产网站构建以及完整的观测性可移植性清单共用。任何必需项失败都会使作业失败;观测项失败则报告为非阻塞。ESLint 保持单线程,因为 16 个 ESLint 工作线程耗时 174.54 秒;覆盖率最多使用 12 个工作线程,外层调度器则保留 16 个槽位。该作业仅恢复由 master 刷新的较小 ESLint 缓存,并在干净环境中执行 pnpm 安装,而不恢复或保存包含大量文件的包存储。全部 6 种 Windows 大型运行器规格都在未修改系统级 Developer Mode 注册表项的情况下完成了安装和生产网站基准测试,因此拉取请求关键路径省略了这个多余步骤。 +- `windows node 24 / complete` 使用一台 32 核 Windows 运行器。一轮准备工作供必需的包构建、必需的生产网站构建以及完整的观测性可移植性清单共用。任何必需项失败都会使作业失败;观测项失败则报告为非阻塞。ESLint 保持单线程,因为 16 个 ESLint 工作线程耗时 174.54 秒;覆盖率最多使用 12 个工作线程,外层调度器则保留 16 个槽位。该作业仅恢复由 master 刷新的较小 ESLint 缓存,并在干净环境中执行 pnpm 安装,而不恢复或保存包含大量文件的包存储。全部 6 种 Windows 大型运行器规格都在未修改系统级 Developer Mode 注册表项的情况下完成了安装和生产网站基准测试,因此拉取请求关键路径省略了这个多余步骤。 原有的门禁级和粗粒度主流程分片作业已从工作流中移除。相应的静态、lint、覆盖率、快照和场景分片选择器也已从仓库中移除,因此未使用的诊断路径无法继续维系第二套 CI 架构。 @@ -40,11 +40,11 @@ Windows 仓库工作在超过 16 核后收益很小,但 32 核池可以让完 客户端包依赖图增大后,缓存机制和调度器压力也成为实测工作负载的一部分。在[一次分支头精确的生产运行](https://github.com/deepseek-harness/deepseek-harness/actions/runs/29912577681)中,Linux 的仓库门禁耗时 39 秒,完整作业耗时 69 秒;Windows 的仓库门禁耗时 117 秒,完整作业耗时 228 秒。Windows pnpm 缓存的 154 MB 归档下载耗时约 2 秒,但解压耗时 27 秒,随后安装耗时 23 秒,作业结束后的保存又耗时 14 秒。一次[无缓存的全规格运行轨迹](https://github.com/deepseek-harness/deepseek-harness/actions/runs/29913033155)在 27 秒内完成了同一台 32 核 Windows 运行器上的安装。因此,生产环境不使用 Windows 包存储缓存,在对延迟敏感的拉取请求作业中使用只恢复不保存的缓存,并限制外层并发度,以免类型检查、lint、覆盖率和构建在同一台主机上过度争用资源。 -两项主机效应仍构成这项决策的依据。一个标准 Node 26 作业曾在总共 67 秒的耗时中,把 36 秒用在 `Set up job` 上,因此各项环境契约使用不同的大型运行器池,而非标准容量。一个 Linux 候选作业在注册 50 KB 的 Bubblewrap 包时耗时 18 秒,因为托管映像扫描了 202,507 个包数据库文件。[`scripts/prepare-ci-bubblewrap.sh`](../../../../scripts/prepare-ci-bubblewrap.sh) 改为验证固定包内容并将其解压到临时运行器目录,执行功能性隔离探针,并让这项准备工作与依赖安装重叠执行。 +3 项主机效应仍构成这项决策的依据。一个标准 Node 26 作业曾在总共 67 秒的耗时中,把 36 秒用在 `Set up job` 上,因此各项环境契约使用不同的大型运行器池,而非标准容量。setup-node action 在 Linux 和 Windows 均已从托管 toolcache 找到 Node 24.18.0 后,仍分别花费 3.68 秒和 46.56 秒输出缓存的环境详情。两个延迟关键作业会直接选择最新的预装 24.x 目录并验证其主版本号;如果映像不再提供该目录,作业会明确报错并失败。兼容性作业仍使用 setup-node,因为选择非默认运行时正是它们的契约。一个 Linux 候选作业还在注册 50 KB 的 Bubblewrap 包时耗时 18 秒,因为托管映像扫描了 202,507 个包数据库文件。[`scripts/prepare-ci-bubblewrap.sh`](../../../../scripts/prepare-ci-bubblewrap.sh) 改为验证固定包内容并将其解压到临时运行器目录,执行功能性隔离探针,并让这项准备工作与依赖安装重叠执行。 -内层与外层工作线程上限是相互独立的控制机制。一次[分支头精确、使用 32 个工作线程的 ESLint 实验](https://github.com/deepseek-harness/deepseek-harness/actions/runs/29918329463)使 lint 耗时增至 52.28 秒、覆盖率耗时增至 42.71 秒;同一次运行中,一项适配器空闲超时测试失败。因此,生产环境将 ESLint 工作线程上限维持在 16 个,并且同时最多只运行 8 项相互独立的仓库门禁,为这些门禁自身的工作线程池留出容量。 +内层与外层工作线程上限是相互独立的控制机制。一次[分支头精确、使用 32 个工作线程的 ESLint 实验](https://github.com/deepseek-harness/deepseek-harness/actions/runs/29918329463)使 lint 耗时增至 52.28 秒、覆盖率耗时增至 42.71 秒;同一次运行中,一项适配器空闲超时测试失败。后来一次同时运行 8 项门禁的运行轨迹将覆盖率耗时降至 35.17 秒,但生产网站构建被延后,直到聚合流程耗时达到 41.06 秒时才完成。因此,生产环境将 ESLint 工作线程上限维持在 16 个,并且同时最多运行 10 项相互独立的仓库门禁,既为这些门禁自身的工作线程池留出容量,又避免后续独立工作因资源不足而迟迟无法启动。 -Linux 覆盖率把每个项目的工作线程上限设为 16 个,Windows 则保留 12 个工作线程的上限。进程约束项目恰好包含 4 个套件文件,因此它的 fork 数量不可能达到任一上限。32 个 fork 曾两次导致 Node 24 的 CJS 词法分析器崩溃,后来一次使用 16 个 fork 的运行又复现了工作进程丢失和无效的覆盖率结果。因此,单次 Vitest 调用会对大范围测试清单使用线程,只为涉及进程全局状态或 `process` API 的套件保留 fork。这份有限的 fork 清单还包含本地 bash 进程通路套件:在聚合门禁争用资源时,该套件的工作线程虽然完成了所有测试,却会间歇性漏记逐文件函数覆盖率所需的 stdin 错误回调。在 96 核主机上使用 32 个工作线程运行全部门禁时,覆盖率耗时变慢至 44.6 秒,还使一项计算预算回归超过其 1 秒阈值,因此生产环境将工作线程数限制在 16 个以内。这样既能保留这些套件的隔离契约和覆盖率结果的确定性,又能避免以 fork 方式执行普通测试文件。 +Linux 覆盖率把每个项目的工作线程上限设为 16 个,Windows 则保留 12 个工作线程的上限。进程约束项目恰好包含 5 个套件文件,因此它的 fork 数量不可能达到任一上限。32 个 fork 曾两次导致 Node 24 的 CJS 词法分析器崩溃,后来一次使用 16 个 fork 的运行又复现了工作进程丢失和无效的覆盖率结果。因此,单次 Vitest 调用会对大范围测试清单使用线程,只为涉及进程全局状态、`process` API 或对时间敏感的进程 I/O 的套件保留 fork。这份有限的 fork 清单还包含本地 bash 进程通路套件:在聚合门禁争用资源时,该套件的工作线程虽然完成了所有测试,却会间歇性漏记逐文件函数覆盖率所需的 stdin 错误回调。两次托管聚合运行都将空闲看门狗对套接字关闭的观测延迟到超过其 100 毫秒测试截止时间,因此这份清单还包含 pi-ai 适配器套件。在 96 核主机上使用 32 个工作线程运行全部门禁时,覆盖率耗时变慢至 44.6 秒,还使一项计算预算回归超过其 1 秒阈值,因此生产环境将工作线程数限制在 16 个以内。这样既能保留这些套件的隔离契约和覆盖率结果的确定性,又能避免以 fork 方式执行普通测试文件。 工作流保留 2 项手动测量套件。`suite=larger-runner-benchmark` 比较所有规格下相互独立的关键通道,`suite=consolidated-runner-benchmark` 比较完整聚合流程。只有在 `master` 移动时,才运行完整的 Linux、macOS 和 Windows 串行参考;拉取请求只运行优化后的作业。 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 42d1c2b1f6..f52274cddd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -38,7 +38,7 @@ jobs: DSH_COVERAGE_MAX_WORKERS: '16' DSH_ESLINT_CACHE: '1' DSH_ESLINT_CONCURRENCY: '16' - DSH_GATE_CONCURRENCY: '8' + DSH_GATE_CONCURRENCY: '10' DSH_PUBLINT_CONCURRENCY: '16' DSH_SNAPSHOT_MAX_CONCURRENCY: '8' steps: @@ -46,21 +46,11 @@ jobs: with: persist-credentials: false - - uses: actions/setup-node@v6 - with: - node-version: ${{ env.PRIMARY_NODE_VERSION }} - - - name: Enable corepack and resolve pnpm store path - id: pnpm-store - run: | - corepack enable - echo "path=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT" - # Pull requests consume the default-branch cache but do not put cache # compression and upload on the paid latency-critical path. - uses: actions/cache/restore@v4 with: - path: ${{ steps.pnpm-store.outputs.path }} + path: /home/runner/.local/share/pnpm/store/v11 key: ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm-${{ hashFiles('pnpm-lock.yaml') }} restore-keys: | ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm- @@ -72,8 +62,17 @@ jobs: restore-keys: | ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-eslint-full- - - name: Install dependencies and prepare bubblewrap + - name: Select preinstalled Node, install dependencies, and prepare bubblewrap run: | + node_root="$(printf '%s\n' "$RUNNER_TOOL_CACHE"/node/"${PRIMARY_NODE_VERSION}".*/x64 | sort -V | tail -n 1)" + if [[ ! -d "$node_root" ]]; then + echo "preinstalled Node ${PRIMARY_NODE_VERSION}.x not found in $RUNNER_TOOL_CACHE" >&2 + exit 1 + fi + echo "$node_root/bin" >> "$GITHUB_PATH" + export PATH="$node_root/bin:$PATH" + [[ "$(node --version)" == "v${PRIMARY_NODE_VERSION}."* ]] + corepack enable pnpm install --frozen-lockfile & install_pid=$! bash scripts/prepare-ci-bubblewrap.sh & @@ -171,10 +170,6 @@ jobs: steps: - uses: actions/checkout@v6 - - uses: actions/setup-node@v6 - with: - node-version: ${{ env.PRIMARY_NODE_VERSION }} - - uses: actions/cache/restore@v4 with: path: .cache/eslint @@ -184,9 +179,25 @@ jobs: # Extracting the many-file pnpm store cache is slower on this image than # a clean parallel install, and saving it adds more latency after gates. - - name: Enable corepack and install (immutable) + - name: Select preinstalled Node and install (immutable) shell: pwsh run: | + $nodeRoot = Get-ChildItem -Path "$env:RUNNER_TOOL_CACHE\node" -Directory | + Where-Object { $_.Name -like "$env:PRIMARY_NODE_VERSION.*" } | + Sort-Object { [version]$_.Name } | + Select-Object -Last 1 + if ($null -eq $nodeRoot) { + throw "preinstalled Node $env:PRIMARY_NODE_VERSION.x not found in $env:RUNNER_TOOL_CACHE" + } + $nodeBin = Join-Path $nodeRoot.FullName 'x64' + if (-not (Test-Path $nodeBin -PathType Container)) { + throw "preinstalled Node x64 directory not found at $nodeBin" + } + Add-Content -Path $env:GITHUB_PATH -Value $nodeBin + $env:PATH = "$nodeBin;$env:PATH" + if ((node --version) -notlike "v$env:PRIMARY_NODE_VERSION.*") { + throw "selected unexpected Node version $(node --version)" + } corepack enable pnpm install --frozen-lockfile diff --git a/vitest.config.ts b/vitest.config.ts index 51ced4b19f..47ac2674a4 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -28,11 +28,13 @@ const testIncludes = [ 'scripts/**/*.spec.ts', ] -// These suites exercise process-global state or process APIs that worker threads cannot isolate. +// These suites exercise process-global state, process APIs, or timing-sensitive process I/O +// that worker threads cannot isolate reliably under aggregate gate contention. // Keep the narrow exception in forks while the rest of the inventory avoids per-file processes. const processBoundTests = [ 'packages/bash/bash-local/tests/run.spec.ts', 'packages/context/time-context/tests/time-context.spec.ts', + 'packages/llm/llm-pi-ai/tests/adapter.spec.ts', 'packages/ui/app-boot/tests/app-boot.spec.ts', 'packages/workflow/workflow-workerthread/tests/session.spec.ts', ] From 822a0c47658db560a22971faf6ddc4e866faff76 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Wed, 22 Jul 2026 21:03:37 +0800 Subject: [PATCH 90/90] fix(build): exclude Vite app from package bundling --- tsdown.config.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tsdown.config.ts b/tsdown.config.ts index c0062b6379..8ad1a5c41c 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -11,10 +11,10 @@ import { defineConfig } from 'tsdown' * (schemastery: dual ESM+CJS; logger-console: extra browser entry). */ export default defineConfig({ - // Explicit globs keep bundling to vendored Cordis and the TypeScript package tree; - // `workspace: true` would discover package manifests outside that bundle set. Landlock - // platform packages contain only a prebuilt native binary, so they have no JS entry. - workspace: ['vendor/*', 'packages/*/*', 'apps/*'], + // Explicit globs keep bundling to vendored Cordis, the TypeScript package tree, and + // the Node CLI assembly. `apps/web` is a Vite application with no lib/types entry; + // `workspace: true` or `apps/*` would incorrectly treat it as a package bundle. + workspace: ['vendor/*', 'packages/*/*', 'apps/cli'], // The brace glob admits the package companion when present while retaining the // index-only build for vendored Cordis packages outside the Harness package tree. entry: ['lib/types/{index,invariant}.js'],