diff --git a/.agents/skills/testing-workflow/SKILL.md b/.agents/skills/testing-workflow/SKILL.md index 512bd7c73..b37177ab4 100644 --- a/.agents/skills/testing-workflow/SKILL.md +++ b/.agents/skills/testing-workflow/SKILL.md @@ -33,6 +33,7 @@ suites are outside this distribution's verification. | Permission facade on macOS/Linux | `pnpm test:policy` | | Sandbox on macOS | `pnpm test:sandbox` | | Source-sync, workflow and release tools | `pnpm test:release-tools` | +| npm release archive installation | `MCODE_RELEASE_TAG=vX.Y.Z MCODE_RELEASE_ARCHIVE=/path/to/package.tar.gz pnpm verify --profile package` | | Types and standalone build boundary | `pnpm typecheck`, `pnpm build`, `pnpm check:standalone` | | Published files and generated paths | `pnpm check:source`, `pnpm check:tsconfig` | @@ -61,6 +62,23 @@ Skills under `.agents/skills`, unknown paths and inventory changes require the full profile. The `archive` profile is for source-archive validation, not a way to bypass the clean-commit export requirement. +The `package` profile authenticates an npm release archive, installs it into a +temporary npm prefix, and exercises its launcher, native dependencies and offline +smoke/BYOK suites. It requires `MCODE_RELEASE_TAG` and `MCODE_RELEASE_ARCHIVE` and +does not replace source validation. The release command commits matching root/TUI source versions before tagging. +The release workflow rejects version mismatches and runs the full profile before +package installation checks. + +## Performance coverage + +Use [CONTRIBUTING.md](../../../CONTRIBUTING.md#performance-checks) to classify the +change. Add `perf:full` for performance optimizations, changes to history/token +processing, storage, streaming or tool execution, and large runtime refactors. +The label automatically selects the full suite and keeps it selected for later +commits. Before merge, link a passing `performance` run with `Suite: full` for the +latest head and intended base. Read [Performance CI](../../../docs/performance-ci.md) +for evidence, limits and manual dispatch. + ## Manual evidence and reporting For CLI behavior, exercise the built `dist/cli.js` and inspect stdout, stderr and diff --git a/.github/ISSUE_TEMPLATE/01-bug-report.yml b/.github/ISSUE_TEMPLATE/01-bug-report.yml index 980c7ee99..8ff285be1 100644 --- a/.github/ISSUE_TEMPLATE/01-bug-report.yml +++ b/.github/ISSUE_TEMPLATE/01-bug-report.yml @@ -10,6 +10,7 @@ body: id: product attributes: label: Product or interface + description: Desktop app and interactive TUI reports automatically receive the desktop or tui label. options: - Desktop app - CLI - interactive TUI diff --git a/.github/ISSUE_TEMPLATE/02-feature-request.yml b/.github/ISSUE_TEMPLATE/02-feature-request.yml index 7d8c4d279..cbab8bb37 100644 --- a/.github/ISSUE_TEMPLATE/02-feature-request.yml +++ b/.github/ISSUE_TEMPLATE/02-feature-request.yml @@ -7,6 +7,7 @@ body: id: product attributes: label: Product or interface + description: Desktop app and interactive TUI reports automatically receive the desktop or tui label. options: - Desktop app - CLI - interactive TUI diff --git a/.github/ISSUE_TEMPLATE/03-question.yml b/.github/ISSUE_TEMPLATE/03-question.yml index a200f16c2..dd2b2f8f9 100644 --- a/.github/ISSUE_TEMPLATE/03-question.yml +++ b/.github/ISSUE_TEMPLATE/03-question.yml @@ -7,7 +7,7 @@ body: id: product attributes: label: Product or interface - description: Leave blank if unsure or asking about repository policy. + description: Leave blank if unsure or asking about repository policy. Desktop app and interactive TUI reports automatically receive the desktop or tui label. options: - Desktop app - CLI - interactive TUI diff --git a/.github/ISSUE_TEMPLATE/docs.yml b/.github/ISSUE_TEMPLATE/docs.yml index 1982f5826..4ecda1c0f 100644 --- a/.github/ISSUE_TEMPLATE/docs.yml +++ b/.github/ISSUE_TEMPLATE/docs.yml @@ -3,6 +3,19 @@ description: Report missing, incorrect, or unclear documentation. title: "[Docs] " labels: ["documentation", "needs-triage"] body: + - type: dropdown + id: product + attributes: + label: Product or interface + description: Desktop app and interactive TUI reports automatically receive the desktop or tui label. + options: + - Desktop app + - CLI - interactive TUI + - CLI - headless + - CLI - ACP + - Source build or repository tooling + validations: + required: true - type: input id: location attributes: diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 868a4f99f..c2c5eccdc 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -7,6 +7,7 @@ Describe the user-visible problem and resulting behavior. Link a public issue wh ## Validation - Checks run and results (include the revision/profile where relevant): +- Performance: basic / `perf:full` (see [requirements](https://github.com/MiniMax-AI/minimax-code/blob/main/CONTRIBUTING.md#performance-checks)); for full coverage, link a passing run for the latest PR head and intended base: - NOT RUN, platform limitations and live-service boundaries: ## Publication and contribution checks diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8007363de..d7b383905 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -55,7 +55,8 @@ jobs: strategy: fail-fast: false matrix: - os: [ubuntu-latest, macos-latest, windows-latest] + # Windows validation is temporarily paused until its checks are reliable. + os: [ubuntu-latest, macos-latest] node: ["24"] include: - os: ubuntu-latest diff --git a/.github/workflows/cli-release.yml b/.github/workflows/cli-release.yml new file mode 100644 index 000000000..cc7d1b103 --- /dev/null +++ b/.github/workflows/cli-release.yml @@ -0,0 +1,146 @@ +name: CLI release +on: + push: + tags: ['v*'] + pull_request: + paths: + - '.github/workflows/cli-release.yml' + - 'scripts/build.mjs' + - 'scripts/*cli-release.mjs' + - 'scripts/release-cli.mjs' + - 'scripts/lib/cli-release.mjs' + - 'scripts/verify.mjs' + - 'test/smoke.test.mjs' + - 'test/byok.test.mjs' + - 'pnpm-lock.yaml' + - 'package.json' + - 'packages/tui/package.json' + workflow_dispatch: + inputs: + tag: + description: 'Optional tag matching the source version (dry run; does not publish)' + required: false + type: string +permissions: + contents: read +concurrency: + group: cli-release-${{ inputs.tag || github.ref }} + cancel-in-progress: false +jobs: + build: + runs-on: ubuntu-latest + timeout-minutes: 25 + outputs: + version: ${{ steps.release.outputs.version }} + matrix: ${{ steps.release.outputs.matrix }} + tag: ${{ steps.release.outputs.tag }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + fetch-depth: 0 + persist-credentials: false + - uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: '24' + cache: pnpm + - name: Validate release version and revision + id: release + shell: bash + run: | + node --input-type=module -e 'import { appendFileSync } from "node:fs"; import { cliBuildVersion, cliReleaseTargets } from "./scripts/lib/cli-release.mjs"; const tag = process.env.REQUESTED_TAG || `v${cliBuildVersion(process.cwd())}`; const version = cliBuildVersion(process.cwd(), tag); appendFileSync(process.env.GITHUB_OUTPUT, `version=${version}\ntag=${tag}\nmatrix=${JSON.stringify({include: cliReleaseTargets})}\n`); appendFileSync(process.env.GITHUB_ENV, `MCODE_RELEASE_TAG=${tag}\n`);' + if [ "$EVENT_NAME" = push ]; then + test "$(git rev-parse "refs/tags/$REQUESTED_TAG^{commit}")" = "$(git rev-parse HEAD)" + fi + env: + EVENT_NAME: ${{ github.event_name }} + REQUESTED_TAG: ${{ inputs.tag || (github.event_name == 'push' && github.ref_name) || '' }} + - run: pnpm install --frozen-lockfile + - uses: ./.github/actions/setup-gitleaks + - name: Scan source history + run: gitleaks git --redact --config .gitleaks.toml --log-opts=--all + - run: pnpm verify + env: + MCODE_VERIFY_REPORT_DIR: ${{ runner.temp }}/build-report + - name: Scan distribution + run: gitleaks dir dist --redact --config .gitleaks.toml + - name: Package the tagged CLI + shell: bash + run: node scripts/package-cli-release.mjs "$MCODE_RELEASE_TAG" "$RUNNER_TEMP/package" + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: cli-package + path: ${{ runner.temp }}/package/ + retention-days: 14 + if-no-files-found: error + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + if: ${{ always() }} + with: + name: cli-build-report + path: ${{ runner.temp }}/build-report/ + retention-days: 14 + install: + needs: build + env: + MCODE_RELEASE_TAG: ${{ needs.build.outputs.tag }} + runs-on: ${{ matrix.os }} + timeout-minutes: 20 + strategy: + fail-fast: false + matrix: ${{ fromJSON(needs.build.outputs.matrix) }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + persist-credentials: false + - uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: ${{ matrix.node }} + cache: pnpm + - run: pnpm install --frozen-lockfile + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + with: + name: cli-package + path: ${{ runner.temp }}/package + - run: pnpm verify --profile package + env: + MCODE_RELEASE_ARCHIVE: ${{ runner.temp }}/package/minimax-code-${{ needs.build.outputs.version }}.tar.gz + MCODE_VERIFY_REPORT_DIR: ${{ runner.temp }}/install-report + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + if: ${{ always() }} + with: + name: cli-install-${{ matrix.os }}-${{ matrix.node }} + path: ${{ runner.temp }}/install-report/ + retention-days: 14 + publish: + needs: [build, install] + if: github.event_name == 'push' + permissions: + contents: write + runs-on: ubuntu-latest + timeout-minutes: 5 + env: + MCODE_RELEASE_TAG: ${{ needs.build.outputs.tag }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + persist-credentials: false + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: '24' + package-manager-cache: false + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + with: + name: cli-package + path: ${{ runner.temp }}/package + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + with: + pattern: cli-install-* + path: ${{ runner.temp }}/reports + - name: Publish only the authenticated, validated archive + run: node scripts/publish-cli-release.mjs + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + MCODE_RELEASE_DIRECTORY: ${{ runner.temp }}/package + MCODE_RELEASE_REPORTS: ${{ runner.temp }}/reports diff --git a/.github/workflows/compatibility.yml b/.github/workflows/compatibility.yml index 1650db893..1b716b138 100644 --- a/.github/workflows/compatibility.yml +++ b/.github/workflows/compatibility.yml @@ -16,7 +16,8 @@ jobs: strategy: fail-fast: false matrix: - os: [ubuntu-latest, macos-latest, windows-latest] + # Windows validation is temporarily paused until its checks are reliable. + os: [ubuntu-latest, macos-latest] node: ["22.19.0", "24.2.0", "25", "26"] include: - os: ubuntu-latest diff --git a/.github/workflows/label-issue-product.yml b/.github/workflows/label-issue-product.yml new file mode 100644 index 000000000..71ef1ba40 --- /dev/null +++ b/.github/workflows/label-issue-product.yml @@ -0,0 +1,71 @@ +name: Label issue product + +on: + issues: + types: [opened, edited] + +permissions: + issues: write + +concurrency: + group: issue-product-${{ github.event.issue.number }} + cancel-in-progress: false + +jobs: + label-product: + runs-on: ubuntu-latest + timeout-minutes: 5 + if: ${{ github.repository == 'MiniMax-AI/minimax-code' && github.event.repository.private == false && !github.event.issue.pull_request }} + steps: + - name: Synchronize product labels + env: + GH_TOKEN: ${{ github.token }} + shell: bash + run: | + node <<'EOF' + const fs = require('node:fs'); + + async function main() { + const event = JSON.parse(fs.readFileSync(process.env.GITHUB_EVENT_PATH, 'utf8')); + const issuePath = `/repos/${process.env.GITHUB_REPOSITORY}/issues/${event.issue.number}`; + async function request(path, method = 'GET', body) { + const response = await fetch(`${process.env.GITHUB_API_URL}${path}`, { + method, + headers: { + Authorization: `Bearer ${process.env.GH_TOKEN}`, + Accept: 'application/vnd.github+json', + 'Content-Type': 'application/json', + }, + body: body === undefined ? undefined : JSON.stringify(body), + }); + if (!response.ok) throw new Error(`GitHub ${method} failed: ${response.status}`); + return response.status === 204 ? undefined : response.json(); + } + + // Read current state so queued edits cannot restore an outdated label. + const issue = await request(issuePath); + const body = (issue.body || '').replace(/\r\n/g, '\n'); + const product = body.match(/^### Product or interface\n+([^\n]+)\n*(?=### |$)/m)?.[1].trim(); + const products = new Map([ + ['Desktop app', 'desktop'], + ['CLI - interactive TUI', 'tui'], + ['CLI - headless', null], + ['CLI - ACP', null], + ['Source build or repository tooling', null], + ]); + // Leave manually triaged issues alone when no valid form answer exists. + if (!products.has(product)) return; + const desired = products.get(product); + const labels = issue.labels.map(label => label.name); + if (desired && !labels.includes(desired)) { + await request(`${issuePath}/labels`, 'POST', { labels: [desired] }); + } + for (const label of ['desktop', 'tui']) { + if (label !== desired && labels.includes(label)) { + await request(`${issuePath}/labels/${label}`, 'DELETE'); + } + } + } + + main(); + EOF diff --git a/.github/workflows/performance.yml b/.github/workflows/performance.yml new file mode 100644 index 000000000..c24f2c25e --- /dev/null +++ b/.github/workflows/performance.yml @@ -0,0 +1,86 @@ +name: Performance +on: + pull_request: + types: [opened, synchronize, reopened, labeled, unlabeled] + workflow_dispatch: + inputs: + base: + description: Baseline commit or branch + required: true + default: main + type: string + suite: + description: Performance suite (full includes startup and long history) + required: true + default: full + type: choice + options: [full, basic] +permissions: + contents: read +jobs: + performance: + # Unrelated label events must not replace the actual performance check. + name: ${{ contains(fromJSON('["labeled","unlabeled"]'), github.event.action) && github.event.label.name != 'perf:full' && 'performance (label ignored)' || 'performance' }} + if: ${{ !contains(fromJSON('["labeled","unlabeled"]'), github.event.action) || github.event.label.name == 'perf:full' }} + strategy: + matrix: + suite: ${{ fromJSON((inputs.suite == 'full' || (github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'perf:full'))) && '["full"]' || '["basic"]') }} + concurrency: + group: performance-${{ github.event_name }}-${{ github.ref }}-${{ github.event_name == 'workflow_dispatch' && matrix.suite || 'auto' }} + cancel-in-progress: true + runs-on: macos-15 + timeout-minutes: ${{ matrix.suite == 'full' && 45 || 15 }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + path: candidate + ref: ${{ github.event.pull_request.head.sha || github.sha }} + persist-credentials: false + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + path: baseline + ref: ${{ github.event.pull_request.base.sha || inputs.base }} + persist-credentials: false + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + repository: KonghaYao/harness-perf-benchmark + ref: 8ad1e09f7653d7e97b83b4f5b4d347ca2fa129c7 + path: benchmark + persist-credentials: false + - uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0 + with: + version: 9.12.0 + package_json_file: candidate/package.json + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: 22.23.2 + package-manager-cache: false + - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 + with: + bun-version: 1.4.2 + - name: Build baseline + working-directory: baseline + run: | + pnpm install --frozen-lockfile + pnpm build + - name: Build candidate + working-directory: candidate + run: | + pnpm install --frozen-lockfile + pnpm build + - name: Install pinned benchmark + working-directory: benchmark + run: bun install --frozen-lockfile + - name: Compare on this runner + env: + PERF_SUITE: ${{ matrix.suite }} + run: node candidate/scripts/perf/run.mjs --base baseline --head candidate --benchmark benchmark --suite "$PERF_SUITE" --out "$RUNNER_TEMP/performance" + - name: Upload performance evidence + if: ${{ always() }} + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: performance-${{ matrix.suite }}-${{ github.run_attempt }} + path: ${{ runner.temp }}/performance/ + include-hidden-files: true + retention-days: 14 + if-no-files-found: warn diff --git a/.github/workflows/source-candidate.yml b/.github/workflows/source-candidate.yml index 6e93d83c3..878f7736c 100644 --- a/.github/workflows/source-candidate.yml +++ b/.github/workflows/source-candidate.yml @@ -56,7 +56,8 @@ jobs: strategy: fail-fast: false matrix: - os: [ubuntu-latest, macos-latest, windows-latest] + # Windows validation is temporarily paused until its checks are reliable. + os: [ubuntu-latest, macos-latest] steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: @@ -122,7 +123,7 @@ jobs: with: pattern: candidate-validation-* path: ${{ runner.temp }}/candidate/reports - - name: Require three matching validation reports + - name: Require matching Linux and macOS validation reports shell: bash run: node scripts/source-candidate.mjs finalize --archive "$RUNNER_TEMP/candidate/minimax-code-source.tar.gz" --revision "$REVISION" --reports "$RUNNER_TEMP/candidate/reports" env: diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3e4adf074..2505d6e43 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -37,15 +37,15 @@ pnpm install --frozen-lockfile pnpm verify ``` -`pnpm verify` runs the complete gate list in the same order as GitHub CI. Normal PR and main-branch checks use Node.js 24 on Linux, macOS and Windows. The Linux job runs this full profile; the other two platforms use `pnpm verify --profile platform`, which omits only the duplicate TypeScript compiler check. All three jobs still export source, build, and test their own platform artifacts. Gates that depend on platform behaviour are selected by platform rather than skipped silently; run `pnpm verify --list` or `pnpm verify --profile platform --list` to inspect either plan. Individual gates remain available as their own scripts, such as `pnpm typecheck` or `pnpm test:byok`, while you iterate. +`pnpm verify` runs the complete gate list in the same order as GitHub CI. Normal PR and main-branch checks use Node.js 24 on Linux and macOS. Windows CI is temporarily paused while its checks are made reliable. The Linux job runs this full profile; the macOS job uses `pnpm verify --profile platform`, which omits only the duplicate TypeScript compiler check. Both jobs still export source, build, and test their own platform artifacts. Gates that depend on platform behaviour are selected by platform rather than skipped silently; run `pnpm verify --list` or `pnpm verify --profile platform --list` to inspect either plan. Individual gates remain available as their own scripts, such as `pnpm typecheck` or `pnpm test:byok`, while you iterate. CI writes per-gate timing and exit metadata to the Job Summary and a seven-day `verification--node--` artifact. For a local report, set `MCODE_VERIFY_REPORT_DIR` to a directory outside the repository. Reports distinguish `PASS`, `FAIL`, intentional `SKIP`, and `NOT_RUN` after a failure. JSON is checkpointed before and after each gate; a cancelled run may leave `RUNNING`, which is not a pass. If installation fails before verification starts, no verification report is available. Reports do not collect command output, environment variables, or runtime data; read the corresponding gate's job log for failure details, including the existing bounded BYOK timeout diagnostics. CI jobs have a 15-minute verification limit and a 10-minute release-audit limit. Existing README files, `CONTRIBUTING.md`, `.github/PULL_REQUEST_TEMPLATE.md`, Markdown under `docs/`, and media directly under `docs/assets/` use the `docs` profile when they are the only changed paths. That profile checks the source inventory and generated paths, exports the committed source, and tests release tooling. History and source-snapshot secret scans still run; platform builds and distribution scans are skipped. Mixed changes, unknown paths, missing comparisons, and any `release/` inventory change get full CI. Documentation-only changes skip the platform matrix entirely. The `verification` aggregate check always runs and rejects failed, cancelled, or unexpectedly skipped jobs. Use it together with `source-history-artifact` as required checks when configuring branch protection; this repository's automation does not change administrative settings. -`Node compatibility` runs weekly and on demand against macOS, Linux and Windows with Node 22.19.0, 24.2.0, 25 and 26. It does not run automatically on PRs. Dispatch it on the selected branch for changes to supported Node versions, native dependencies or compatibility-sensitive verification tooling, and before a source release. This covers the minimum versions of the two supported ranges and the additional supported majors. Deferring those versions from ordinary PR checks can delay regression discovery; a known failure in a supported version still needs resolution before release. Dependabot proposes weekly Actions and npm updates, grouping Actions and development-tool minor/patch updates. External Actions use reviewed full commit SHAs, while local actions and reusable workflows come from the same checked-out revision. +`Node compatibility` runs weekly and on demand against macOS and Linux with Node 22.19.0, 24.2.0, 25 and 26. Windows is also temporarily excluded from this matrix. It does not run automatically on PRs. Dispatch it on the selected branch for changes to supported Node versions, native dependencies or compatibility-sensitive verification tooling, and before a source release. This covers the minimum versions of the two supported ranges and the additional supported majors. Deferring those versions from ordinary PR checks can delay regression discovery; a known failure in a supported version still needs resolution before release. Dependabot proposes weekly Actions and npm updates, grouping Actions and development-tool minor/patch updates. External Actions use reviewed full commit SHAs, while local actions and reusable workflows come from the same checked-out revision. -Source candidates are requested independently through the `Source candidate` workflow; ordinary PRs and main pushes do not produce them. Its three-platform archive validation is described in [Releasing](docs/releasing.md). Product npm and installer publication remains a separate release process. +Source candidates are requested independently through the `Source candidate` workflow; ordinary PRs and main pushes do not produce them. Its Linux/macOS archive validation is described in [Releasing](docs/releasing.md). `CLI release` builds npm-installable tar.gz packages from version tags and validates the same archive before attaching it to a GitHub Release. Its `package` verification profile tests installation of an existing archive; it does not replace full source verification. npm registry and official installer publication remain separate release processes. Source export reports separate archive creation, extraction, inventory validation, hashing, and cleanup timings in `export.json`. Windows uses native `tar` after complete archive preflight; other systems use the Node extractor. The preflight rejects traversal, links, duplicate entries and Git metadata before writing files. A machine without native `tar` falls back to Node. Set `MCODE_SOURCE_EXTRACTOR=node` or `native` when comparing extractors locally. @@ -55,6 +55,33 @@ Test files are declared in `test/vitest-suites.json`, grouped by the gate that r Review added or removed files before running `node scripts/source-inventory.mjs --write`. Updating the inventory must not bypass checks for private protocols, internal addresses, credentials, or third-party licensing. Before a source release, also scan the complete Git history with Gitleaks; see the [release process](docs/releasing.md). +## Performance checks + +Every PR runs the pinned 100-round upstream benchmark. Add the `perf:full` label +for the changes below; it automatically selects startup, 100-round and 300-round +long-history tests instead. Keep the label until merge so new commits run the +full suite too. + +| Change | Required performance coverage | +| --- | --- | +| Performance optimization, including CPU, memory, startup or throughput fixes | `perf:full` | +| History construction/serialization, token estimation/caching, session storage, streaming or the tool execution loop | `perf:full` | +| Large runtime refactors spanning multiple components, or runtime dependency changes affecting these paths | `perf:full` | +| Other changes, including documentation-only edits and mechanical changes outside runtime paths | Automatic 100-round basic check | + +The author adds the label; reviewers verify the classification. File count alone +does not make a change a large runtime refactor. Before merging a labeled PR, +require a successful `performance` check with `Suite: full` for the latest PR +head and intended base, and link the run in the PR's Validation section. Failed, +cancelled or inconclusive runs do not satisfy this requirement. If the base +changes after measurement, rerun against the updated base. + +Removing `perf:full` restores the basic suite; do not remove it to bypass a +failure. Unrelated label changes do not run benchmarks. This is a review rule; +repository administrators must configure required checks separately. See +[Performance CI](docs/performance-ci.md) for manual dispatch, runner configuration +and raw evidence. + ## Capability boundaries Preserve MiniMax OAuth, Token Plan, BYOK, mcode-tools, search, plugins, connectors, updates, and feedback. Do not solve standalone-build problems by removing capabilities. Managed services use public clients; internal HTTP services, generated IDL, and cloud executor implementations are outside this repository. diff --git a/docs/installation.md b/docs/installation.md index 1d8987a93..9939cccac 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -1,4 +1,35 @@ -# Install from source +# Installation packages and source builds + +## Install a GitHub release archive + +When a CLI release is available on [GitHub Releases](https://github.com/MiniMax-AI/minimax-code/releases), +download `minimax-code-X.Y.Z.tar.gz` and the matching `.sha256` file. This is an npm +installation package containing the built CLI; no source build or pnpm is needed. +Install Node.js 22.19+ (22.x), 24.2+ (24.x), 25 or 26 first. npm still needs network +access to public npm for runtime dependencies. Native dependencies can require +Python and C/C++ build tools when no matching prebuilt binary is available. + +For example, for an available `v0.4.13` release: + +```bash +# Linux; on macOS use: shasum -a 256 -c minimax-code-0.4.13.tar.gz.sha256 +sha256sum -c minimax-code-0.4.13.tar.gz.sha256 +npm install --global ./minimax-code-0.4.13.tar.gz --registry=https://registry.npmjs.org/ --include=optional --ignore-scripts=false --allow-scripts=better-sqlite3 +mcode --version +``` + +Keep optional dependencies enabled and allow the native SQLite installation +script. The tag determines the installed version. GitHub archive installation is +validated on Linux and macOS; Windows package acceptance is currently not run. + +This archive uses the same `@minimax-ai/code` package name, `mcode` command and +default user data directory as the official npm CLI. Installing it globally into +the same npm prefix replaces that npm installation. Update to another GitHub +version by explicitly installing its archive; the built-in updater follows the +official npm registry channel and does not select GitHub release assets. To remove +the package, use `npm uninstall --global @minimax-ai/code`. User data remains in place. + +## Install from source The official CLI is available as [`@minimax-ai/code`](https://www.npmjs.com/package/@minimax-ai/code). Public npm `latest` was 0.4.12 on 2026-09-18. Follow the [official quick start](https://agent.minimax.io/docs/cli/quick-start) or the [README installation steps](../README.md#quick-start) for the macOS / Linux / WSL installer, Windows PowerShell installer, or npm installation. @@ -8,7 +39,7 @@ For npm, use the command in the README: it explicitly selects the public registr This guide builds the 0.4.12 source preview. Workspace/local build manifests remain `private: true` to prevent accidental publishing. A source checkout may contain additional reviewed distribution changes; matching version strings alone do not establish byte-for-byte or build-provenance equivalence with the official npm tarball. Use the committed source revision and release receipt to identify a source build. -For a source build, you need Git, Node.js 22.19+ (22.x), 24.2+ (24.x), 25, or 26, and pnpm 9.12.0. Regular CI uses Node.js 24 across Linux, macOS, and Windows. The weekly and manual compatibility matrix covers Node.js 22.19.0, 24.2.0, 25, and 26 on all three platforms. Initial installation and build require access to public npm. +For a source build, you need Git, Node.js 22.19+ (22.x), 24.2+ (24.x), 25, or 26, and pnpm 9.12.0. Regular CI uses Node.js 24 across Linux and macOS. The weekly and manual compatibility matrix covers Node.js 22.19.0, 24.2.0, 25, and 26 on both platforms. Windows CI and source-candidate validation are temporarily paused while their checks are made reliable. Initial installation and build require access to public npm. Node 24.0 and 24.1 are unsupported: their bundled libuv can return inconsistent Windows file identity metadata, causing safe configuration reads to fail. [Node 24.2.0](https://nodejs.org/en/blog/release/v24.2.0) includes libuv 1.51.0 with the [upstream fix](https://github.com/libuv/libuv/commit/82cdfb75f). Use a current patch release of a supported Node line. diff --git a/docs/performance-ci.md b/docs/performance-ci.md new file mode 100644 index 000000000..0625e81b4 --- /dev/null +++ b/docs/performance-ci.md @@ -0,0 +1,82 @@ +# Performance CI + +Every PR runs the `performance` check: the original 100-round scenario by default. +Adding `perf:full` automatically runs the full suite. While the label remains, +new commits and reopened PRs run the full suite too. Removing it starts a basic +run and cancels any previous automatic run for that PR. Unrelated label changes +skip benchmarking and do not replace the active `performance` check. Both +revisions build separately, then run serially on one `macos-15` runner with Node +22.23.2 and Bun 1.4.2. Build and installation time are excluded. + +| Mode | Trigger | Scenarios | Job timeout | +| --- | --- | --- | --- | +| `basic` | PR without `perf:full`; optional manual run | `upstream-100` | 15 minutes | +| `full` | Add `perf:full`, then new commits/reopens while labeled; optional manual run | Startup, `upstream-100`, `history-300` | 45 minutes | + +| Scenario | Input | Purpose | +| --- | --- | --- | +| `startup` | 1 tool round, minimal body | Startup and shutdown | +| `upstream-100` | 100 rounds × approximately 4 KiB, 64-character chunks, zero delay | Original [harness-perf-benchmark](https://github.com/KonghaYao/harness-perf-benchmark) workload | +| `history-300` | 300 rounds × approximately 8 KiB | History growth beyond the original 1,048,576 UTF-16-unit token cache budget | + +The upstream commit is pinned in `scripts/perf/config.json` and the workflow. +Its generator, mock, sampler and CPU accounting run without source edits. +An observing loopback proxy forwards the request body and response stream to +the mock, validating the complete assistant/tool history on every request. +It records request bytes and validation duration in `requests.jsonl`, without +headers. This adds observer latency to wall time; it is identical on both sides +and outside the sampled CLI process tree. Absolute timings differ from running +the upstream demo without the observer. +The adapter uses synthetic workspaces and the repository's offline network +fixture. No live-model key is needed. The long scenario advertises a +2,000,000-token mock context to prevent compaction from shortening the history; +this is a stress fixture, not production model capacity. The standard scenario +keeps upstream provider defaults. The suite does not measure live-model quality, +latency, compaction acceptance or other products' rankings. + +| Rule | Initial setting | +| --- | --- | +| Repetitions | One excluded warmup per revision/scenario; three measured pairs, alternating base/head order | +| Duration regression | Median increases by more than both 25% and 1 second | +| CPU regression | Median increases by more than both 20% and 0.5 core-seconds | +| Sampled peak RSS regression | Median increases by more than both 20% and 32 MiB | +| Pair confirmation | Candidate is worse in all three matched pairs | +| Noise | Range exceeds 30% of median on either revision: `INCONCLUSIVE`, exit 2 | +| Correctness | Exit 0; N+1 requests with complete wire history; N matched bash commands/results; expected echo, pwd and listing output; complete stored bodies; expected final response | +| Invalid evidence | Missing samples, wrong sampler, bad metrics, incomplete runs or changed fixture: fail | + +These are initial regression budgets, not service-level targets or statistical +confidence intervals. `REGRESSION` exits 1; `PASS` exits 0. An inconclusive run +needs another clean measurement; it is neither a confirmed regression nor a pass. +Tune budgets from repeated same-revision measurements. Review changes to +scenarios, comparison logic or limits as changes to the performance contract. +The workflow does not change branch protection; maintainers can add +`performance` as a required check after runner calibration; its name stays the +same for basic and full runs. The summary and artifact identify the selected +suite. Follow the [label and review requirements](../CONTRIBUTING.md#performance-checks) +when deciding which PRs need full coverage. + +The Job Summary contains machine/method and comparison tables. The 14-day +artifact includes JSON results, configuration, commit and fixture hashes, +warmup/measured samples, CLI/mock logs, and synthetic histories. Confirmed +regressions get a separate candidate CPU-profile run, excluded from comparison. +Reports checkpoint after each run; setup failures before the runner starts +remain in job logs. CPU and sampled RSS are compared separately; the combined +CU score is not a gate. Sampling can miss brief memory peaks. + +Install and build both checkouts with identical Node/pnpm versions and install +the pinned benchmark with `bun install --frozen-lockfile`, then run: + +```bash +node scripts/perf/run.mjs --base /path/to/base --head /path/to/head \ + --benchmark /path/to/pinned-benchmark --out /tmp/new-performance-output +``` + +The output directory must be new. The CLI defaults to `--suite basic`; pass +`--suite full` explicitly for the complete suite or `--scenario startup` for a +focused local check. Once the workflow is merged into the default branch, open +**Actions → Performance → Run workflow**, choose the PR branch, set the baseline +ref, and select `full` for long-history testing. Manual runs use a separate +concurrency group and do not cancel automatic PR checks. The workflow uses read-only +permissions and `pull_request`, without comments or secrets. See GitHub's +[event security guidance](https://docs.github.com/en/actions/reference/security/securely-using-pull_request_target). diff --git a/docs/releasing.md b/docs/releasing.md index 1f915ff56..79f274aaf 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -1,4 +1,74 @@ -# Releasing a source preview +# Releasing MiniMax Code + +## Tag-triggered CLI installation packages + +Run the release command from a clean checkout of the latest reviewed `origin/main`. +Git and an authenticated `gh-axi` or `gh` are required: + +```bash +pnpm release:cli --version 0.4.13 --dry-run +pnpm release:cli --version 0.4.13 +``` + +The dry run checks the starting revision, versions and remote refs without changing +files, creating commits or pushing. The release command then: + +1. Creates `release/v0.4.13` from the reviewed starting commit. +2. Bumps `package.json` and `packages/tui/package.json` together and commits them. +3. Creates the annotated `v0.4.13` tag on that version commit. +4. Atomically pushes the release branch and tag, without pushing `main`. +5. Opens a version PR back to `main`; merge it through the normal review process. + +CI requires the tag, both committed source versions and `mcode --version` to agree. +It does not override the source version during a build. An existing tag or release +branch, a non-increasing version, uncommitted files, or a starting commit other +than the latest `origin/main` stops the command before version changes. + +The tag starts the release workflow independently of the version PR. If a network +or PR-creation failure occurs, inspect the local and remote branch/tag before +retrying: the version commit and tag are retained for recovery. If both refs were +pushed but opening the PR failed, open that version PR manually. Never delete and +recreate an already distributed tag. Merge the version PR before starting the +next release so `main` carries the released version. + +The workflow runs the full verification profile and secret scans, builds one +`minimax-code-X.Y.Z.tar.gz` npm installation package, and authenticates and installs +that same archive on Linux and macOS with Node 22.19.0, 24.2.0, 25 and 26. Each +installation checks the generated `mcode` launcher, native SQLite, ripgrep, and +the offline smoke/BYOK suites. Windows validation remains paused. + +Only after every installation succeeds does CI create a GitHub Release with the +archive and its `.sha256` checksum. Tags such as `v0.4.13-rc.1` create prereleases. +Release creation starts as a draft; assets are uploaded before it becomes public. +Existing releases are never overwritten. If publication fails after draft +creation, inspect the draft and workflow artifacts before deciding whether to +finish publication manually or delete only the incomplete draft and rerun. +Never move an already distributed tag to different code. + +To exercise this workflow without publication, dispatch `CLI release` on a +selected branch. An optional tag input must match the committed source version. A manual dispatch only builds and +validates Actions artifacts, even when the requested tag already exists. +PRs that change release tooling also run the build/install matrix using the +committed source version, without creating a tag or publishing a release. + +To reproduce the packaging and installation checks locally, use a clean reviewed +commit and keep output outside the repository: + +```bash +export MCODE_RELEASE_TAG="v$(node -p 'require("./package.json").version')" +pnpm verify +node scripts/package-cli-release.mjs "$MCODE_RELEASE_TAG" /tmp/mcode-release +MCODE_RELEASE_ARCHIVE="/tmp/mcode-release/minimax-code-${MCODE_RELEASE_TAG#v}.tar.gz" pnpm verify --profile package +``` + +The `package` profile validates installation of an existing archive; it does not +replace full source verification. The archive includes the compiled CLI, runtime +assets, licenses and a `release.json` source receipt. npm installs external runtime +dependencies, including native dependencies, for the user's platform. It does not +include Node.js and is not an offline bundle. See [installation](installation.md#install-a-github-release-archive). +This workflow does not publish to the npm registry or change the official installer. + +## Source previews The current source target is MiniMax Code 0.4.12. Workspace and local-build manifests remain `private: true` to prevent accidental npm publication. A source release, npm package, and installer are separate artifacts with separate verification. @@ -15,9 +85,11 @@ Before npm or installer distribution, separately validate the published package ## Automated source candidates -The `Source candidate` workflow exports the selected commit without Git history, verifies its receipt, and scans both repository history and the extracted source. Linux, macOS, and Windows runners authenticate the same archive, install from public npm into fresh stores, and run the archive verification profile. +The `Source candidate` workflow exports the selected commit without Git history, verifies its receipt, and scans both repository history and the extracted source. Linux and macOS runners authenticate the same archive, install from public npm into fresh stores, and run the archive verification profile. + +Windows validation is temporarily paused across source verification, Node compatibility, and source candidates. Candidate reports cover only Linux and macOS; a successful candidate does not establish Windows acceptance. Restore the Windows workflow matrices and the required report set in `scripts/source-candidate.mjs` together when Windows checks are reliable again. -After all platform jobs pass, the workflow creates a `source-candidate-` artifact containing: +After both platform jobs pass, the workflow creates a `source-candidate-` artifact containing: - `minimax-code-source.tar.gz` - The archive receipt and SHA-256 file diff --git a/docs/tui-capabilities.md b/docs/tui-capabilities.md index 485e3d092..4fb1d449b 100644 --- a/docs/tui-capabilities.md +++ b/docs/tui-capabilities.md @@ -22,6 +22,21 @@ The evidence column summarizes the historical TUI 0.3.11 restoration record from | Files, shell, subagents, sessions, headless, ACP | Actual runtime retained | BYOK, file reads, session resume, ACP, sandbox, and status protocol tests | | Built-in skills, MCP, plugin tools | Original TUI assets and activation conditions retained | Asset build, plugin, and MCP tests; no claim that every skill has passed a real task | +## ACP Skill commands + +ACP clients receive enabled Skills alongside built-in slash commands when a session +is created, loaded, resumed, or forked. Discovery uses the session's Agent and +workspace, including installed plugin Skills. Command names come from the runtime +Skill roster (for example, `/review` or `/plugin:review`), not the installation +package name. Select a command and append instructions to invoke it through the +normal Agent turn. `/skills [filter]` lists the session's available Skills. + +Built-in command names take priority over conflicting Skill names. Disabled, +duplicate, and invalid command names are omitted. If Skill discovery fails, +built-in commands remain available. Reopen the session after installing or enabling +Skills to refresh its command menu. Protocol tests cover command discovery and +prompt forwarding; this does not establish live Zed or model acceptance. + ## Desktop boundary Background workspace indexing is removed from this distribution. Runtime startup and conversation turns do not collect workspace snapshots, create workspace ZIP archives, or upload/retry them for cloud indexing. The semantic workspace search tool and its enablement policy are also removed; a saved indexing preference cannot reactivate them. Existing indexing records are left inert. User-directed file reading, search, and Git operations remain available. @@ -65,3 +80,16 @@ the configured default. Omitting `--model` preserves existing startup behavior. The untargeted `--session` picker cannot be combined with `--model`; provide an ID or use `--continue` instead. If opening or continuing a Session fails, the override and initial prompt are not applied. + +## Feature panels and chat restoration + +In regular mode, independent feature panels occupy the complete visible terminal +area, including short Rewind previews and scope pickers. Closing a panel restores +the current conversation. When running content shrinks across the native scrolling +boundary, the renderer reconstructs the current session to fill the viewport and +keep its history unique. This reconstruction clears earlier shell scrollback; +ordinary updates keep native scrolling and selection behavior. + +Rewind and Fork history-loading hints disappear as soon as their lists are ready. +Returning from a cancelled operation must not leave a stale loading message in the +Composer. Rewind displays its completed result after a successful operation. diff --git a/package.json b/package.json index d150417e6..e99d8e504 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "minimax-code", - "version": "0.4.12", + "version": "0.5.0", "private": true, "type": "module", "description": "Standalone MiniMax Code TUI with managed accounts, BYOK models, cloud tools, plugins and ACP.", @@ -10,6 +10,7 @@ }, "scripts": { "build": "node scripts/build.mjs", + "release:cli": "node scripts/release-cli.mjs", "start": "node dist/cli.js", "mcode": "node dist/cli.js", "verify": "node scripts/verify.mjs", diff --git a/packages/agent-modules/context-manager/src/token-estimator.ts b/packages/agent-modules/context-manager/src/token-estimator.ts index 9b4c6e7f2..559c00a2e 100644 --- a/packages/agent-modules/context-manager/src/token-estimator.ts +++ b/packages/agent-modules/context-manager/src/token-estimator.ts @@ -47,6 +47,7 @@ import type { AgentMessage } from '@earendil-works/pi-agent-core'; import { Buffer } from 'node:buffer'; +import { createHash } from 'node:crypto'; import { countTokens as countO200kBase } from 'gpt-tokenizer/model/gpt-4o'; import type { ContextCompactionSummaryMessage } from './types.js'; @@ -199,8 +200,14 @@ function getLastAssistantUsageInfo( return undefined; } +const MAX_INLINE_CACHE_KEY_UNITS = 256; +const MAX_CACHED_TEXT_ENTRIES = 2_048; + export class BpeTokenEstimator implements TokenEstimator { private readonly countExactTokens: (text: string) => number; + // Bounded content keys survive detached histories without retaining long + // message bodies. Hash UTF-16 units so lone surrogates stay distinct. + private readonly textTokens = new Map(); /** * @param encoder Override the default o200k_base tokenizer. Tests inject @@ -222,12 +229,28 @@ export class BpeTokenEstimator implements TokenEstimator { estimateTextTokens(text: string): number { if (!text) return 0; + const key = + text.length <= MAX_INLINE_CACHE_KEY_UNITS + ? `text:${text}` + : `sha256:${createHash('sha256').update(text, 'utf16le').digest('hex')}`; + const cached = this.textTokens.get(key); + if (cached !== undefined) { + this.textTokens.delete(key); + this.textTokens.set(key, cached); + return cached; + } try { if (text.length > MAX_EXACT_TOKENIZER_CHARS && hasOversizedAlphanumericRun(text)) { return estimateTextTokensUpperBound(text); } const tokens = this.countExactTokens(text); - return Number.isFinite(tokens) && tokens >= 0 ? tokens : estimateTextTokensUpperBound(text); + if (!Number.isFinite(tokens) || tokens < 0) return estimateTextTokensUpperBound(text); + if (this.textTokens.size >= MAX_CACHED_TEXT_ENTRIES) { + const oldest = this.textTokens.keys().next().value; + if (oldest !== undefined) this.textTokens.delete(oldest); + } + this.textTokens.set(key, tokens); + return tokens; } catch { return estimateTextTokensUpperBound(text); } @@ -290,7 +313,10 @@ export class BpeTokenEstimator implements TokenEstimator { return tokens; } case 'bashExecution': { - const m = message as AgentMessage & { command?: string; output?: string }; + const m = message as AgentMessage & { + command?: string; + output?: string; + }; tokens += this.estimateTextTokens(m.command ?? ''); tokens += this.estimateTextTokens(m.output ?? ''); return tokens; diff --git a/packages/config/src/local-model-provider-write.ts b/packages/config/src/local-model-provider-write.ts index 8a19a428d..9a0bb6562 100644 --- a/packages/config/src/local-model-provider-write.ts +++ b/packages/config/src/local-model-provider-write.ts @@ -1,5 +1,5 @@ import { randomBytes } from 'node:crypto'; -import fs, { existsSync, readFileSync } from 'node:fs'; +import fs from 'node:fs'; import { dirname, join } from 'node:path'; import yaml from 'js-yaml'; @@ -277,13 +277,21 @@ async function atomicWriteFile(filePath: string, content: string): Promise } function readLocalRawConfig(configPath: string): Record { - if (!existsSync(configPath)) return {}; + // Callers create missing files before locking. A read failure must abort the + // update rather than turn an existing configuration into an empty document. + const source = fs.readFileSync(configPath, 'utf-8'); + let parsed: unknown; try { - const parsed = yaml.load(readFileSync(configPath, 'utf-8')); - return isPlainRecord(parsed) ? parsed : {}; + parsed = yaml.load(source); } catch { - return {}; + // YAML errors include source snippets, which may contain credentials. + throw new LocalModelProviderConfigValidationError('Invalid config.yaml: unable to parse YAML'); } + if (parsed == null) return {}; + if (!isPlainRecord(parsed) || Object.getPrototypeOf(parsed) !== Object.prototype) { + throw new LocalModelProviderConfigValidationError('Invalid config.yaml: expected a YAML mapping'); + } + return parsed; } function withoutEmptyEntries( diff --git a/packages/config/test/config-update-preservation.test.ts b/packages/config/test/config-update-preservation.test.ts new file mode 100644 index 000000000..175c9da38 --- /dev/null +++ b/packages/config/test/config-update-preservation.test.ts @@ -0,0 +1,187 @@ +import fs from "node:fs"; +import os from "node:os"; +import { join } from "node:path"; +import yaml from "js-yaml"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + getConfigPath, + resetConfig, + setLegacyByokProviderMigrationEnabled, + setManagedPresetBaseUrlSyncEnabled, +} from "../src/config.js"; +import { + compareAndSetLocalModelContext, + updateLocalByokConfig, + updateLocalModelSelection, +} from "../src/local-model-provider-write.js"; +import { + compareAndSetLocalModelContext as compareLegacyContext, + updateLocalByokConfig as updateLegacyByok, + updateLocalConfigFile, +} from "../../local-runtime/src/config/update.js"; + +const secret = "synthetic-config-preservation-key"; +const contextInput = { + providerId: "minimax", + modelId: "synthetic-model", + expectedContextLimit: 100, + contextLimit: 200, +}; +const writers = [ + [ + "permission mode", + () => updateLocalConfigFile({ field: "permissionMode", set: "default" }), + ], + [ + "prepared payload", + () => + updateLocalConfigFile({}, new Set(["thinking"]), { + thinking: { effort: "high" }, + }), + ], + [ + "legacy BYOK", + () => + updateLegacyByok((draft) => { + draft.minimax_api = { apiKey: secret }; + }), + ], + [ + "legacy model context", + () => compareLegacyContext(contextInput, async () => true), + ], + [ + "model selection", + () => updateLocalModelSelection({ modelKey: "minimax/synthetic-model" }), + ], + [ + "BYOK", + () => + updateLocalByokConfig((draft) => { + draft.minimax_api = { apiKey: secret }; + }), + ], + [ + "model context", + () => compareAndSetLocalModelContext(contextInput, async () => true), + ], +] as const; + +let root: string; +let dataDir: string; +let configPath: string; + +beforeEach(() => { + root = fs.mkdtempSync(join(os.tmpdir(), "mcode-config-preservation-")); + dataDir = join(root, "profile"); + fs.mkdirSync(dataDir); + vi.spyOn(os, "homedir").mockReturnValue(root); + vi.stubEnv("MINIMAX_DATA_DIR", dataDir); + vi.stubEnv("__MAVIS_RUNTIME_MANAGED", "0"); + resetConfig(); + setLegacyByokProviderMigrationEnabled(false); + setManagedPresetBaseUrlSyncEnabled(false); + configPath = getConfigPath(); +}); + +afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllEnvs(); + resetConfig(); + setLegacyByokProviderMigrationEnabled(true); + setManagedPresetBaseUrlSyncEnabled(true); + fs.rmSync(root, { recursive: true, force: true }); +}); + +describe.each(writers)("%s config updates", (_name, write) => { + it.each([ + ["malformed YAML", `provider: [${secret}\n`], + ["duplicate keys", `apiKey: ${secret}\napiKey: duplicate\n`], + ["sequence", `- ${secret}\n`], + ["scalar", `${secret}\n`], + ["timestamp", "2026-01-01\n"], + ])( + "rejects %s without replacing the original file", + async (_kind, source) => { + fs.writeFileSync(configPath, source); + const rename = vi.spyOn(fs.promises, "rename"); + + await expect(write()).rejects.toThrow(/Invalid config\.yaml/); + + expect(fs.readFileSync(configPath, "utf8")).toBe(source); + expect(rename).not.toHaveBeenCalled(); + expect(fs.readdirSync(dataDir)).toEqual(["config.yaml"]); + }, + ); + + it("does not expose configuration contents in parse errors", async () => { + fs.writeFileSync(configPath, `provider: [${secret}\n`); + + const error = await write().catch((error: unknown) => error); + + expect(error).toBeInstanceOf(Error); + expect(String(error)).not.toContain(secret); + expect(error).not.toHaveProperty("cause"); + }); + + it("aborts on a read failure and releases the lock", async () => { + const source = `permissionMode: auto\n# ${secret}\n`; + fs.writeFileSync(configPath, source); + const read = vi.spyOn(fs, "readFileSync").mockImplementation(() => { + throw Object.assign(new Error(`EACCES ${secret}`), { code: "EACCES" }); + }); + const rename = vi.spyOn(fs.promises, "rename"); + + await expect(write()).rejects.toThrow( + "Local runtime failed to update config file", + ); + + read.mockRestore(); + expect(fs.readFileSync(configPath, "utf8")).toBe(source); + expect(rename).not.toHaveBeenCalled(); + expect(fs.readdirSync(dataDir)).toEqual(["config.yaml"]); + }); +}); + +describe("valid local config updates", () => { + it.each([undefined, "", "# empty config\n", "{}\n", "null\n"])( + "initializes a missing or empty document: %s", + async (source) => { + if (source !== undefined) fs.writeFileSync(configPath, source); + await updateLocalConfigFile({ permissionMode: "default" }); + expect(yaml.load(fs.readFileSync(configPath, "utf8"))).toEqual({ + permissionMode: "default", + }); + }, + ); + + it("preserves unrelated settings and credentials", async () => { + const source = { + permissionMode: "auto", + custom_provider: { example: { options: { apiKey: secret }, models: {} } }, + customSetting: { enabled: true }, + }; + fs.writeFileSync(configPath, yaml.dump(source)); + + await updateLocalConfigFile({ field: "permissionMode", set: "default" }); + + expect(yaml.load(fs.readFileSync(configPath, "utf8"))).toEqual({ + ...source, + permissionMode: "default", + }); + }); + + it("can update the file after the user repairs malformed YAML", async () => { + fs.writeFileSync(configPath, "provider: ["); + await expect( + updateLocalConfigFile({ permissionMode: "default" }), + ).rejects.toThrow(); + fs.writeFileSync(configPath, "permissionMode: auto\n"); + + await updateLocalConfigFile({ permissionMode: "default" }); + + expect(yaml.load(fs.readFileSync(configPath, "utf8"))).toEqual({ + permissionMode: "default", + }); + }); +}); diff --git a/packages/local-runtime-v2/src/infra/file/canonical-history-jsonl.ts b/packages/local-runtime-v2/src/infra/file/canonical-history-jsonl.ts index b4788e4ea..8b0425042 100644 --- a/packages/local-runtime-v2/src/infra/file/canonical-history-jsonl.ts +++ b/packages/local-runtime-v2/src/infra/file/canonical-history-jsonl.ts @@ -244,8 +244,7 @@ interface MinimalNativeCompactionSummary extends Record { } export type CanonicalHistoryMessage = - | TimestampedCanonicalHistoryMessage - | MinimalNativeCompactionSummary; + TimestampedCanonicalHistoryMessage | MinimalNativeCompactionSummary; export interface CanonicalTurnConfigTool extends Record { readonly tool_name: string; @@ -431,10 +430,15 @@ export class CanonicalHistoryJsonlDataSource { return { status, records: await this.readActive() }; } - async append(records: readonly CanonicalHistoryEnvelope[]): Promise { + async append( + records: readonly CanonicalHistoryEnvelope[], + verifiedActive?: readonly CanonicalHistoryEnvelope[], + ): Promise { const appended = decodeRecords(records); if (appended.length === 0) return; - const active = await this.readActive(); + // The Session owner may pass its strict read from this same write lane. + // Other callers keep the existing disk read; no history survives between operations. + const active = verifiedActive ?? (await this.readActive()); assertCanonicalHistoryAppend(active, appended); await appendJsonl(this.options.activePath, appended); } @@ -473,7 +477,9 @@ export class CanonicalHistoryJsonlDataSource { const existing = await readStrictEnvelopeFile(snapshotPath); if (revisionOfNormalized(existing) !== revision) throw new Error('revision differs'); } catch (error) { - throw new Error(`Canonical history snapshot conflict: ${snapshotPath}`, { cause: error }); + throw new Error(`Canonical history snapshot conflict: ${snapshotPath}`, { + cause: error, + }); } return status; } diff --git a/packages/local-runtime-v2/src/service/session-system/messages/history/canonical-history-materializer.ts b/packages/local-runtime-v2/src/service/session-system/messages/history/canonical-history-materializer.ts index 344e143f7..8b77ce844 100644 --- a/packages/local-runtime-v2/src/service/session-system/messages/history/canonical-history-materializer.ts +++ b/packages/local-runtime-v2/src/service/session-system/messages/history/canonical-history-materializer.ts @@ -64,20 +64,21 @@ interface MaterializedHistory { export async function ensureCanonicalHistoryMaterialized( options: CanonicalHistoryMaterializerOptions, ): Promise { - const target = await options.files.readTarget(options.paths.messages); if (!options.legacyHistory) { - if (target === undefined) await options.files.publishInitial(options.paths.messages, []); + if (!(await targetExists(options))) + await options.files.publishInitial(options.paths.messages, []); return; } const checkpoint = await options.legacyHistory.checkpoints.getCheckpoint(options.sessionId); if (checkpoint) { - if (target === undefined) { + if (!(await targetExists(options))) { throw new Error(`Canonical history target is missing after migration: ${options.sessionId}`); } return; } + const target = await options.files.readTarget(options.paths.messages); const selected = await selectLegacySource(options); const normalized = normalizeSelectedHistory(options.sessionId, selected.records); const materialized = @@ -103,6 +104,12 @@ export async function ensureCanonicalHistoryMaterialized( }); } +async function targetExists(options: CanonicalHistoryMaterializerOptions): Promise { + return options.files.targetExists + ? options.files.targetExists(options.paths.messages) + : (await options.files.readTarget(options.paths.messages)) !== undefined; +} + async function selectLegacySource(options: CanonicalHistoryMaterializerOptions): Promise<{ readonly source: CanonicalHistoryMigrationSource; readonly records: readonly CanonicalHistoryEnvelope[]; diff --git a/packages/local-runtime-v2/src/service/session-system/messages/history/canonical-history-provider.ts b/packages/local-runtime-v2/src/service/session-system/messages/history/canonical-history-provider.ts index 41da0a493..68be9a7a1 100644 --- a/packages/local-runtime-v2/src/service/session-system/messages/history/canonical-history-provider.ts +++ b/packages/local-runtime-v2/src/service/session-system/messages/history/canonical-history-provider.ts @@ -305,7 +305,7 @@ export function createSessionSystemCanonicalHistoryProvider( `Canonical compaction generation verification failed: ${change.sessionId}`, ); } - await syncIndex(change.sessionId, paths, true); + await syncIndex(change.sessionId, paths, true, committed); const result = { revision: canonicalActiveHistoryRevision(committed), generation: committedGeneration, @@ -335,7 +335,7 @@ export function createSessionSystemCanonicalHistoryProvider( } options.activity?.notify(sessionId); } - await syncIndex(sessionId, paths, true); + await syncIndex(sessionId, paths, true, records); return historySnapshot(records, allowPendingToolCallTail); } @@ -372,8 +372,9 @@ export function createSessionSystemCanonicalHistoryProvider( sessionId: string, paths: SessionHistoryPaths, allowMissingParent = false, + verifiedRecords?: readonly CanonicalHistoryEnvelope[], ): Promise { - const active = await files.readActiveStrict(paths.messages); + const active = verifiedRecords ?? (await files.readActiveStrict(paths.messages)); const adapter = indexes.get(sessionId) ?? createCanonicalHistoryIndexAdapter(paths.sessionDir); indexes.set(sessionId, adapter); try { @@ -433,7 +434,7 @@ async function appendWithInterruptedToolRoundRecovery(input: { await input.files.replaceActive(input.path, recovery.records); return; } - await input.files.append(input.path, input.appended); + await input.files.append(input.path, input.appended, input.existing); } function appendEnvelopes(change: SessionCanonicalHistoryChange): CanonicalHistoryEnvelope[] { diff --git a/packages/local-runtime-v2/src/service/session-system/sessions/representation/canonical-history-contract.ts b/packages/local-runtime-v2/src/service/session-system/sessions/representation/canonical-history-contract.ts index 8b55fde63..69d05fbe6 100644 --- a/packages/local-runtime-v2/src/service/session-system/sessions/representation/canonical-history-contract.ts +++ b/packages/local-runtime-v2/src/service/session-system/sessions/representation/canonical-history-contract.ts @@ -13,6 +13,8 @@ export { } from '../../../../infra/file/canonical-history.js'; export interface CanonicalHistoryFileAdapter { + /** Existence-only migration probe; callers still validate records before using them. */ + targetExists?(path: string): Promise; readTarget(path: string): Promise; readTargetStrict(path: string): Promise; readActive(path: string): Promise; @@ -23,7 +25,12 @@ export interface CanonicalHistoryFileAdapter { path: string, records: readonly CanonicalHistoryEnvelope[], ): Promise; - append(path: string, records: readonly CanonicalHistoryEnvelope[]): Promise; + /** verifiedActive must be read in the same serialized operation, never cached across writes. */ + append( + path: string, + records: readonly CanonicalHistoryEnvelope[], + verifiedActive?: readonly CanonicalHistoryEnvelope[], + ): Promise; replace(path: string, records: readonly CanonicalHistoryEnvelope[]): Promise; replaceActive(path: string, records: readonly CanonicalHistoryEnvelope[]): Promise; publishSnapshot( diff --git a/packages/local-runtime-v2/src/service/session-system/sessions/representation/canonical-history.ts b/packages/local-runtime-v2/src/service/session-system/sessions/representation/canonical-history.ts index 6265e4f6e..74516dfe5 100644 --- a/packages/local-runtime-v2/src/service/session-system/sessions/representation/canonical-history.ts +++ b/packages/local-runtime-v2/src/service/session-system/sessions/representation/canonical-history.ts @@ -20,6 +20,9 @@ export function createCanonicalHistoryFileAdapter( class JsonlCanonicalHistoryFileAdapter implements CanonicalHistoryFileAdapter { constructor(private readonly options: CreateCanonicalHistoryFileAdapterOptions) {} + async targetExists(path: string) { + return exists(path); + } async readTarget(path: string) { if (!(await exists(path))) return undefined; return this.source(path).readActive(); @@ -43,8 +46,12 @@ class JsonlCanonicalHistoryFileAdapter implements CanonicalHistoryFileAdapter { async publishInitial(path: string, records: readonly CanonicalHistoryEnvelope[]) { return this.source(path).publishInitial(records); } - async append(path: string, records: readonly CanonicalHistoryEnvelope[]) { - await this.source(path).append(records); + async append( + path: string, + records: readonly CanonicalHistoryEnvelope[], + verifiedActive?: readonly CanonicalHistoryEnvelope[], + ) { + await this.source(path).append(records, verifiedActive); } async replace(path: string, records: readonly CanonicalHistoryEnvelope[]) { await this.source(path).replace(records); diff --git a/packages/local-runtime-v2/src/service/turn-system/agent-host/history/durable-canonical-history-store.ts b/packages/local-runtime-v2/src/service/turn-system/agent-host/history/durable-canonical-history-store.ts index c14466521..e55a311ee 100644 --- a/packages/local-runtime-v2/src/service/turn-system/agent-host/history/durable-canonical-history-store.ts +++ b/packages/local-runtime-v2/src/service/turn-system/agent-host/history/durable-canonical-history-store.ts @@ -27,9 +27,10 @@ export interface DurableCanonicalHistoryProvider { read(sessionId: string): Promise; /** Active post-mutation history; the final tool round may still await results. */ readActive(sessionId: string): Promise; - append(change: CanonicalHistoryChange): Promise; - replace(change: CanonicalHistoryChange): Promise; - compact?(change: CanonicalHistoryCompactionChange): Promise; + /** Return the verified post-write snapshot from the same lane, or request a reread with void. */ + append(change: CanonicalHistoryChange): Promise; + replace(change: CanonicalHistoryChange): Promise; + compact?(change: CanonicalHistoryCompactionChange): Promise; settleTurnTail?(mutation: SettleTurnTailMutation): Promise; retractTurn?(mutation: RetractTurnMutation): Promise; } @@ -102,13 +103,17 @@ export class DurableCanonicalHistoryStore implements CanonicalHistoryStore { private commit( kind: 'append' | 'replace', change: CanonicalHistoryChange, - mutate: (snapshot: CanonicalHistoryChange) => Promise, + mutate: (snapshot: CanonicalHistoryChange) => Promise, ): Promise { const snapshot = captureSemanticSnapshot(change).value; validateCanonicalHistoryChange(snapshot, kind); return this.lane.run(snapshot.sessionId, async () => { - await mutate(snapshot); - return this.readActiveCommitted(snapshot.sessionId); + const committed = await mutate(snapshot); + // Session-owned providers already reread and validate inside their lane. + // Legacy adapters returning void retain the strict post-write read. + return committed === undefined + ? this.readActiveCommitted(snapshot.sessionId) + : this.readProviderSnapshot(committed); }); } diff --git a/packages/local-runtime-v2/src/service/turn-system/agent-host/history/incremental-sha256.ts b/packages/local-runtime-v2/src/service/turn-system/agent-host/history/incremental-sha256.ts index 5de417dc1..0ae6c2015 100644 --- a/packages/local-runtime-v2/src/service/turn-system/agent-host/history/incremental-sha256.ts +++ b/packages/local-runtime-v2/src/service/turn-system/agent-host/history/incremental-sha256.ts @@ -1,34 +1,10 @@ -const BLOCK_BYTES = 64; -const UTF8_CHUNK_CODE_UNITS = 8 * 1_024; - -const INITIAL_STATE = [ - 0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19, -] as const; +import { createHash } from 'node:crypto'; -const ROUND_CONSTANTS = new Uint32Array([ - 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5, - 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174, - 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da, - 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967, - 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, - 0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070, - 0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3, - 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2, -]); +const UTF8_CHUNK_CODE_UNITS = 8 * 1_024; -/** - * Dependency-free incremental SHA-256 for AgentHost semantic identity. - * Text is encoded in bounded chunks so hashing one large History string does - * not allocate another payload-sized Buffer. This is intentionally internal; - * persistence digests continue to use their owning adapters. - */ +/** Native SHA-256 with bounded UTF-8 chunks and the existing update boundaries. */ export class IncrementalSha256 { - private readonly state = new Uint32Array(INITIAL_STATE); - private readonly block = new Uint8Array(BLOCK_BYTES); - private readonly schedule = new Uint32Array(64); - private readonly textEncoder = new TextEncoder(); - private bufferedBytes = 0; - private totalBytes = 0; + private readonly hash = createHash('sha256'); private finalized = false; update(value: string): void { @@ -36,7 +12,7 @@ export class IncrementalSha256 { let offset = 0; while (offset < value.length) { const end = safeUtf8ChunkEnd(value, offset); - this.updateBytes(this.textEncoder.encode(value.slice(offset, end))); + this.hash.update(value.slice(offset, end), 'utf8'); offset = end; } } @@ -44,96 +20,7 @@ export class IncrementalSha256 { digestHex(): string { if (this.finalized) throw new Error('SHA-256 digest is already finalized.'); this.finalized = true; - const messageBytes = this.totalBytes; - this.block[this.bufferedBytes] = 0x80; - this.bufferedBytes += 1; - if (this.bufferedBytes > 56) { - this.block.fill(0, this.bufferedBytes); - this.compress(); - this.bufferedBytes = 0; - } - this.block.fill(0, this.bufferedBytes, 56); - const messageBits = messageBytes * 8; - writeUint32BigEndian(this.block, 56, Math.floor(messageBits / 0x1_0000_0000)); - writeUint32BigEndian(this.block, 60, messageBits >>> 0); - this.compress(); - return [...this.state].map((word) => word.toString(16).padStart(8, '0')).join(''); - } - - private updateBytes(bytes: Uint8Array): void { - this.totalBytes += bytes.length; - let offset = 0; - while (offset < bytes.length) { - const copied = Math.min(BLOCK_BYTES - this.bufferedBytes, bytes.length - offset); - this.block.set(bytes.subarray(offset, offset + copied), this.bufferedBytes); - this.bufferedBytes += copied; - offset += copied; - if (this.bufferedBytes !== BLOCK_BYTES) continue; - this.compress(); - this.bufferedBytes = 0; - } - } - - private compress(): void { - const words = this.schedule; - for (let index = 0; index < 16; index += 1) { - const offset = index * 4; - words[index] = - (arrayValue(this.block, offset) << 24) | - (arrayValue(this.block, offset + 1) << 16) | - (arrayValue(this.block, offset + 2) << 8) | - arrayValue(this.block, offset + 3); - } - for (let index = 16; index < 64; index += 1) { - const prior15 = arrayValue(words, index - 15); - const prior2 = arrayValue(words, index - 2); - const sigma0 = rotateRight(prior15, 7) ^ rotateRight(prior15, 18) ^ (prior15 >>> 3); - const sigma1 = rotateRight(prior2, 17) ^ rotateRight(prior2, 19) ^ (prior2 >>> 10); - words[index] = - (arrayValue(words, index - 16) + sigma0 + arrayValue(words, index - 7) + sigma1) >>> 0; - } - this.compressRounds(words); - } - - private compressRounds(words: Uint32Array): void { - let a = arrayValue(this.state, 0); - let b = arrayValue(this.state, 1); - let c = arrayValue(this.state, 2); - let d = arrayValue(this.state, 3); - let e = arrayValue(this.state, 4); - let f = arrayValue(this.state, 5); - let g = arrayValue(this.state, 6); - let h = arrayValue(this.state, 7); - for (let index = 0; index < 64; index += 1) { - const upperSigma1 = rotateRight(e, 6) ^ rotateRight(e, 11) ^ rotateRight(e, 25); - const choose = (e & f) ^ (~e & g); - const temporary1 = - (h + - upperSigma1 + - choose + - arrayValue(ROUND_CONSTANTS, index) + - arrayValue(words, index)) >>> - 0; - const upperSigma0 = rotateRight(a, 2) ^ rotateRight(a, 13) ^ rotateRight(a, 22); - const majority = (a & b) ^ (a & c) ^ (b & c); - const temporary2 = (upperSigma0 + majority) >>> 0; - h = g; - g = f; - f = e; - e = (d + temporary1) >>> 0; - d = c; - c = b; - b = a; - a = (temporary1 + temporary2) >>> 0; - } - this.state[0] = (arrayValue(this.state, 0) + a) >>> 0; - this.state[1] = (arrayValue(this.state, 1) + b) >>> 0; - this.state[2] = (arrayValue(this.state, 2) + c) >>> 0; - this.state[3] = (arrayValue(this.state, 3) + d) >>> 0; - this.state[4] = (arrayValue(this.state, 4) + e) >>> 0; - this.state[5] = (arrayValue(this.state, 5) + f) >>> 0; - this.state[6] = (arrayValue(this.state, 6) + g) >>> 0; - this.state[7] = (arrayValue(this.state, 7) + h) >>> 0; + return this.hash.digest('hex'); } } @@ -152,20 +39,3 @@ function isHighSurrogate(value: number): boolean { function isLowSurrogate(value: number): boolean { return value >= 0xdc00 && value <= 0xdfff; } - -function rotateRight(value: number, bits: number): number { - return (value >>> bits) | (value << (32 - bits)); -} - -function writeUint32BigEndian(target: Uint8Array, offset: number, value: number): void { - target[offset] = (value >>> 24) & 0xff; - target[offset + 1] = (value >>> 16) & 0xff; - target[offset + 2] = (value >>> 8) & 0xff; - target[offset + 3] = value & 0xff; -} - -function arrayValue(values: Uint8Array | Uint32Array, index: number): number { - const value = values[index]; - if (value === undefined) throw new RangeError(`SHA-256 array index is out of bounds: ${index}.`); - return value; -} diff --git a/packages/local-runtime-v2/src/service/turn-system/agent-host/history/semantic-identity.ts b/packages/local-runtime-v2/src/service/turn-system/agent-host/history/semantic-identity.ts index bccc659a1..f1a3ca7fe 100644 --- a/packages/local-runtime-v2/src/service/turn-system/agent-host/history/semantic-identity.ts +++ b/packages/local-runtime-v2/src/service/turn-system/agent-host/history/semantic-identity.ts @@ -1,3 +1,4 @@ +import { types } from 'node:util'; import { IncrementalSha256 } from './incremental-sha256.js'; export interface SemanticSnapshot { @@ -5,6 +6,9 @@ export interface SemanticSnapshot { readonly fingerprint: string; } +const ownedValues = new WeakSet(); +const fingerprints = new WeakMap(); + /** * Detach callback-owned History/event data before it becomes an in-run * identity or deferred delivery payload. Unsupported or cyclic values fail @@ -12,15 +16,98 @@ export interface SemanticSnapshot { * encoded payload size. */ export function captureSemanticSnapshot(value: T): SemanticSnapshot { - const snapshot = structuredClone(value); - const encoder = new SemanticIdentityEncoder(new IncrementalSha256()); - encodeValue(snapshot, new WeakSet(), encoder); + // Native cloning preserves external getters and aliases. Data-only wrappers + // can instead share descendants already detached and frozen by this module. + const snapshot = + typeof value === 'object' && value !== null && ownedValues.has(value) + ? value + : freezeSemanticValue( + cloneOwnedWrapper(value) ?? structuredClone(value), + new WeakSet(), + ); + let fingerprint: string | undefined; return { - value: freezeSemanticValue(snapshot, new WeakSet()), - fingerprint: encoder.digest(), + value: snapshot, + // Value-only consumers still validate and detach eagerly, but never encode + // or hash the history. Only values frozen by this module may be reused. + get fingerprint() { + if (fingerprint !== undefined) return fingerprint; + const object = typeof snapshot === 'object' && snapshot !== null ? snapshot : undefined; + fingerprint = object ? fingerprints.get(object) : undefined; + if (fingerprint === undefined) { + const encoder = new SemanticIdentityEncoder(new IncrementalSha256()); + encodeValue(snapshot, new WeakSet(), encoder); + fingerprint = encoder.digest(); + if (object) fingerprints.set(object, fingerprint); + } + return fingerprint; + }, }; } +const NATIVE_CLONE_REQUIRED = Symbol('native-clone-required'); + +function plainDataDescriptors(value: object): PropertyDescriptorMap | undefined { + // Inspecting a Proxy would invoke traps that native structuredClone rejects. + if (types.isProxy(value)) return undefined; + const prototype = Object.getPrototypeOf(value); + if ( + prototype !== Object.prototype && + prototype !== null && + !(Array.isArray(value) && prototype === Array.prototype) + ) + return undefined; + const descriptors = Object.getOwnPropertyDescriptors(value); + if (Object.values(descriptors).some((d) => d.enumerable && !('value' in d))) return undefined; + return descriptors; +} + +function cloneOwnedWrapper(value: T): T | undefined { + if (typeof value !== 'object' || value === null) return undefined; + const descriptors = plainDataDescriptors(value); + if ( + !descriptors || + !Object.values(descriptors).some( + (d) => + d.enumerable && typeof d.value === 'object' && d.value !== null && ownedValues.has(d.value), + ) + ) + return undefined; + + const copies = new WeakMap(); + const clone = (node: unknown): unknown => { + if (typeof node !== 'object' || node === null) { + if (node !== null && !['undefined', 'string', 'boolean', 'number'].includes(typeof node)) { + throw NATIVE_CLONE_REQUIRED; + } + return node; + } + if (ownedValues.has(node)) return node; + const previous = copies.get(node); + if (previous) return previous; + const fields = node === value ? descriptors : plainDataDescriptors(node); + if (!fields) throw NATIVE_CLONE_REQUIRED; + const copy = Array.isArray(node) ? new Array(fields.length!.value as number) : {}; + copies.set(node, copy); + for (const [key, field] of Object.entries(fields)) { + if (!field.enumerable) continue; + Object.defineProperty(copy, key, { + value: clone(field.value), + enumerable: true, + writable: true, + configurable: true, + }); + } + return copy; + }; + try { + return clone(value) as T; + } catch (error) { + if (error === NATIVE_CLONE_REQUIRED) return undefined; + throw error; + } +} + /** * Measures the streamed semantic representation without cloning or building * a canonical string. Used only for already-detached replay results. @@ -54,13 +141,28 @@ class SemanticIdentityEncoder { } } -function freezeSemanticValue(value: T, seen: WeakSet): T { - if (typeof value !== 'object' || value === null || seen.has(value)) return value; - seen.add(value); - Reflect.ownKeys(value).forEach((key) => { - freezeSemanticValue(Reflect.get(value, key), seen); - }); - return Object.freeze(value); +function freezeSemanticValue(value: T, ancestors: WeakSet): T { + if (typeof value !== 'object' || value === null) { + if (!['undefined', 'string', 'boolean', 'number'].includes(typeof value) && value !== null) { + throw new TypeError(`Unsupported semantic identity value: ${typeof value}.`); + } + return value; + } + if (ownedValues.has(value)) return value; + if (ancestors.has(value)) throw new TypeError('Cyclic semantic identity values are unsupported.'); + const prototype = Object.getPrototypeOf(value); + if (!Array.isArray(value) && prototype !== Object.prototype && prototype !== null) { + throw new TypeError('Semantic identity values must contain only plain objects and arrays.'); + } + if (Object.getOwnPropertySymbols(value).length > 0) { + throw new TypeError('Symbol-keyed semantic identity values are unsupported.'); + } + ancestors.add(value); + for (const key of Object.keys(value)) freezeSemanticValue(Reflect.get(value, key), ancestors); + ancestors.delete(value); + Object.freeze(value); + ownedValues.add(value); + return value; } function encodeValue( diff --git a/packages/local-runtime-v2/src/service/turn-system/production-composition.ts b/packages/local-runtime-v2/src/service/turn-system/production-composition.ts index 1acfdeb4d..42edf4f03 100644 --- a/packages/local-runtime-v2/src/service/turn-system/production-composition.ts +++ b/packages/local-runtime-v2/src/service/turn-system/production-composition.ts @@ -855,8 +855,16 @@ function adaptCanonicalHistory( identityVector: snapshot.identityVector, }; }, - append: (change) => provider.append(requireHistoryOperation(change)), - replace: (change) => provider.replace(requireHistoryOperation(change)), + append: async (change) => { + const committed = await provider.append(requireHistoryOperation(change)); + validateCanonicalHistoryMessages(committed.messages); + return { ...committed, messages: committed.messages }; + }, + replace: async (change) => { + const committed = await provider.replace(requireHistoryOperation(change)); + validateCanonicalHistoryMessages(committed.messages); + return { ...committed, messages: committed.messages }; + }, compact: async (change: CanonicalHistoryCompactionChange) => { const operation = requireHistoryOperation(change); if (operation.reason !== 'replaceMessages' || operation.operation.kind !== 'compaction') { @@ -875,14 +883,20 @@ function adaptCanonicalHistory( : { currentUserSourceIndex: metadata.currentUserSourceIndex }), } as const; if (metadata.method !== 'llm_checkpoint') { - await provider.compact({ + const committed = await provider.compact({ ...baseChange, method: metadata.method, replacementSourceIndexes: metadata.replacementSourceIndexes, }); - return; + validateCanonicalHistoryMessages(committed.messages); + return { ...committed, messages: committed.messages }; } - await provider.compact({ ...baseChange, method: metadata.method }); + const committed = await provider.compact({ + ...baseChange, + method: metadata.method, + }); + validateCanonicalHistoryMessages(committed.messages); + return { ...committed, messages: committed.messages }; }, settleTurnTail: async (input) => { if (!mutation.settleTurnTail) { diff --git a/packages/local-runtime/src/config/update.ts b/packages/local-runtime/src/config/update.ts index 21882e86f..c4d29214e 100644 --- a/packages/local-runtime/src/config/update.ts +++ b/packages/local-runtime/src/config/update.ts @@ -1,5 +1,5 @@ import { randomBytes } from 'node:crypto'; -import fs, { existsSync, readFileSync } from 'node:fs'; +import fs from 'node:fs'; import { dirname, join } from 'node:path'; import { getConfig, getConfigPath, MINIMAX_API_MODEL_CATALOG, resetConfig } from '@mavis/config'; import yaml from 'js-yaml'; @@ -310,13 +310,21 @@ export async function atomicWriteFile(filePath: string, content: string): Promis } function readLocalRawConfig(configPath: string): Record { - if (!existsSync(configPath)) return {}; + // Callers create missing files before locking. A read failure must abort the + // update rather than turn an existing configuration into an empty document. + const source = fs.readFileSync(configPath, 'utf-8'); + let parsed: unknown; try { - const parsed = yaml.load(readFileSync(configPath, 'utf-8')); - return isPlainRecord(parsed) ? parsed : {}; + parsed = yaml.load(source); } catch { - return {}; + // YAML errors include source snippets, which may contain credentials. + throw new LocalConfigValidationError('Invalid config.yaml: unable to parse YAML'); } + if (parsed == null) return {}; + if (!isPlainRecord(parsed) || Object.getPrototypeOf(parsed) !== Object.prototype) { + throw new LocalConfigValidationError('Invalid config.yaml: expected a YAML mapping'); + } + return parsed; } function applyLocalConfigUpdate(raw: Record, body: Record): void { diff --git a/packages/shared/src/asset-markup/deliver-assets.ts b/packages/shared/src/asset-markup/deliver-assets.ts index e90c68b56..d5144e3af 100644 --- a/packages/shared/src/asset-markup/deliver-assets.ts +++ b/packages/shared/src/asset-markup/deliver-assets.ts @@ -413,7 +413,8 @@ function parseMediaItem( } } - const src = attrs['src']?.trim(); + // Some models emit a local file's path as an attribute instead of src. + const src = attrs['src']?.trim() || attrs['path']?.trim(); if (!src) { return null; } diff --git a/packages/tui/package.json b/packages/tui/package.json index 78e86a24f..cee86a461 100644 --- a/packages/tui/package.json +++ b/packages/tui/package.json @@ -1,6 +1,6 @@ { "name": "@minimax/code", - "version": "0.4.12", + "version": "0.5.0", "private": true, "description": "Minimax Code CLI and TUI product entry.", "type": "module", diff --git a/packages/tui/src/acp/agent.ts b/packages/tui/src/acp/agent.ts index e7d873946..f9a4cf7a3 100644 --- a/packages/tui/src/acp/agent.ts +++ b/packages/tui/src/acp/agent.ts @@ -19,7 +19,7 @@ import type { import { buildTuiMessageParts } from '../runtime/stream-events.js'; import type { TuiMessage, TuiStreamEvent } from '../runtime/stream-events.js'; import type { TuiAcpRuntime } from './runtime.js'; -import { executeTuiAcpCommand, TUI_ACP_AVAILABLE_COMMANDS } from './commands.js'; +import { availableSkillCommands, executeTuiAcpCommand, TUI_ACP_AVAILABLE_COMMANDS } from './commands.js'; import { ACP_CONFIG_MODEL, ACP_CONFIG_PERMISSION_MODE, @@ -102,6 +102,46 @@ export function createTuiAcpAgent(options: CreateTuiAcpAgentOptions): acp.AgentA let permissionBroadcastRunning = false; const app = acp.agent({ name: 'minimax-code' }); + const advertiseAvailableCommands = (client: acp.AgentContext, sessionId: string): void => { + const attachment = sessions.get(sessionId); + if (!attachment) return; + let commands: acp.AvailableCommand[] = [...TUI_ACP_AVAILABLE_COMMANDS]; + const isCurrent = () => + sessions.get(sessionId) === attachment && !attachment.attachmentController.signal.aborted; + const notify = () => { + if (!isCurrent()) return; + void client + .notify(acp.methods.client.session.update, { + sessionId, + update: { + sessionUpdate: 'available_commands_update', + availableCommands: commands, + }, + }) + .catch(() => undefined); + }; + + // Preserve immediate native command discovery even if Skill discovery fails or stalls. + setImmediate(() => { + if (!isCurrent()) return; + notify(); + void (async () => { + const skills = await options.runtime.listSkills( + attachment.session.agentName, + undefined, + attachment.session.workspaceDir, + ); + if (!isCurrent()) return; + commands = [...TUI_ACP_AVAILABLE_COMMANDS, ...availableSkillCommands(skills)]; + notify(); + })().catch(() => undefined); + }); + // Some clients attach their notification listener after receiving the Session response. + // Retry the latest roster, so a late native-only update cannot erase discovered Skills. + const retry = setTimeout(notify, AVAILABLE_COMMANDS_RETRY_DELAY_MS); + retry.unref(); + }; + const schedulePermissionBroadcast = ( client: acp.AgentContext, attachments: readonly (readonly [string, AcpSession])[], @@ -1051,6 +1091,7 @@ export function createTuiAcpAgent(options: CreateTuiAcpAgentOptions): acp.AgentA runtime: options.runtime, sessionId: active.session.sessionId, agentName: active.session.agentName, + workspaceDir: active.session.workspaceDir, prompt: context.params.prompt, }); if (isCancelled()) return { cancelled: true as const }; @@ -1807,24 +1848,6 @@ function mapClientMcpServers(servers: readonly acp.McpServer[]): readonly TuiSes }); } -function advertiseAvailableCommands(client: acp.AgentContext, sessionId: string): void { - const notify = () => { - void client - .notify(acp.methods.client.session.update, { - sessionId, - update: { - sessionUpdate: 'available_commands_update', - availableCommands: [...TUI_ACP_AVAILABLE_COMMANDS], - }, - }) - .catch(() => undefined); - }; - - setImmediate(notify); - const retry = setTimeout(notify, AVAILABLE_COMMANDS_RETRY_DELAY_MS); - retry.unref(); -} - interface KeyedSerialExecutor { (key: string, task: (signal: AbortSignal) => Promise): Promise; reserved(key: string, task: (signal: AbortSignal) => Promise): Promise; diff --git a/packages/tui/src/acp/commands.ts b/packages/tui/src/acp/commands.ts index 07f5d9638..e7e434e24 100644 --- a/packages/tui/src/acp/commands.ts +++ b/packages/tui/src/acp/commands.ts @@ -1,7 +1,8 @@ import * as acp from '@agentclientprotocol/sdk'; import { TUI_COMMAND_DESCRIPTORS } from '../application/command-descriptors.js'; -import type { TuiModel } from '../runtime/port.js'; +import { sanitizeTerminalText } from '../tui/rendering/terminal-text.js'; +import type { TuiModel, TuiSkillList } from '../runtime/port.js'; import type { TuiAcpRuntime } from './runtime.js'; export const TUI_ACP_AVAILABLE_COMMANDS = [ @@ -41,6 +42,34 @@ export const TUI_ACP_AVAILABLE_COMMANDS = [ }, ] satisfies readonly acp.AvailableCommand[]; +/** Keep native commands authoritative and expose only invocable Skill names. */ +export function availableSkillCommands(result: TuiSkillList): acp.AvailableCommand[] { + const seen = new Set(TUI_ACP_AVAILABLE_COMMANDS.map((command) => command.name)); + return (result.skills ?? []) + .flatMap((skill): acp.AvailableCommand[] => { + const name = skill.name.trim().toLowerCase(); + if ( + skill.enabled === false || + seen.has(name) || + name.length > 128 || + !/^[a-z0-9][a-z0-9._-]*(?::[a-z0-9][a-z0-9._-]*)?$/u.test(name) + ) + return []; + seen.add(name); + const description = sanitizeTerminalText( + skill.displayDescription ?? skill.description ?? '', + ).trim(); + return [ + { + name, + description: description ? `[Skill] ${description}` : '[Skill]', + input: { hint: '[instructions]' }, + }, + ]; + }) + .sort((left, right) => left.name.localeCompare(right.name)); +} + export type TuiAcpCommandResult = | { readonly handled: false } | { @@ -63,6 +92,7 @@ export async function executeTuiAcpCommand(options: { >; readonly sessionId: string; readonly agentName?: string; + readonly workspaceDir?: string; readonly prompt: readonly acp.ContentBlock[]; }): Promise { const command = parseCommand(options.prompt); @@ -112,7 +142,11 @@ export async function executeTuiAcpCommand(options: { return { handled: true, output: formatSkills( - await options.runtime.listSkills(options.agentName, command.input || undefined), + await options.runtime.listSkills( + options.agentName, + command.input || undefined, + options.workspaceDir, + ), ), }; } diff --git a/packages/tui/src/application/assistant-content.ts b/packages/tui/src/application/assistant-content.ts index c4363b121..c868b7027 100644 --- a/packages/tui/src/application/assistant-content.ts +++ b/packages/tui/src/application/assistant-content.ts @@ -251,9 +251,10 @@ function simplifyAssetMarkup(content: string, assets: DeliverAssetItem[]): strin for (let pass = 0; pass < 3; pass += 1) { const segments = parseDeliverAssetsContent(remaining); const extracted = collectAssetSegments(segments); + // Keep the parser's cleanup even when every attachment was invalid. + remaining = extracted.text; if (extracted.assets.length === 0) break; assets.push(...extracted.assets); - remaining = extracted.text; } return remaining; } diff --git a/packages/tui/src/runtime/adapter.ts b/packages/tui/src/runtime/adapter.ts index c43f6573e..637ce328f 100644 --- a/packages/tui/src/runtime/adapter.ts +++ b/packages/tui/src/runtime/adapter.ts @@ -541,8 +541,12 @@ export class TuiRuntimeAdapter implements TuiRuntime { getActiveRun(sessionId: string): Promise { return this.productAccess.getActiveRun(sessionId); } - listSkills(agentName?: string, keyword?: string): Promise { - return this.productAccess.listSkills(agentName, keyword); + listSkills( + agentName?: string, + keyword?: string, + workspaceDir?: string, + ): Promise { + return this.productAccess.listSkills(agentName, keyword, workspaceDir); } inspectProjectMcp( sessionId: string, diff --git a/packages/tui/src/runtime/adapters/product-access.ts b/packages/tui/src/runtime/adapters/product-access.ts index dfdc65988..e5ab81440 100644 --- a/packages/tui/src/runtime/adapters/product-access.ts +++ b/packages/tui/src/runtime/adapters/product-access.ts @@ -396,11 +396,12 @@ export class TuiProductAccess { async listSkills( agentName = this.defaultAgentName, keyword?: string, + workspaceDir = this.workspaceDir, ): Promise { const service = this.context.service("skill.list"); const result = await service.listRuntimeSkills({ agentName, - ...(this.workspaceDir ? { workspaceDir: this.workspaceDir } : {}), + ...(workspaceDir ? { workspaceDir } : {}), includePluginSkills: true, }); const normalizedKeyword = keyword?.trim().toLocaleLowerCase(); diff --git a/packages/tui/src/runtime/port.ts b/packages/tui/src/runtime/port.ts index e39f31965..cda4fe686 100644 --- a/packages/tui/src/runtime/port.ts +++ b/packages/tui/src/runtime/port.ts @@ -294,7 +294,7 @@ export interface TuiInspectionPort { agentName?: string, customInstructions?: string, ): Promise; - listSkills(agentName?: string, keyword?: string): Promise; + listSkills(agentName?: string, keyword?: string, workspaceDir?: string): Promise; listMcpServers(keyword?: string, sessionId?: string): Promise; inspectProjectMcp(sessionId: string): Promise; getContextSnapshot(sessionId: string): Promise; diff --git a/packages/tui/src/tui/controller/product/session-mutation-flow.ts b/packages/tui/src/tui/controller/product/session-mutation-flow.ts index e818d8234..1c788cb04 100644 --- a/packages/tui/src/tui/controller/product/session-mutation-flow.ts +++ b/packages/tui/src/tui/controller/product/session-mutation-flow.ts @@ -708,6 +708,7 @@ export class TuiSessionMutationFlow { ...(this.options.nowMs ? { nowMs: this.options.nowMs() } : {}), }); this.options.setHint(undefined); + this.options.onChanged(); const handle = this.options.surfaceHost.pushFeature({ screen: picker, focus: picker }); this.mutationScreen = handle; this.invocations.set(sequence, { @@ -1201,6 +1202,7 @@ export class TuiSessionMutationFlow { ...(this.options.nowMs ? { nowMs: this.options.nowMs() } : {}), }); this.options.setHint(undefined); + this.options.onChanged(); const handle = this.options.surfaceHost.pushFeature({ screen: picker, focus: picker, @@ -1446,6 +1448,7 @@ export class TuiSessionMutationFlow { if (sequence === this.mutationSequence) { this.closeMutationScreen(); this.options.setHint(undefined); + this.options.onChanged(); } this.invocations.delete(sequence); } diff --git a/packages/tui/src/tui/engine/LOCAL_CHANGES.json b/packages/tui/src/tui/engine/LOCAL_CHANGES.json index 3fb374d81..a6aecc965 100644 --- a/packages/tui/src/tui/engine/LOCAL_CHANGES.json +++ b/packages/tui/src/tui/engine/LOCAL_CHANGES.json @@ -163,11 +163,11 @@ }, { "path": "tui-main-screen.ts", - "currentSha256": "146fd4222ae5a48d1f439de2f280b111789151a30c1bdd2735e32fff07329b9d", + "currentSha256": "daf48c4d9a594abd282d4d4fd855373d17cb1033d0b5bdc2396e1727c2186153", "changeIds": ["L005", "L017", "L027", "L033", "L034"], "upstreamCommit": "6c4f360264397c59801f6da2bdac13e3b1fcbe91", "reason": "Keep strict TypeScript fixes and stream full and differential renders through Pi's bounded terminal writer. 缩放期间仅重绘可见尾部 常规模式在差分比较前剥离行首 OSC 133 zone 标记。 内容收缩或历史内容变化触发回退重绘时仅更新可见区域。", - "behaviorImpact": "Large regular-mode renders preserve output order without constructing a single V8-limited terminal string. 150ms 后及退出前重放完整投影历史。 内部导航标记不再写入终端,避免 iTerm2 左侧箭头和命令条噪声。 Session 结束后的状态更新不再发送 ESC 3J 清除原生滚动历史,当前视口仍显示最新内容。" + "behaviorImpact": "Regular updates preserve native scrollback. When document shrink reveals previously scrolled rows, replay the complete current session to fill the viewport and avoid duplicate history. Rebuilding clears pre-launch shell scrollback. Resize retains the existing delayed history replay." }, { "path": "tui.ts", diff --git a/packages/tui/src/tui/engine/LOCAL_CHANGES.md b/packages/tui/src/tui/engine/LOCAL_CHANGES.md index e2f84903b..fdb41e94a 100644 --- a/packages/tui/src/tui/engine/LOCAL_CHANGES.md +++ b/packages/tui/src/tui/engine/LOCAL_CHANGES.md @@ -115,3 +115,11 @@ Remove `L024` when the selected Pi baseline natively matches legacy-terminal `Ct - Adaptation: the original contribution widened hits over two live content columns. The reserved gutter prevents those presses from swallowing content clicks or text selection. `chat-layout.ts` opts into the three-column gutter. - Evidence: `test/unit/tui-scrollbar-interaction.test.ts` drives SGR press, motion, release and wheel events through VirtualTerminal, covering track jumps, thumb grabs, narrow terminals, content routing, selection, overlays and the actual fullscreen ChatLayout. - Removal condition: the selected Pi baseline provides equivalent reserved-gutter track and drag interaction and MCode migrates to it. + +## L034: Regular viewport reconstruction after document shrink + +- Product contract: after running content or a feature panel closes, show the complete current chat viewport with its Composer and status line. Every current-session row must occur once in native history. +- Minimal difference: when a shorter document would move the viewport origin backwards, clear and replay the complete current projection. Other updates retain differential rendering and resize retains the existing delayed history replay. +- Tradeoff: structural reconstruction clears native scrollback, including shell history from before TUI startup. Initial short chat documents retain natural document placement. +- Evidence: local-delta tests assert every visible row and the complete history, while real Tasks and feature lifecycle tests cover short/long content, background growth, paging, resize, nested panels and return to chat. Virtual terminals do not establish native iTerm2 touchpad acceptance. +- Removal condition: the selected Pi baseline provides equivalent complete viewport and unique-history behavior. diff --git a/packages/tui/src/tui/engine/tui-main-screen.ts b/packages/tui/src/tui/engine/tui-main-screen.ts index f6b837f15..d2c51ae5b 100644 --- a/packages/tui/src/tui/engine/tui-main-screen.ts +++ b/packages/tui/src/tui/engine/tui-main-screen.ts @@ -304,6 +304,19 @@ export class TuiMainScreen extends TuiBase implements TUI { // Helper to redraw either the complete logical document or only the visible viewport. // Viewport-only redraws preserve the terminal's native scrollback. const fullRender = (clear: boolean, viewportOnly = false): void => { + // Native scrollback cannot move backwards with a shrinking document. Keep the + // previous viewport origin so rows already scrolled out are not painted twice, + // and growth still writes every row before it scrolls out. Resize previews are + // temporary: they show the new tail until the pending full history replay. + if (viewportOnly && !this.historyReplayPending && newLines.length <= prevViewportTop) { + // Nothing remains addressable on screen; rebuild with one consistent origin. + viewportOnly = false; + } + const start = viewportOnly + ? this.historyReplayPending + ? Math.max(0, newLines.length - height) + : prevViewportTop + : 0; this.fullRedrawCount += 1; const output = new BoundedTerminalWriter((data) => this.terminal.write(data)); output.append("\x1b[?2026h"); // Begin synchronized output @@ -311,7 +324,6 @@ export class TuiMainScreen extends TuiBase implements TUI { output.append(this.deleteKittyImages(this.previousKittyImageIds)); output.append(viewportOnly ? "\x1b[2J\x1b[H" : "\x1b[2J\x1b[H\x1b[3J"); } - const start = viewportOnly ? Math.max(0, newLines.length - height) : 0; for (let i = start; i < newLines.length; i++) { if (i > start) output.append("\r\n"); const line = newLines[i]!; @@ -339,8 +351,7 @@ export class TuiMainScreen extends TuiBase implements TUI { } else { this.maxLinesRendered = Math.max(this.maxLinesRendered, newLines.length); } - const bufferLength = Math.max(height, newLines.length); - this.previousViewportTop = Math.max(0, bufferLength - height); + this.previousViewportTop = Math.max(start, newLines.length - height); this.positionHardwareCursor(cursorPos, newLines.length); this.previousLines = newLines; this.previousKittyImageIds = this.collectKittyImageIds(newLines); @@ -387,6 +398,15 @@ export class TuiMainScreen extends TuiBase implements TUI { return; } + // A shorter document can bring previously scrolled rows back into view. + // Rebuild the complete projection so the viewport is full and native history + // contains each row once; neither tail replay nor blank padding can do both. + if (Math.max(0, newLines.length - height) < prevViewportTop) { + logRedraw("document shrink reveals scrolled rows"); + fullRender(true); + return; + } + // Content shrunk below the working area and no overlays - re-render to clear empty rows // (overlays need the padding, so only do this when no overlays are active) // Configurable via setClearOnShrink() or PI_CLEAR_ON_SHRINK=0 env var diff --git a/packages/tui/src/tui/features/session-mutation/copy.en.ts b/packages/tui/src/tui/features/session-mutation/copy.en.ts index f588e5b1f..53f5d12a0 100644 --- a/packages/tui/src/tui/features/session-mutation/copy.en.ts +++ b/packages/tui/src/tui/features/session-mutation/copy.en.ts @@ -142,7 +142,7 @@ export const SESSION_MUTATION_COPY = { 'sessionMutation.hint.editSubmitting': 'Replacing the message and rewinding history…', 'sessionMutation.hint.editResubmit': 'Edited draft preserved · Enter resubmit · Esc cancel', 'sessionMutation.hint.loadingFork': 'Loading fork history…', - 'sessionMutation.hint.loadingRewind': 'Loading rewind history…', + 'sessionMutation.hint.loadingRewind': 'Finding messages to rewind to…', 'sessionMutation.hint.loadingEdit': 'Loading the latest message…', 'sessionMutation.hint.noForkPrompts': 'No persisted user prompts are available. Send a message, then retry /fork.', diff --git a/packages/tui/src/tui/features/session-mutation/copy.zh-Hans.ts b/packages/tui/src/tui/features/session-mutation/copy.zh-Hans.ts index 8c6ce5596..ad4021ad2 100644 --- a/packages/tui/src/tui/features/session-mutation/copy.zh-Hans.ts +++ b/packages/tui/src/tui/features/session-mutation/copy.zh-Hans.ts @@ -130,7 +130,7 @@ export const ZH_HANS_SESSION_MUTATION_COPY = { 'sessionMutation.hint.editSubmitting': '正在替换消息并回退历史…', 'sessionMutation.hint.editResubmit': '编辑草稿已保留 · Enter 重新提交 · Esc 取消', 'sessionMutation.hint.loadingFork': '正在加载分支历史…', - 'sessionMutation.hint.loadingRewind': '正在加载回退历史…', + 'sessionMutation.hint.loadingRewind': '正在查找可回退的消息…', 'sessionMutation.hint.loadingEdit': '正在加载最新消息…', 'sessionMutation.hint.noForkPrompts': '暂无可用的已持久化用户提示词。发送消息后请重试 /fork。', 'sessionMutation.confirm.fork.title': '确认创建分支', diff --git a/packages/tui/src/tui/shell/regular-feature-presenter.ts b/packages/tui/src/tui/shell/regular-feature-presenter.ts index 273323c2f..419122488 100644 --- a/packages/tui/src/tui/shell/regular-feature-presenter.ts +++ b/packages/tui/src/tui/shell/regular-feature-presenter.ts @@ -89,7 +89,10 @@ class TuiRegularFeatureViewport implements Component { render(width: number): string[] { if (this.screen.renderViewport && !getLayoutNode(this.screen.layoutRoot)) { this.layout = undefined; - return [...this.screen.renderViewport(width, Math.max(1, this.terminal.rows))]; + const height = Math.max(1, this.terminal.rows); + const lines = this.screen.renderViewport(width, height); + // Feature screens own the viewport even when their content is short. + return Array.from({ length: height }, (_, index) => lines[index] ?? ''); } this.layout = renderLayoutFrame( this.screen.layoutRoot, diff --git a/packages/tui/test/unit/acp-agent.test.ts b/packages/tui/test/unit/acp-agent.test.ts index b188fd720..68f7bcc96 100644 --- a/packages/tui/test/unit/acp-agent.test.ts +++ b/packages/tui/test/unit/acp-agent.test.ts @@ -1,6 +1,7 @@ import * as acp from '@agentclientprotocol/sdk'; import { describe, expect, it, vi } from 'vitest'; +import { TUI_ACP_AVAILABLE_COMMANDS } from '../../src/acp/commands.js'; import { createTuiAcpAgent } from '../../src/acp/agent.js'; import { TuiAcpPromptContinuation } from '../../src/acp/prompt-continuation.js'; import type { TuiAcpRuntime } from '../../src/acp/runtime.js'; @@ -79,7 +80,7 @@ function createRuntime( Promise.resolve(options.contextSnapshot ?? { status: 'empty' as const }), ); const getSessionUsage = vi.fn(async () => options.sessionUsage ?? {}); - const listSkills = vi.fn(async () => options.skills ?? {}); + const listSkills = vi.fn(async () => options.skills ?? {}); const listMcpServers = vi.fn(async () => [...(options.mcpServers ?? [])]); const requestCompaction = vi.fn(async () => ({ success: true })); const getPlanModeCapabilities = vi.fn(async () => ({ entryEnabled: true })); @@ -267,6 +268,219 @@ function createRuntime( } describe('MiniMax Code ACP agent', () => { + it.each(['new', 'load', 'resume', 'fork'] as const)( + 'advertises session Skills on %s and forwards Skill instructions to the Runtime', + async (method) => { + const { runtime, listSkills, sendMessage, getSession, forkSession } = createRuntime( + [{ type: 'session-status', status: 'finished' }], + { + skills: { + skills: [ + { + name: 'matt:review', + description: 'Review changes', + enabled: true, + }, + { name: 'repo-test', displayDescription: 'Test the project' }, + { name: 'MATT:REVIEW', description: 'Duplicate' }, + { + name: 'Compact', + description: 'Must not replace a native command', + }, + { name: 'disabled', enabled: false }, + { name: 'invalid/name' }, + { name: 'invalid name' }, + { name: '' }, + { name: 'x'.repeat(129) }, + { name: 'bad\u001b[31m' }, + ], + }, + }, + ); + getSession.mockImplementation(async (sessionId) => ({ + sessionId, + workspaceDir: '/project', + agentName: 'reviewer', + })); + forkSession.mockResolvedValue({ + session: { + sessionId: 'session-fork', + workspaceDir: '/project', + agentName: 'reviewer', + }, + }); + const updates: acp.SessionNotification[] = []; + const agent = createTuiAcpAgent({ runtime, version: '1.2.3' }); + const client = acp + .client({ name: 'zed' }) + .onNotification(acp.methods.client.session.update, ({ params }) => updates.push(params)); + await client.connectWith(agent, async (connection) => { + await connection.request(acp.methods.agent.initialize, { + protocolVersion: acp.PROTOCOL_VERSION, + clientCapabilities: {}, + }); + const response = await connection.request(acp.methods.agent.session[method], { + sessionId: 'session-1', + cwd: '/project', + mcpServers: [], + }); + const sessionId = 'sessionId' in response ? response.sessionId : 'session-1'; + const roster = () => + updates + .filter( + (update) => + update.sessionId === sessionId && + update.update.sessionUpdate === 'available_commands_update', + ) + .at(-1)?.update; + const expected = { + sessionUpdate: 'available_commands_update', + availableCommands: [ + ...TUI_ACP_AVAILABLE_COMMANDS, + { + name: 'matt:review', + description: '[Skill] Review changes', + input: { hint: '[instructions]' }, + }, + { + name: 'repo-test', + description: '[Skill] Test the project', + input: { hint: '[instructions]' }, + }, + ], + }; + await vi.waitFor(() => expect(roster()).toEqual(expected)); + expect(listSkills).toHaveBeenCalledWith( + method === 'new' ? undefined : 'reviewer', + undefined, + '/project', + ); + // A delayed listener retry must retain the complete Skill roster. + await new Promise((resolve) => setTimeout(resolve, 130)); + expect(roster()).toEqual(expected); + await expect( + connection.request(acp.methods.agent.session.prompt, { + sessionId, + prompt: [{ type: 'text', text: '/matt:review focus on error handling' }], + }), + ).resolves.toEqual({ stopReason: 'end_turn' }); + expect(sendMessage).toHaveBeenCalledWith( + expect.objectContaining({ + id: sessionId, + content: '/matt:review focus on error handling', + }), + expect.any(AbortSignal), + ); + }); + }, + ); + + it.each(['reject', 'stall'] as const)( + 'keeps native commands available when Skill discovery can %s', + async (failure) => { + const { runtime, listSkills } = createRuntime(); + if (failure === 'reject') listSkills.mockRejectedValue(new Error('Unavailable')); + else listSkills.mockImplementation(() => new Promise(() => undefined)); + const updates: acp.SessionNotification[] = []; + const client = acp + .client({ name: 'zed' }) + .onNotification(acp.methods.client.session.update, ({ params }) => updates.push(params)); + await client.connectWith( + createTuiAcpAgent({ runtime, version: '1.2.3' }), + async (connection) => { + await connection.request(acp.methods.agent.initialize, { + protocolVersion: acp.PROTOCOL_VERSION, + clientCapabilities: {}, + }); + const { sessionId } = await connection.request(acp.methods.agent.session.new, { + cwd: '/workspace', + mcpServers: [], + }); + await vi.waitFor(() => + expect(updates).toContainEqual({ + sessionId, + update: { + sessionUpdate: 'available_commands_update', + availableCommands: TUI_ACP_AVAILABLE_COMMANDS, + }, + }), + ); + await expect( + connection.request(acp.methods.agent.session.prompt, { + sessionId, + prompt: [{ type: 'text', text: '/help' }], + }), + ).resolves.toEqual({ stopReason: 'end_turn' }); + }, + ); + }, + ); + + it.each(['close', 'resume'] as const)( + 'ignores late Skill discovery after session %s', + async (method) => { + const { runtime, listSkills } = createRuntime([], { + skills: { skills: [{ name: 'current' }] }, + }); + let resolveSkills!: (skills: TuiSkillList) => void; + listSkills.mockImplementationOnce( + () => + new Promise((resolve) => { + resolveSkills = resolve; + }), + ); + const updates: acp.SessionNotification[] = []; + const client = acp + .client({ name: 'zed' }) + .onNotification(acp.methods.client.session.update, ({ params }) => updates.push(params)); + await client.connectWith( + createTuiAcpAgent({ runtime, version: '1.2.3' }), + async (connection) => { + await connection.request(acp.methods.agent.initialize, { + protocolVersion: acp.PROTOCOL_VERSION, + clientCapabilities: {}, + }); + const { sessionId } = await connection.request(acp.methods.agent.session.new, { + cwd: '/workspace', + mcpServers: [], + }); + await vi.waitFor(() => expect(listSkills).toHaveBeenCalledOnce()); + await connection.request(acp.methods.agent.session[method], { + sessionId, + cwd: '/workspace', + mcpServers: [], + }); + if (method === 'resume') { + await vi.waitFor(() => + expect(updates).toContainEqual({ + sessionId, + update: { + sessionUpdate: 'available_commands_update', + availableCommands: [ + ...TUI_ACP_AVAILABLE_COMMANDS, + { + name: 'current', + description: '[Skill]', + input: { hint: '[instructions]' }, + }, + ], + }, + }), + ); + } + resolveSkills({ skills: [{ name: 'stale' }] }); + const count = updates.length; + await new Promise((resolve) => setTimeout(resolve, 130)); + const rosters = updates.flatMap(({ update }) => + update.sessionUpdate === 'available_commands_update' ? update.availableCommands : [], + ); + expect(rosters.some((command) => command.name === 'stale')).toBe(false); + if (method === 'close') expect(updates).toHaveLength(count); + }, + ); + }, + ); + it('advertises and executes native help and model commands without starting an Agent turn', async () => { const { runtime, sendMessage, listModels, selectModel } = createRuntime([], { models: [ @@ -555,7 +769,7 @@ describe('MiniMax Code ACP agent', () => { }); expect(getContextSnapshot).toHaveBeenCalledWith('session-1'); - expect(listSkills).toHaveBeenCalledWith(undefined, 'review'); + expect(listSkills).toHaveBeenCalledWith(undefined, 'review', '/workspace'); expect(listMcpServers).toHaveBeenCalledWith('git', 'session-1'); expect(getSessionUsage).toHaveBeenCalledWith('session-1'); expect(sendMessage).not.toHaveBeenCalled(); diff --git a/packages/tui/test/unit/runtime-product-access.test.ts b/packages/tui/test/unit/runtime-product-access.test.ts index 57f585507..9852673c4 100644 --- a/packages/tui/test/unit/runtime-product-access.test.ts +++ b/packages/tui/test/unit/runtime-product-access.test.ts @@ -571,6 +571,25 @@ describe("TuiRuntimeAdapter product access", () => { expect(cliService.selectModel).toHaveBeenCalledWith(selection); }); + it("discovers Skills in the requested Session workspace without changing the adapter default", async () => { + const cliService = cliServiceFixture(); + const adapter = new TuiRuntimeAdapter(cliService, { + workspaceDir: "/default-workspace", + }); + await adapter.listSkills("reviewer", undefined, "/session-workspace"); + await adapter.listSkills("mavis"); + expect(cliService.listRuntimeSkills).toHaveBeenNthCalledWith(1, { + agentName: "reviewer", + workspaceDir: "/session-workspace", + includePluginSkills: true, + }); + expect(cliService.listRuntimeSkills).toHaveBeenNthCalledWith(2, { + agentName: "mavis", + workspaceDir: "/default-workspace", + includePluginSkills: true, + }); + }); + it("registers installed Plugin Skills from the unified Runtime roster as slash commands", async () => { const cliService = cliServiceFixture(); vi.mocked(cliService.listRuntimeSkills).mockImplementationOnce( diff --git a/packages/tui/test/unit/tui-app.test.ts b/packages/tui/test/unit/tui-app.test.ts index 0ecd36d61..623c56789 100644 --- a/packages/tui/test/unit/tui-app.test.ts +++ b/packages/tui/test/unit/tui-app.test.ts @@ -7935,6 +7935,49 @@ describe("createTuiApp", () => { await app.stop(); }); + it.each(['/rewind', '/fork'])( + 'clears the loading hint after %s history loads and stays clear after cancellation', + async (command) => { + const terminal = new FakeTerminal(); + const runtime = createRuntime(); + let resolveHistory!: ( + value: Awaited>, + ) => void; + vi.mocked(runtime.listSessionInputSummaries).mockImplementation( + () => + new Promise((resolve) => { + resolveHistory = resolve; + }), + ); + const app = createTuiApp({ runtime, terminal, version: '0.1.0', workspaceDir: '/workspace' }); + app.start(); + try { + await app.ready; + await app.submit('Seed session'); + await app.submit(command); + const loadingChat = stripAnsi(app.tui.render(120).join('\n')); + expect(loadingChat).toMatch(/Loading|Finding/); + resolveHistory([{ userMessageId: 'history-user', timestamp: 1, fileChangeCount: 0 }]); + await vi.waitFor(() => + expect(app.surfaceHost.getActiveSurface()).toEqual({ + kind: 'feature', + id: 'session-mutation:history', + }), + ); + expect(stripAnsi(app.tui.render(120).join('\n'))).not.toMatch( + /Loading (?:rewind|fork) history|Finding messages/, + ); + terminal.input?.('\x1b'); + expect(app.surfaceHost.getActiveSurface()).toEqual({ kind: 'chat', id: 'chat' }); + expect(stripAnsi(app.tui.render(120).join('\n'))).not.toMatch( + /Loading (?:rewind|fork) history|Finding messages/, + ); + } finally { + await app.stop(); + } + }, + ); + it("cancels /rewind preview and ignores duplicate scope submission", async () => { const terminal = new FakeTerminal(); const runtime = createRuntime(); @@ -8020,6 +8063,9 @@ describe("createTuiApp", () => { id: "session-mutation:rewind-confirm", }); resolveRewind({ rewound: true }); + await vi.waitFor(() => expect(app.surfaceHost.getActiveSurface()).toEqual({ kind: 'chat', id: 'chat' })); + expect(stripAnsi(app.tui.render(120).join('\n'))).toContain('Rewound 1 turn'); + expect(stripAnsi(app.tui.render(120).join('\n'))).not.toMatch(/Loading rewind history|Finding messages/); await app.stop(); }); diff --git a/packages/tui/test/unit/tui-assistant-content.test.ts b/packages/tui/test/unit/tui-assistant-content.test.ts new file mode 100644 index 000000000..dba1e5c0e --- /dev/null +++ b/packages/tui/test/unit/tui-assistant-content.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, it } from 'vitest'; +import { parseDeliverAssetsContent } from '@mavis/shared/asset-markup'; +import { projectAssistantContentForTerminal } from '../../src/application/assistant-content.js'; + +describe('terminal attachment projection', () => { + it('renders five path-based files without seven per-tag placeholders', () => { + const names = ['first.py', 'second.py', 'test_first.py', 'report.md', 'notes.txt']; + const content = [ + 'Done.', + '', + ...names.map((name) => ``), + '', + ].join('\n'); + + expect(projectAssistantContentForTerminal(content)).toEqual({ + text: 'Done.', + assets: names.map((name) => ({ path: `/workspace/${name}`, name })), + }); + }); + + it.each([ + '', + '', + '', + '\n', + ])('removes unusable attachment markup while preserving prose: %s', (markup) => { + expect(projectAssistantContentForTerminal(`Before.\n\n${markup}\n\nAfter.`)).toEqual({ + text: 'Before.\n\nAfter.', + assets: [], + }); + }); + + it('preserves prose inside an invalid attachment wrapper', () => { + const content = + 'Keep this explanation.'; + expect(projectAssistantContentForTerminal(content)).toEqual({ + text: 'Keep this explanation.', + assets: [], + }); + }); + + it('keeps valid files and deduplicates aliases in mixed attachment markup', () => { + const content = [ + '', + '', + '', + '', + '', + '', + ].join('\n'); + expect(projectAssistantContentForTerminal(content)).toEqual({ + text: '', + assets: [{ path: '/workspace/report.txt', name: 'Report' }, { path: '/workspace/notes.txt' }], + }); + }); + + it('preserves literal attachment examples in inline and fenced code', () => { + const markup = ''; + const content = ['Example:', `\`${markup}\``, '```xml', markup, '```'].join('\n'); + expect(projectAssistantContentForTerminal(content)).toEqual({ text: content, assets: [] }); + }); + + it('projects a completed attachment after an incomplete streaming prefix', () => { + const prefix = 'Done.\n\n\n`)).toEqual({ + text: 'Done.', + assets: [{ path: '/workspace/report.txt' }], + }); + }); +}); + +describe('shared media source compatibility', () => { + it.each([ + ['src="/workspace/preferred.txt" path="/workspace/fallback.txt"', '/workspace/preferred.txt'], + ['src=" " path=" /workspace/fallback.txt "', '/workspace/fallback.txt'], + ['path="/workspace/a&b.txt"', '/workspace/a&b.txt'], + ])('uses src before path and normalizes the source: %s', (attributes, path) => { + expect(parseDeliverAssetsContent(``)).toEqual([ + { type: 'deliver-assets', items: [{ path, name: 'Report', type: 'file' }] }, + ]); + }); +}); diff --git a/packages/tui/test/unit/tui-background-work-panel.test.ts b/packages/tui/test/unit/tui-background-work-panel.test.ts index 7f7515cc0..2f2893557 100644 --- a/packages/tui/test/unit/tui-background-work-panel.test.ts +++ b/packages/tui/test/unit/tui-background-work-panel.test.ts @@ -4,6 +4,8 @@ import { describe, expect, it, vi } from "vitest"; import { TuiBackgroundWorkPanel } from "../../src/tui/background-work/panel.js"; import type { TuiBackgroundTask } from "../../src/runtime/port.js"; import type { TuiAgentTeamSnapshot } from "../../src/tui/agent-team/model.js"; +import { TuiMainScreen } from '../../src/tui/engine/tui-main-screen.js'; +import { TuiOverlayRegularFeaturePresenter } from '../../src/tui/shell/regular-feature-presenter.js'; import { TuiAltScreen } from "../../src/tui/engine/tui-alt-screen.js"; import { VirtualTerminal } from "../pi-084-upstream/virtual-terminal.js"; import { visibleWidth } from "../../src/tui/rendering/text.js"; @@ -332,3 +334,96 @@ describe("TuiBackgroundWorkPanel", () => { ); }); }); + +describe('Tasks in the regular terminal viewport', () => { + it.each([1, 8, 15])( + 'keeps tasks reachable after shrinking by %i rows without leaking duplicate tasks into scrollback', + async (shrinkRows) => { + const terminal = new VirtualTerminal(80, 16); + const tui = new TuiMainScreen(terminal); + const chatLines = Array.from({ length: 40 }, (_, index) => `chat-line-${index}`); + tui.addChild({ render: () => [...chatLines, 'COMPOSER', 'STATUS'], invalidate() {} }); + const tasks = Array.from({ length: 30 }, (_, index) => ({ + ...backgroundTasks()[0]!, + taskId: `task-${index}`, + description: `task-row-${String(index).padStart(2, '0')}`, + })); + const panel = new TuiBackgroundWorkPanel({ + agentTeam: () => ({ ...teamSnapshot(), members: [] }), + backgroundTasks: () => tasks, + activeSessionId: () => 'root', + onOpenAgent: vi.fn(), + onCancel: vi.fn(), + requestRender: () => tui.requestRender(), + }); + tui.start(); + try { + await terminal.waitForRender(); + chatLines.splice(-shrinkRows); + tui.renderNow(); + await terminal.flush(); + expect(terminal.getViewport()).toEqual( + [...chatLines, 'COMPOSER', 'STATUS'].slice(-terminal.rows), + ); + const presenter = new TuiOverlayRegularFeaturePresenter(terminal, tui, () => + tui.requestRender(), + ); + let handle = presenter.show(panel, panel); + tui.setFocus(handle.focus); + tui.renderNow(); + await terminal.flush(); + expect(terminal.getViewport().join('\n')).toContain('Tasks'); + expect(terminal.getViewport().join('\n')).toContain('task-row-00'); + handle.close(); + tui.renderNow(); + await terminal.flush(); + expect(terminal.getViewport()).toEqual( + [...chatLines, 'COMPOSER', 'STATUS'].slice(-terminal.rows), + ); + expect(terminal.getScrollBuffer()).toEqual([...chatLines, 'COMPOSER', 'STATUS']); + handle = presenter.show(panel, panel); + tui.setFocus(handle.focus); + tui.renderNow(); + await terminal.flush(); + const seen = new Set(); + for (let index = 0; index < 30; index++) { + chatLines.push(`live-chat-${index}`); + tui.renderNow(); + await terminal.flush(); + const viewport = terminal.getViewport().join('\n'); + for (const match of viewport.matchAll(/task-row-\d+/g)) seen.add(match[0]); + expect(viewport).toContain(`task-row-${String(index).padStart(2, '0')}`); + expect(terminal.getScrollBuffer().slice(0, -terminal.rows).join('\n')).not.toContain( + 'task-row-', + ); + terminal.sendInput('\u001B[B'); + } + expect(seen.size).toBe(30); + terminal.sendInput('\u001B[6~'); + tui.renderNow(); + await terminal.flush(); + terminal.sendInput('\u001B[5~'); + tui.renderNow(); + await terminal.flush(); + terminal.resize(60, 12); + await terminal.waitForRender(); + terminal.sendInput('\u001B[B'); + tui.renderNow(); + await terminal.flush(); + expect(terminal.getViewport().join('\n')).toContain('Tasks'); + handle.close(); + tui.renderNow(); + await terminal.flush(); + await vi.waitFor(async () => { + await terminal.flush(); + expect(terminal.getScrollBuffer().join('\n')).not.toContain('task-row-'); + }); + for (const line of chatLines) + expect(terminal.getScrollBuffer().filter((row) => row === line)).toHaveLength(1); + expect(terminal.getViewport().join('\n')).toContain('STATUS'); + } finally { + tui.stop(); + } + }, + ); +}); diff --git a/packages/tui/test/unit/tui-engine-local-deltas.test.ts b/packages/tui/test/unit/tui-engine-local-deltas.test.ts index 8922d7705..b19f6329c 100644 --- a/packages/tui/test/unit/tui-engine-local-deltas.test.ts +++ b/packages/tui/test/unit/tui-engine-local-deltas.test.ts @@ -56,7 +56,7 @@ describe('MCode Pi Engine local deltas', () => { } }); - it('preserves native scrollback when transient rows shrink above the viewport', async () => { + it('rebuilds when a transient collapse changes rows already in scrollback', async () => { const terminal = new RecordingVirtualTerminal(40, 5); const tui = new TuiMainScreen(terminal); const component = new MutableLines(); @@ -81,12 +81,164 @@ describe('MCode Pi Engine local deltas', () => { const writes = terminal.takeWrites(); expect(writes).toContain('\x1b[2J\x1b[H'); - expect(writes).not.toContain('\x1b[3J'); - expect(terminal.getScrollBuffer()).toContain('activity-1'); + expect(writes).toContain('\x1b[3J'); + expect(terminal.getScrollBuffer()).not.toContain('activity-1'); expect(terminal.getViewport()).toEqual(['header', 'status', 'composer', '', '']); + expect(terminal.getScrollBuffer().filter((line) => line === 'header')).toHaveLength(1); expect(tui.fullRedraws).toBe(2); }); + it('does not replay scrolled answer rows after a narrow terminal frame shrinks', async () => { + const terminal = new RecordingVirtualTerminal(67, 44); + const tui = new TuiMainScreen(terminal); + const component = new MutableLines(); + const answer = Array.from({ length: 80 }, (_, index) => `Answer line ${index}`); + component.lines = [...answer, 'activity', `composer${CURSOR_MARKER}`, 'status']; + tui.addChild(component); + tui.renderNow(); + await terminal.flush(); + + // Settle the activity row, then repaint a historical line (for example Markdown styling). + terminal.takeWrites(); + component.lines = [...answer, `composer${CURSOR_MARKER}`, 'status']; + component.lines[0] = '\x1b[1mAnswer line 0\x1b[0m'; + tui.renderNow(); + await terminal.flush(); + expect(terminal.takeWrites()).toContain('\x1b[3J'); + component.lines[0] = '\x1b[1mAnswer line 0\x1b[0m'; + tui.renderNow(); + await terminal.flush(); + + expect(terminal.takeWrites()).not.toContain('\x1b[3J'); + expect(terminal.getScrollBuffer()).toEqual([...answer, 'composer', 'status']); + expect(terminal.getViewport().at(-1)).toBe('status'); + expect(terminal.getCursorPosition()).toEqual({ x: 8, y: 42 }); + + // A later burst must continue from the same logical/physical boundary. + component.lines.splice(80, 0, ...Array.from({ length: 50 }, (_, index) => `More ${index}`)); + tui.renderNow(); + await terminal.flush(); + expect(terminal.getScrollBuffer()).toEqual([ + ...answer, + ...Array.from({ length: 50 }, (_, index) => `More ${index}`), + 'composer', + 'status', + ]); + }); + + it.each([1, 8, 30])( + 'keeps the composer and status at the bottom as %i running rows settle and new output grows', + async (activityRows) => { + const terminal = new RecordingVirtualTerminal(60, 44); + const tui = new TuiMainScreen(terminal); + const component = new MutableLines(); + const answer = Array.from({ length: 80 }, (_, index) => `Answer ${index}`); + component.lines = [ + ...answer, + ...Array.from({ length: activityRows }, (_, index) => `Activity ${index}`), + `composer${CURSOR_MARKER}`, + 'running', + ]; + tui.addChild(component); + tui.renderNow(); + await terminal.flush(); + terminal.takeWrites(); + + component.lines = [...answer, `composer${CURSOR_MARKER}`, 'idle']; + for (let added = 0; added <= activityRows + 1; added++) { + tui.renderNow(); + await terminal.flush(); + expect(terminal.getViewport()).toEqual( + component.lines.slice(-terminal.rows).map((line) => line.replace(CURSOR_MARKER, '')), + ); + expect(terminal.getCursorPosition()).toEqual({ x: 8, y: 42 }); + if (added === 0) expect(terminal.takeWrites()).toContain('\x1b[3J'); + else expect(terminal.takeWrites()).not.toContain('\x1b[3J'); + expect(terminal.getScrollBuffer()).toEqual( + component.lines.map((line) => line.replace(CURSOR_MARKER, '')), + ); + if (added <= activityRows) component.lines.splice(-2, 0, `New answer ${added}`); + } + expect(terminal.getScrollBuffer()).not.toContain(''); + }, + ); + + it('keeps every appended row when a historical row changes in the same frame', async () => { + const terminal = new RecordingVirtualTerminal(67, 44); + const tui = new TuiMainScreen(terminal); + const component = new MutableLines(); + const answer = Array.from({ length: 80 }, (_, index) => `Answer line ${index}`); + component.lines = [...answer, 'composer']; + tui.addChild(component); + tui.renderNow(); + await terminal.flush(); + terminal.takeWrites(); + + const more = Array.from({ length: 50 }, (_, index) => `More ${index}`); + component.lines = [...answer, ...more, 'composer']; + component.lines[0] = '\x1b[1mAnswer line 0\x1b[0m'; + tui.renderNow(); + await terminal.flush(); + + expect(terminal.takeWrites()).not.toContain('\x1b[3J'); + expect(terminal.getScrollBuffer()).toEqual([...answer, ...more, 'composer']); + }); + + it('rebuilds the document when shrinking leaves no rows in the previous viewport', async () => { + const terminal = new RecordingVirtualTerminal(67, 44); + const tui = new TuiMainScreen(terminal); + const component = new MutableLines(); + component.lines = Array.from({ length: 100 }, (_, index) => `Old line ${index}`); + tui.addChild(component); + tui.renderNow(); + await terminal.flush(); + + component.lines = ['answer', `composer${CURSOR_MARKER}`]; + tui.renderNow(); + await terminal.flush(); + + expect(terminal.getScrollBuffer().filter(Boolean)).toEqual(['answer', 'composer']); + expect(terminal.getCursorPosition()).toEqual({ x: 8, y: 1 }); + }); + + it('still previews the resized tail and restores ordered scrollback after resize settles', async () => { + const terminal = new RecordingVirtualTerminal(67, 44); + const tui = new TuiMainScreen(terminal); + const component = new MutableLines(); + component.lines = [ + ...Array.from({ length: 80 }, (_, index) => `Answer line ${index}`), + `composer${CURSOR_MARKER}`, + ]; + tui.addChild(component); + try { + tui.start(); + tui.renderNow(); + await terminal.flush(); + terminal.takeWrites(); + + terminal.resize(60, 30); + tui.renderNow(); + await terminal.flush(); + expect(terminal.takeWrites()).not.toContain('\x1b[3J'); + expect(terminal.getViewport()).toEqual([ + ...Array.from({ length: 29 }, (_, index) => `Answer line ${index + 51}`), + 'composer', + ]); + + await new Promise((resolve) => setTimeout(resolve, 200)); + tui.renderNow(); + await terminal.flush(); + expect(terminal.takeWrites()).toContain('\x1b[3J'); + expect(terminal.getScrollBuffer()).toEqual([ + ...Array.from({ length: 80 }, (_, index) => `Answer line ${index}`), + 'composer', + ]); + expect(terminal.getCursorPosition()).toEqual({ x: 8, y: 29 }); + } finally { + tui.stop(); + } + }); + it('renders an urgent product interaction without resetting Main diff state', async () => { const terminal = new RecordingVirtualTerminal(40, 5); const tui = new TuiMainScreen(terminal); diff --git a/packages/tui/test/unit/tui-feature-viewport-contract.test.ts b/packages/tui/test/unit/tui-feature-viewport-contract.test.ts new file mode 100644 index 000000000..032b7b4d0 --- /dev/null +++ b/packages/tui/test/unit/tui-feature-viewport-contract.test.ts @@ -0,0 +1,289 @@ +import { describe, expect, it, vi } from 'vitest'; +import type { TuiSessionInputSummary } from '../../src/runtime/port.js'; +import { TuiAgentTeamPanel } from '../../src/tui/agent-team/panel.js'; +import type { TuiAgentTeamSnapshot } from '../../src/tui/agent-team/model.js'; +import { TuiBackgroundWorkPanel } from '../../src/tui/background-work/panel.js'; +import { TuiSessionMutationForkConfirmation } from '../../src/tui/controller/product/session-mutation-flow.js'; +import { TuiAltScreen } from '../../src/tui/engine/tui-alt-screen.js'; +import { TuiMainScreen } from '../../src/tui/engine/tui-main-screen.js'; +import { TuiPluginManager } from '../../src/tui/features/plugin/manager.js'; +import { TuiSessionHistoryExplorer } from '../../src/tui/features/session-mutation/history-explorer.js'; +import { TuiSessionMutationHistoryPicker } from '../../src/tui/features/session-mutation/history-picker.js'; +import { TuiSessionMutationRewindPreviewPanel } from '../../src/tui/features/session-mutation/rewind-preview-panel.js'; +import { TuiSessionMutationScopePicker } from '../../src/tui/features/session-mutation/scope-picker.js'; +import { TuiTranscriptPanel } from '../../src/tui/features/transcript/panel.js'; +import { TuiOverlayRegularFeaturePresenter } from '../../src/tui/shell/regular-feature-presenter.js'; +import { TuiSurfaceHost, type TuiFeatureScreen } from '../../src/tui/shell/surface-host.js'; +import { createTranscriptCell } from '../../src/tui/transcript/model.js'; +import { TranscriptStore } from '../../src/tui/transcript/store.js'; +import { VirtualTerminal } from '../pi-084-upstream/virtual-terminal.js'; + +type Factory = (count: number, onCancel: () => void, requestRender: () => void) => TuiFeatureScreen; +const summaries = (count: number): TuiSessionInputSummary[] => + Array.from({ length: count }, (_, index) => ({ + userMessageId: `input-${index}`, + contentHead: `Prompt ${index}`, + timestamp: index + 1, + fileChangeCount: 0, + })); +const team = (count: number): TuiAgentTeamSnapshot => ({ + rootSessionId: 'root', + capturedAtMs: 1, + summary: { total: count, running: count, waiting: 0, queued: 0, done: 0, failed: 0, stopped: 0 }, + members: Array.from({ length: count }, (_, index) => ({ + sessionId: `agent-${index}`, + parentSessionId: 'root', + agentName: `Agent ${index}`, + task: 'Inspect rendering', + status: 'running', + phase: 'tool', + activity: 'Reading', + toolCount: 1, + startedAtMs: 1, + updatedAtMs: 1, + })), +}); +const factories: Record = { + tasks: (count, onCancel, requestRender) => + new TuiBackgroundWorkPanel({ + agentTeam: () => team(count), + backgroundTasks: () => [], + activeSessionId: () => 'root', + onOpenAgent: vi.fn(), + onCancel, + requestRender, + }), + agents: (count, onCancel, requestRender) => + new TuiAgentTeamPanel({ + snapshot: () => team(count), + activeSessionId: () => 'root', + onSelect: vi.fn(), + onCancel, + requestRender, + }), + plugins: (count, onCancel, requestRender) => { + const plugins = Array.from({ length: count }, (_, index) => ({ + pluginId: `plugin-${index}`, + name: `Plugin ${index}`, + displayName: `Plugin ${index}`, + marketplace: 'official' as const, + installed: false, + enabled: false, + capabilities: { appCount: 0, mcpServerCount: 0, skillCount: 1 }, + })); + return new TuiPluginManager({ + plugins, + onInstall: async (plugin) => plugin, + onRemove: async (plugin) => plugin, + onSetEnabled: async (plugin) => plugin, + onRefresh: async () => plugins, + onCancel, + requestRender, + }); + }, + transcript: (count, onCancel, requestRender) => + new TuiTranscriptPanel({ + source: new TranscriptStore( + Array.from({ length: count }, (_, index) => + createTranscriptCell({ + id: `cell-${index}`, + turnId: `turn-${index}`, + kind: 'user', + status: 'succeeded', + content: `Inspect message ${index}`, + createdAtMs: index + 1, + }), + ), + ), + onCancel, + requestRender, + }), + history: (count, onCancel, requestRender) => + new TuiSessionHistoryExplorer({ + summaries: summaries(count), + mutationAvailable: true, + onAction: vi.fn(), + onCancel, + requestRender, + }), + 'history-picker': (count, onCancel, requestRender) => + new TuiSessionMutationHistoryPicker({ + summaries: summaries(count), + mode: 'rewind', + onSelect: vi.fn(), + onCancel, + requestRender, + }), + 'rewind-preview': (count, onCancel, requestRender) => + new TuiSessionMutationRewindPreviewPanel({ + preview: { + turns: [ + { + turnId: 'turn', + files: Array.from({ length: count }, (_, index) => ({ + filePath: `src/file-${index}.ts`, + action: 'modified' as const, + skipped: false, + })), + }, + ], + }, + onContinue: vi.fn(), + onCancel, + requestRender, + }), + 'rewind-scope': (_count, onCancel, requestRender) => + new TuiSessionMutationScopePicker({ + onSelect: vi.fn(), + onCancel, + requestRender, + }), + 'fork-confirmation': (_count, onCancel, requestRender) => + new TuiSessionMutationForkConfirmation({ + summary: summaries(1)[0]!, + options: { canFork: true, worktreeEligible: false, worktreeVisible: false }, + onConfirm: vi.fn(), + onCancel, + requestRender, + }), +}; + +describe.each(Object.entries(factories))('%s regular viewport lifecycle', (_name, factory) => { + it.each([ + ...[0, 1, 30].flatMap((count) => [8, 24, 50].map((rows) => ({ count, rows, chatCount: 80 }))), + ...[0, 4].map((chatCount) => ({ count: 1, rows: 24, chatCount })), + ])( + 'isolates $count entries over $chatCount chat rows at $rows rows through updates, resize, nesting and return', + async ({ count, rows, chatCount }) => { + const terminal = new VirtualTerminal(80, rows); + const tui = new TuiMainScreen(terminal); + const messages = Array.from({ length: chatCount }, (_, index) => `BACKGROUND-${index}`); + const chat = () => [...messages, 'COMPOSER', 'STATUS']; + const chatComponent = { render: chat, invalidate() {} }; + const host = new TuiSurfaceHost({ + chat: { component: chatComponent, focus: chatComponent }, + chatMode: 'regular', + viewportRows: () => terminal.rows, + regularFeaturePresenter: new TuiOverlayRegularFeaturePresenter(terminal, tui, () => + tui.requestRender(), + ), + setFocus: (focus) => tui.setFocus(focus), + requestRender: () => tui.requestRender(), + }); + tui.addChild(host); + const assertCovered = async () => { + tui.renderNow(); + await terminal.flush(); + const viewport = terminal.getViewport().join('\n'); + expect(viewport.trim()).not.toBe(''); + expect(viewport).not.toMatch(/BACKGROUND-|COMPOSER|STATUS/); + }; + tui.start(); + try { + await terminal.waitForRender(); + const handle = host.pushFeature({ + screen: factory( + count, + () => handle.close(), + () => tui.requestRender(), + ), + }); + await assertCovered(); + messages.push('BACKGROUND-live'); + await assertCovered(); + messages.splice(-12); + await assertCovered(); + for (const key of ['\x1b[B', '\x1b[6~', '\x1b[5~']) { + terminal.sendInput(key); + await assertCovered(); + } + terminal.resize(40, rows === 8 ? 24 : 8); + await assertCovered(); + terminal.resize(80, rows); + await assertCovered(); + const nestedFactory = + _name === 'rewind-preview' ? factories['rewind-scope']! : factories['rewind-preview']!; + const nested = host.pushFeature({ + screen: nestedFactory( + 0, + () => nested.close(), + () => tui.requestRender(), + ), + }); + await assertCovered(); + terminal.sendInput('\x1b'); + await assertCovered(); + expect(handle.isActive()).toBe(true); + // Some panels first clear a search/filter on Esc; close their surface handle here. + handle.close(); + tui.renderNow(); + await vi.waitFor(async () => { + await terminal.flush(); + const expected = [...chat(), ...Array(Math.max(0, rows - chat().length)).fill('')]; + expect(terminal.getViewport()).toEqual(expected.slice(-rows)); + expect(terminal.getScrollBuffer()).toEqual(expected); + }); + } finally { + host.dispose(); + tui.stop(); + } + }, + ); +}); + +describe.each(Object.entries(factories))('%s fullscreen viewport lifecycle', (_name, factory) => { + it.each([ + { rows: 8, count: 0 }, + { rows: 24, count: 1 }, + { rows: 50, count: 30 }, + ])( + 'isolates $count entries at $rows rows and restores the chat layout', + async ({ rows, count }) => { + const terminal = new VirtualTerminal(80, rows); + const tui = new TuiAltScreen(terminal); + const component = { + render: () => ['BACKGROUND-chat', 'COMPOSER', 'STATUS'], + invalidate() {}, + }; + const host = new TuiSurfaceHost({ + chat: { component, focus: component }, + chatMode: 'fullscreen', + viewportRows: () => terminal.rows, + regularFeaturePresenter: new TuiOverlayRegularFeaturePresenter(terminal, tui, () => + tui.requestRender(), + ), + setFocus: (focus) => tui.setFocus(focus), + requestRender: () => tui.requestRender(), + }); + tui.setLayoutRoot(host); + tui.start(); + try { + await terminal.waitForRender(); + const original = terminal.getViewport(); + const handle = host.pushFeature({ + screen: factory( + count, + () => handle.close(), + () => tui.requestRender(), + ), + }); + for (const width of [80, 24, 80]) { + terminal.resize(width, rows); + terminal.sendInput('\x1b[B'); + terminal.sendInput('\x1b[6~'); + tui.renderNow(); + await terminal.flush(); + expect(terminal.getViewport().join('\n').trim()).not.toBe(''); + expect(terminal.getViewport().join('\n')).not.toMatch(/BACKGROUND-|COMPOSER|STATUS/); + } + handle.close(); + tui.renderNow(); + await terminal.flush(); + expect(terminal.getViewport()).toEqual(original); + } finally { + host.dispose(); + tui.stop(); + } + }, + ); +}); diff --git a/packages/tui/test/unit/tui-session-history-flow.test.ts b/packages/tui/test/unit/tui-session-history-flow.test.ts new file mode 100644 index 000000000..71e391ecb --- /dev/null +++ b/packages/tui/test/unit/tui-session-history-flow.test.ts @@ -0,0 +1,274 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { TuiMainScreen } from '../../src/tui/engine/tui-main-screen.js'; +import { TuiOverlayRegularFeaturePresenter } from '../../src/tui/shell/regular-feature-presenter.js'; +import { TuiSurfaceHost, type TuiFeatureScreen } from '../../src/tui/shell/surface-host.js'; +import { VirtualTerminal } from '../pi-084-upstream/virtual-terminal.js'; + +import type { TuiSessionInputSummary } from '../../src/runtime/port.js'; +import { TuiSessionMutationFlow } from '../../src/tui/controller/product/session-mutation-flow.js'; +import { stripAnsi } from '../../src/tui/rendering/text.js'; + +const SESSION_ID = 'session-history'; +const SUMMARY: TuiSessionInputSummary = { + userMessageId: 'user-history-1', + assistantMessageId: 'assistant-history-1', + contentHead: 'Change the historical input', + timestamp: 1_700_000_000_000, + fileChangeCount: 1, +}; + +function createHarness(realHost?: TuiSurfaceHost) { + const listSessionInputSummaries = vi.fn(async () => [SUMMARY]); + const getSessionRewindPreview = vi.fn(async () => ({ + turns: [ + { + turnId: 'turn-history-1', + files: [{ filePath: 'src/history.ts', action: 'modified' as const, skipped: false }], + }, + ], + })); + const rewindSession = vi.fn(async () => ({ rewound: true })); + const listMessagePage = vi.fn(async () => ({ + messages: [ + { + id: SUMMARY.userMessageId, + role: 'user' as const, + content: 'Full historical input', + timestamp: SUMMARY.timestamp, + }, + ], + hasMore: false, + })); + const editSessionMessage = vi.fn(); + const pushedScreens: Array = []; + const closeHandles: Array> = []; + const surfaceHost = { + pushFeature: vi.fn( + ({ + screen, + }: { + screen: TuiFeatureScreen & { handleInput(data: string): void }; + }) => { + pushedScreens.push(screen); + const realHandle = realHost?.pushFeature({ screen }); + const close = vi.fn(() => realHandle?.close() ?? true); + closeHandles.push(close); + return { id: screen.id, close, isActive: () => true }; + }, + ), + setChatFocus: vi.fn(), + }; + const editor = { setText: vi.fn() }; + const refreshProjection = vi.fn(async () => undefined); + const reloadSessionProjection = vi.fn(async () => undefined); + const setEditTranscriptBoundary = vi.fn(); + const flow = new TuiSessionMutationFlow({ + runtime: { + listSessionInputSummaries, + getSessionRewindPreview, + rewindSession, + listMessagePage, + editSessionMessage, + } as never, + controller: { + snapshot: () => ({ + session: { sessionId: SESSION_ID, title: 'History test' }, + sessions: [], + }), + } as never, + sessionFlow: { activateSessionById: vi.fn() } as never, + refreshProjection, + reloadSessionProjection, + surfaceHost: surfaceHost as never, + editor: editor as never, + setHint: vi.fn(), + append: vi.fn(), + setEditTranscriptBoundary, + onChanged: vi.fn(), + hasLiveRun: () => false, + }); + return { + flow, + listSessionInputSummaries, + getSessionRewindPreview, + rewindSession, + listMessagePage, + editSessionMessage, + pushedScreens, + closeHandles, + surfaceHost, + editor, + refreshProjection, + reloadSessionProjection, + setEditTranscriptBoundary, + }; +} + +describe('TuiSessionMutationFlow history explorer', () => { + it.each([8, 24, 50])( + 'restores chat after cancelling the real rewind confirmation at %i rows', + async (rows) => { + const terminal = new VirtualTerminal(80, rows); + const tui = new TuiMainScreen(terminal); + const chat = [ + ...Array.from({ length: 80 }, (_, index) => `BACKGROUND-${index}`), + 'COMPOSER', + 'STATUS', + ]; + const component = { render: () => chat, invalidate() {} }; + const host = new TuiSurfaceHost({ + chat: { component, focus: component }, + chatMode: 'regular', + viewportRows: () => terminal.rows, + regularFeaturePresenter: new TuiOverlayRegularFeaturePresenter(terminal, tui, () => + tui.requestRender(), + ), + setFocus: (focus) => tui.setFocus(focus), + requestRender: () => tui.requestRender(), + }); + const harness = createHarness(host); + tui.addChild(host); + tui.start(); + try { + await terminal.waitForRender(); + harness.flow.startHistory(); + await vi.waitFor(() => expect(harness.pushedScreens).toHaveLength(1)); + // Choose conversation-only rewind through the actual history action menu. + for (const key of ['\r', '\x1b[B', '\x1b[B', '\r']) terminal.sendInput(key); + await vi.waitFor(() => expect(harness.pushedScreens).toHaveLength(2)); + tui.renderNow(); + await terminal.flush(); + expect(harness.pushedScreens[1]?.id).toBe('session-mutation:rewind-confirm'); + expect(terminal.getViewport().join('\n')).not.toMatch(/BACKGROUND-|COMPOSER|STATUS/); + terminal.sendInput('\x1b'); + tui.renderNow(); + await terminal.flush(); + expect(host.getActiveSurface()).toEqual({ + kind: 'feature', + id: 'session-history:explorer', + }); + terminal.sendInput('\x1b'); + tui.renderNow(); + await terminal.flush(); + expect(terminal.getViewport()).toEqual(chat.slice(-rows)); + expect(terminal.getScrollBuffer()).toEqual(chat); + expect(harness.rewindSession).not.toHaveBeenCalled(); + } finally { + host.dispose(); + tui.stop(); + } + }, + ); + + it('confirms a conversation-only rewind without an irrelevant file preview', async () => { + const harness = createHarness(); + + harness.flow.startHistory(); + await vi.waitFor(() => expect(harness.pushedScreens).toHaveLength(1)); + expect(harness.pushedScreens[0]?.id).toBe('session-history:explorer'); + + harness.pushedScreens[0]?.handleInput('\r'); + harness.pushedScreens[0]?.handleInput('\u001b[B'); + harness.pushedScreens[0]?.handleInput('\u001b[B'); + harness.pushedScreens[0]?.handleInput('\r'); + + await vi.waitFor(() => expect(harness.pushedScreens).toHaveLength(2)); + expect(harness.pushedScreens[1]?.id).toBe('session-mutation:rewind-confirm'); + expect(harness.getSessionRewindPreview).not.toHaveBeenCalled(); + expect(harness.rewindSession).not.toHaveBeenCalled(); + + harness.pushedScreens[1]?.handleInput('\r'); + await vi.waitFor(() => + expect(harness.rewindSession).toHaveBeenCalledWith({ + sessionId: SESSION_ID, + userMessageId: SUMMARY.userMessageId, + clientRequestId: expect.stringMatching(/^tui-rewind_/u), + }), + ); + await vi.waitFor(() => + expect(harness.reloadSessionProjection).toHaveBeenCalledWith(SESSION_ID), + ); + expect(harness.refreshProjection).not.toHaveBeenCalled(); + expect(harness.flow.getInvocationCountForTesting()).toBe(0); + }); + + it('restores the history explorer after cancelling a conversation-only rewind', async () => { + const harness = createHarness(); + + harness.flow.startHistory(); + await vi.waitFor(() => expect(harness.pushedScreens).toHaveLength(1)); + const explorer = harness.pushedScreens[0]; + explorer?.handleInput('\r'); + explorer?.handleInput('\u001b[B'); + explorer?.handleInput('\u001b[B'); + explorer?.handleInput('\r'); + + await vi.waitFor(() => expect(harness.pushedScreens).toHaveLength(2)); + harness.pushedScreens[1]?.handleInput('\u001b'); + + expect(harness.flow.getInvocationCountForTesting()).toBe(0); + expect(stripAnsi(explorer?.render(100).join('\n') ?? '')).toContain('Search:'); + explorer?.handleInput('\r'); + expect(stripAnsi(explorer?.render(100).join('\n') ?? '')).toContain('Choose history action'); + }); + + it('previews a historical edit before loading it into the composer', async () => { + const harness = createHarness(); + + harness.flow.startHistory(); + await vi.waitFor(() => expect(harness.pushedScreens).toHaveLength(1)); + harness.pushedScreens[0]?.handleInput('\r'); + harness.pushedScreens[0]?.handleInput('\u001b[B'); + harness.pushedScreens[0]?.handleInput('\r'); + + await vi.waitFor(() => expect(harness.pushedScreens).toHaveLength(2)); + expect(harness.pushedScreens[1]?.id).toBe('session-mutation:rewind-preview'); + expect(harness.listMessagePage).not.toHaveBeenCalled(); + expect(harness.editor.setText).not.toHaveBeenCalled(); + + harness.pushedScreens[1]?.handleInput('\r'); + await vi.waitFor(() => + expect(harness.listMessagePage).toHaveBeenCalledWith(SESSION_ID, { + limit: 100, + }), + ); + await vi.waitFor(() => + expect(harness.editor.setText).toHaveBeenCalledWith('Full historical input'), + ); + expect(harness.setEditTranscriptBoundary).toHaveBeenCalledWith(SUMMARY.userMessageId); + expect(harness.surfaceHost.setChatFocus).toHaveBeenCalled(); + expect(harness.editSessionMessage).not.toHaveBeenCalled(); + }); + + it('uses the file preview as the final confirmation for a file rewind', async () => { + const harness = createHarness(); + + harness.flow.startHistory(); + await vi.waitFor(() => expect(harness.pushedScreens).toHaveLength(1)); + harness.pushedScreens[0]?.handleInput('\r'); + harness.pushedScreens[0]?.handleInput('\u001b[B'); + harness.pushedScreens[0]?.handleInput('\u001b[B'); + harness.pushedScreens[0]?.handleInput('\u001b[B'); + harness.pushedScreens[0]?.handleInput('\r'); + + await vi.waitFor(() => expect(harness.pushedScreens).toHaveLength(2)); + expect(harness.pushedScreens[1]?.id).toBe('session-mutation:rewind-preview'); + expect(harness.rewindSession).not.toHaveBeenCalled(); + + harness.pushedScreens[1]?.handleInput('\r'); + await vi.waitFor(() => + expect(harness.rewindSession).toHaveBeenCalledWith({ + sessionId: SESSION_ID, + userMessageId: SUMMARY.userMessageId, + clientRequestId: expect.stringMatching(/^tui-rewind_/u), + rewindTurnDiff: true, + }), + ); + expect(harness.pushedScreens).toHaveLength(2); + }); +}); diff --git a/packages/tui/test/unit/tui-session-mutation-panels.test.ts b/packages/tui/test/unit/tui-session-mutation-panels.test.ts new file mode 100644 index 000000000..89ca46fd3 --- /dev/null +++ b/packages/tui/test/unit/tui-session-mutation-panels.test.ts @@ -0,0 +1,458 @@ +import { describe, expect, it, vi } from 'vitest'; +import { TuiMainScreen } from '../../src/tui/engine/tui-main-screen.js'; +import { TuiOverlayRegularFeaturePresenter } from '../../src/tui/shell/regular-feature-presenter.js'; +import { VirtualTerminal } from '../pi-084-upstream/virtual-terminal.js'; + +import { TuiSessionMutationHistoryPicker } from '../../src/tui/features/session-mutation/history-picker.js'; +import { TuiSessionMutationRewindPreviewPanel } from '../../src/tui/features/session-mutation/rewind-preview-panel.js'; +import { TuiSessionMutationScopePicker } from '../../src/tui/features/session-mutation/scope-picker.js'; +import { + formatPromptHead, + formatRelativeTimestamp, + formatSessionInputSummaryLabel, +} from '../../src/tui/features/session-mutation/format.js'; +import { + sessionHistoryText, + sessionMutationTemplate, + sessionMutationText, +} from '../../src/tui/features/session-mutation/copy.js'; +import { stripAnsi } from '../../src/tui/rendering/text.js'; +import type { + TuiRewindPreview, + TuiSessionInputSummary, + TuiRewindScope, +} from '../../src/runtime/port.js'; + +const NOW_MS = 1_700_000_000_000; + +function makeSummary(overrides: Partial = {}): TuiSessionInputSummary { + return { + userMessageId: 'msg-user-1', + assistantMessageId: 'msg-assistant-1', + contentHead: 'Investigate flaky login tests and capture the failure trace', + timestamp: NOW_MS - 5 * 60_000, + fileChangeCount: 2, + ...overrides, + }; +} + +describe('formatSessionInputSummaryLabel', () => { + it('formats a concise label with assistant-boundary, files, and relative time', () => { + const label = formatSessionInputSummaryLabel( + { + userMessageId: 'msg-user-1', + assistantMessageId: 'msg-assistant-1', + contentHead: 'Investigate flaky login tests and capture the failure trace', + timestamp: NOW_MS - 5 * 60_000, + fileChangeCount: 2, + }, + { nowMs: NOW_MS }, + ); + + expect(label.title).toContain('Investigate flaky login tests'); + expect(label.title).toMatch(/\u2026$/u); + expect(label.subtitle).toMatch(/2 files/u); + expect(label.subtitle).toMatch(/5m/u); + expect(label.subtitle).toMatch(/assistant replied/u); + }); + + it('omits the assistant marker when no assistant message exists', () => { + const label = formatSessionInputSummaryLabel( + makeSummary({ assistantMessageId: undefined, fileChangeCount: 0 }), + { nowMs: NOW_MS }, + ); + expect(label.subtitle).not.toMatch(/assistant/u); + expect(label.subtitle).toMatch(/0 files/u); + }); + + it('uses Chinese Session history copy and formatting for a Chinese locale', () => { + const label = formatSessionInputSummaryLabel(makeSummary(), { + nowMs: NOW_MS, + locale: 'zh-CN', + }); + + expect(label.subtitle).toContain('2 个文件'); + expect(label.subtitle).toContain('助手已回复'); + expect(formatRelativeTimestamp(0, NOW_MS, 'zh-CN')).toBe('未知时间'); + expect(sessionHistoryText('actions', 'zh_CN')).toBe('选择历史操作'); + expect(sessionMutationText('sessionMutation.confirm.rewind.title', 'zh-Hans')).toBe( + '确认回退', + ); + expect( + sessionMutationTemplate( + 'sessionMutation.confirm.fileCounts', + { ready: 2, skipped: 1 }, + 'zh-CN', + ), + ).toBe('2 可回退 · 1 已跳过'); + }); +}); + +describe('TuiSessionMutationHistoryPicker', () => { + it('renders the picker header and lists every summary newest-first with full id retained', () => { + const older = makeSummary({ + userMessageId: 'msg-user-older', + contentHead: 'Older prompt body', + timestamp: NOW_MS - 30 * 60_000, + fileChangeCount: 1, + }); + const middle = makeSummary({ + userMessageId: 'msg-user-middle', + contentHead: 'Middle prompt body', + timestamp: NOW_MS - 10 * 60_000, + fileChangeCount: 0, + }); + const latest = makeSummary({ + userMessageId: 'msg-user-latest', + contentHead: 'Latest prompt body', + timestamp: NOW_MS - 1 * 60_000, + fileChangeCount: 3, + }); + const onSelect = vi.fn(); + const picker = new TuiSessionMutationHistoryPicker({ + summaries: [older, middle, latest], + mode: 'rewind', + onSelect, + onCancel: vi.fn(), + nowMs: NOW_MS, + }); + + const rendered = stripAnsi(picker.render(80).join('\n')); + + expect(rendered).toMatch(/Rewind to a previous message/); + expect(rendered).toContain('Latest prompt body'); + expect(rendered).toContain('Middle prompt body'); + expect(rendered).toContain('Older prompt body'); + expect(rendered).toMatch(/1m/); + expect(rendered).toMatch(/3 files/); + expect(rendered.indexOf('Latest prompt body')).toBeLessThan( + rendered.indexOf('Middle prompt body'), + ); + expect(rendered.indexOf('Middle prompt body')).toBeLessThan( + rendered.indexOf('Older prompt body'), + ); + expect(rendered).toMatch(/1 turn affected/); + expect(rendered).toMatch(/2 turns affected/); + expect(rendered).toMatch(/3 turns affected/); + + // Selection defaults to the latest summary. + picker.handleInput('\r'); + expect(onSelect).toHaveBeenCalledTimes(1); + expect(onSelect).toHaveBeenCalledWith(latest); + }); + + it('does not offer incomplete inputs without an assistant boundary', () => { + const complete = makeSummary({ userMessageId: 'msg-user-complete' }); + const incomplete = makeSummary({ + userMessageId: 'msg-user-incomplete', + assistantMessageId: undefined, + contentHead: 'Still running', + }); + const onSelect = vi.fn(); + const picker = new TuiSessionMutationHistoryPicker({ + summaries: [complete, incomplete], + mode: 'fork', + onSelect, + onCancel: vi.fn(), + nowMs: NOW_MS, + }); + + const rendered = stripAnsi(picker.render(100).join('\n')); + expect(rendered).toContain('Investigate flaky login tests'); + expect(rendered).not.toContain('Still running'); + expect(rendered).toMatch(/1 incomplete message hidden/); + + picker.handleInput('\r'); + expect(onSelect).toHaveBeenCalledWith(complete); + }); + + it('truncates the displayed prompt head but keeps the full summary object on select', () => { + const longHead = 'word '.repeat(80).trim(); + const summary = makeSummary({ + userMessageId: 'msg-user-long', + contentHead: longHead, + fileChangeCount: 0, + }); + const onSelect = vi.fn(); + const picker = new TuiSessionMutationHistoryPicker({ + summaries: [summary], + mode: 'fork', + onSelect, + onCancel: vi.fn(), + }); + + const rendered = stripAnsi(picker.render(60).join('\n')); + expect(rendered).toContain('Fork from a previous message'); + // Rendered line should not contain the full long head; it should be truncated with ellipsis. + expect(rendered).not.toContain(longHead); + expect(rendered).toMatch(/\u2026/); + + picker.handleInput('\r'); + expect(onSelect).toHaveBeenCalledWith(summary); + }); + + it('does not split an emoji at the prompt-head boundary', () => { + const head = `${'a'.repeat(46)}😀z`; + + expect(formatPromptHead(head, 48)).toBe(head); + expect(formatPromptHead(`${head}tail`, 48)).not.toContain('\uFFFD'); + }); + + it('renders a safe empty state when there are no summaries', () => { + const onSelect = vi.fn(); + const picker = new TuiSessionMutationHistoryPicker({ + summaries: [], + mode: 'rewind', + onSelect, + onCancel: vi.fn(), + }); + + const rendered = stripAnsi(picker.render(80).join('\n')); + expect(rendered).toMatch(/No previous user messages/i); + expect(rendered).toMatch(/Esc cancel/); + // No summaries means there is nothing to select, so onSelect must not fire. + picker.handleInput('\r'); + picker.handleInput('\u001b'); + expect(onSelect).not.toHaveBeenCalled(); + }); + + it('routes Escape to onCancel exactly once and never invokes the mutation callback', () => { + const summary = makeSummary(); + const onSelect = vi.fn(); + const onCancel = vi.fn(); + const picker = new TuiSessionMutationHistoryPicker({ + summaries: [summary], + mode: 'rewind', + onSelect, + onCancel, + }); + + picker.handleInput('\u001b'); + picker.handleInput('\u001b'); + + expect(onCancel).toHaveBeenCalledTimes(1); + expect(onSelect).not.toHaveBeenCalled(); + }); + + it('exposes a stable surface id and pads renderViewport to the requested height', () => { + const summary = makeSummary(); + const picker = new TuiSessionMutationHistoryPicker({ + summaries: [summary], + mode: 'fork', + onSelect: vi.fn(), + onCancel: vi.fn(), + }); + + expect(picker.id).toBe('session-mutation:history'); + + const padded = picker.renderViewport(80, 24); + expect(padded.length).toBe(24); + expect(padded.some((line) => line.length > 0)).toBe(true); + + const clipped = picker.renderViewport(80, 4); + expect(clipped.length).toBe(4); + expect(clipped.length).toBeLessThanOrEqual(picker.render(80).length); + }); + + it('returns an empty viewport for zero width without crashing', () => { + const picker = new TuiSessionMutationHistoryPicker({ + summaries: [makeSummary()], + mode: 'fork', + onSelect: vi.fn(), + onCancel: vi.fn(), + }); + + expect(picker.renderViewport(0, 10)).toEqual([]); + }); +}); + +describe('TuiSessionMutationRewindPreviewPanel', () => { + it.each([8, 24, 50])( + 'owns the complete regular viewport at %i rows and restores chat on cancel', + async (rows) => { + const terminal = new VirtualTerminal(80, rows); + const tui = new TuiMainScreen(terminal); + const messages = Array.from({ length: 80 }, (_, index) => `chat-marker-${index}`); + const chat = () => [...messages, 'COMPOSER', 'STATUS']; + tui.addChild({ render: chat, invalidate() {} }); + let close: () => void = () => undefined; + const panel = new TuiSessionMutationRewindPreviewPanel({ + preview: { turns: [] }, + target: 'Target /example-skill', + impact: '1 turn affected · 0 files', + onCancel: () => close(), + onContinue: vi.fn(), + requestRender: () => tui.requestRender(), + }); + tui.start(); + try { + await terminal.waitForRender(); + const presenter = new TuiOverlayRegularFeaturePresenter(terminal, tui, () => + tui.requestRender(), + ); + const handle = presenter.show(panel, panel); + close = () => handle.close(); + tui.setFocus(handle.focus); + for (let update = 0; update < 3; update++) { + tui.renderNow(); + await terminal.flush(); + const viewport = terminal.getViewport().join('\n'); + expect(viewport).toContain('Rewind preview'); + expect(viewport).not.toContain('chat-marker-'); + expect(viewport).not.toContain('COMPOSER'); + expect(viewport).not.toContain('STATUS'); + if (update < 2) messages.push(`chat-marker-live-${update}`); + } + terminal.sendInput('\u001B'); + tui.renderNow(); + await terminal.flush(); + expect(terminal.getViewport()).toEqual(chat().slice(-rows)); + expect(terminal.getScrollBuffer()).toEqual(chat()); + } finally { + tui.stop(); + } + }, + ); + + it('groups ready vs skipped files and shows modified/created/deleted actions', () => { + const preview: TuiRewindPreview = { + turns: [ + { + turnId: 'turn-1', + files: [ + { filePath: 'src/a.ts', action: 'modified', skipped: false }, + { filePath: 'src/b.ts', action: 'created', skipped: false }, + { filePath: 'src/c.ts', action: 'deleted', skipped: false }, + { filePath: 'src/skipped.ts', action: 'modified', skipped: true }, + ], + }, + ], + }; + const onCancel = vi.fn(); + const panel = new TuiSessionMutationRewindPreviewPanel({ + preview, + onCancel, + }); + + const rendered = stripAnsi(panel.render(80).join('\n')); + + expect(rendered).toMatch(/Rewind preview/); + expect(rendered).toMatch(/Ready to rewind/); + expect(rendered).toMatch(/Skipped/); + expect(rendered).toContain('src/a.ts'); + expect(rendered).toContain('src/b.ts'); + expect(rendered).toContain('src/c.ts'); + expect(rendered).toContain('src/skipped.ts'); + expect(rendered).toMatch(/modified/); + expect(rendered).toMatch(/created/); + expect(rendered).toMatch(/deleted/); + expect(rendered).toMatch(/skipped/); + }); + + it('renders a no-diff message when no ready files remain', () => { + const preview: TuiRewindPreview = { + turns: [ + { + turnId: 'turn-1', + files: [{ filePath: 'src/already-clean.ts', action: 'modified', skipped: true }], + }, + ], + }; + const onCancel = vi.fn(); + const panel = new TuiSessionMutationRewindPreviewPanel({ preview, onCancel }); + + const rendered = stripAnsi(panel.render(80).join('\n')); + expect(rendered).toMatch(/no[- ]?diff/i); + }); + + it('treats repeated Escape presses as a single idempotent close', () => { + const panel = new TuiSessionMutationRewindPreviewPanel({ + preview: { turns: [] }, + onCancel: vi.fn(), + }); + + panel.handleInput('\u001b'); + panel.handleInput('\u001b'); + + expect(panel.isClosed()).toBe(true); + }); + + it('keeps scroll and confirmation controls visible when the file preview overflows', () => { + const preview: TuiRewindPreview = { + turns: [ + { + turnId: 'turn-many-files', + files: Array.from({ length: 20 }, (_, index) => ({ + filePath: `src/file-${String(index)}.ts`, + action: 'modified', + skipped: false, + })), + }, + ], + }; + const onContinue = vi.fn(); + const panel = new TuiSessionMutationRewindPreviewPanel({ + preview, + onCancel: vi.fn(), + onContinue, + continueHint: 'Enter rewind · Esc back', + requestRender: vi.fn(), + }); + + const first = stripAnsi(panel.renderViewport(70, 10).join('\n')); + expect(first).toContain('PgUp/PgDn scroll'); + expect(first).toContain('Enter rewind'); + expect(first).toContain('Esc back'); + + panel.handleInput('\u001b[6~'); + const scrolled = stripAnsi(panel.renderViewport(70, 10).join('\n')); + expect(scrolled).toContain('Enter rewind'); + expect(scrolled).not.toBe(first); + panel.handleInput('\r'); + expect(onContinue).toHaveBeenCalledOnce(); + }); +}); + +describe('TuiSessionMutationScopePicker', () => { + it('only exposes the two Desktop-compatible rewind scopes', () => { + const onSelect = vi.fn<(scope: TuiRewindScope) => void>(); + const picker = new TuiSessionMutationScopePicker({ onSelect, onCancel: vi.fn() }); + + const rendered = stripAnsi(picker.render(80).join('\n')); + + expect(rendered).toMatch(/Choose what to rewind/); + expect(rendered).toContain('Conversation only'); + expect(rendered).not.toContain('Files only'); + expect(rendered).toContain('Conversation and files'); + expect(rendered).not.toMatch(/partial/i); + + const compact = stripAnsi(picker.renderViewport(60, 4).join('\n')); + expect(compact).toContain('Enter confirm'); + expect(compact).toContain('Esc cancel'); + + picker.handleInput('\r'); + expect(onSelect).toHaveBeenCalledWith('conversation'); + }); + + it('returns the selected scope when navigating down then confirming', () => { + const onSelect = vi.fn<(scope: TuiRewindScope) => void>(); + const picker = new TuiSessionMutationScopePicker({ onSelect, onCancel: vi.fn() }); + + picker.handleInput('\u001b[B'); + picker.handleInput('\r'); + + expect(onSelect).toHaveBeenCalledWith('conversation_and_files'); + }); + + it('cancels idempotently and never fires onSelect', () => { + const onSelect = vi.fn(); + const onCancel = vi.fn(); + const picker = new TuiSessionMutationScopePicker({ onSelect, onCancel }); + + picker.handleInput('\u001b'); + picker.handleInput('\u001b'); + + expect(onCancel).toHaveBeenCalledTimes(1); + expect(onSelect).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/tui/test/unit/tui-surface-host.test.ts b/packages/tui/test/unit/tui-surface-host.test.ts new file mode 100644 index 000000000..da7e69253 --- /dev/null +++ b/packages/tui/test/unit/tui-surface-host.test.ts @@ -0,0 +1,771 @@ +import { describe, expect, it, vi } from 'vitest'; +import type { Terminal, TUI } from '../../src/tui/engine/public.js'; +import type { Component } from '../../src/tui/rendering/component.js'; +import { + TuiSurfaceHost, + type TuiFeatureScreen, + type TuiSurfaceHostOptions, +} from '../../src/tui/shell/surface-host.js'; +import { + TuiOverlayRegularFeaturePresenter, + type TuiRegularFeaturePresenter, +} from '../../src/tui/shell/regular-feature-presenter.js'; +import { TuiInteractionSurface } from '../../src/tui/shell/interaction-surface.js'; +import { TuiInlinePanelHost } from '../../src/tui/shell/inline-panel.js'; +import { TuiActivityLine } from '../../src/tui/shell/activity-line.js'; + +function component(lines: readonly string[]): Component { + return { + render: () => lines, + invalidate: () => undefined, + }; +} + +function screen(id: string, render: (width: number) => readonly string[]): TuiFeatureScreen { + const layoutRoot: Component = { render, invalidate: () => undefined }; + return { + id, + layoutRoot, + render: (width) => [...render(width)], + invalidate: () => undefined, + }; +} + +function createSurfaceHost( + options: Omit & { + regularFeaturePresenter?: TuiRegularFeaturePresenter; + }, +): TuiSurfaceHost { + return new TuiSurfaceHost({ + regularFeaturePresenter: { + show: (_screen, focus) => ({ focus, close: () => undefined }), + }, + ...options, + }); +} + +function createRecordingPresenter(): { + presenter: TuiRegularFeaturePresenter; + presentations: Array<{ + id: string; + focus: Component; + close: ReturnType; + }>; +} { + const presentations: Array<{ + id: string; + focus: Component; + close: ReturnType; + }> = []; + return { + presenter: { + show: (feature, focus) => { + const presentation = { id: feature.id, focus, close: vi.fn() }; + presentations.push(presentation); + return presentation; + }, + }, + presentations, + }; +} + +describe('TuiSurfaceHost', () => { + it.each(['regular', 'fullscreen'] as const)( + 'passes current terminal size to leaf features in %s', + (mode) => { + const terminal = { rows: 30 } as Terminal; + const overlays: Component[] = []; + const presenter = new TuiOverlayRegularFeaturePresenter( + terminal, + { + showOverlay: (overlay: Component) => { + overlays.push(overlay); + return { hide: vi.fn() }; + }, + } as unknown as TUI, + vi.fn(), + ); + const renderViewport = vi.fn((width: number, height: number) => [`${width}x${height}`]); + const host = createSurfaceHost({ + chat: { component: component(['chat']), focus: component([]) }, + chatMode: mode, + mode: () => mode, + viewportRows: () => terminal.rows, + regularFeaturePresenter: presenter, + setFocus: vi.fn(), + requestRender: vi.fn(), + }); + host.pushFeature({ screen: { ...screen('history', () => ['unbounded']), renderViewport } }); + const render = () => (mode === 'regular' ? overlays[0]!.render(40) : host.render(40)); + expect(render()).toEqual( + mode === 'regular' ? ['40x30', ...Array(29).fill('')] : ['40x30'], + ); + terminal.rows = 8; + expect(render()).toEqual(mode === 'regular' ? ['40x8', ...Array(7).fill('')] : ['40x8']); + host.dispose(); + }, + ); + + it('invalidates only the mounted chat root in regular mode', () => { + const chatInvalidate = vi.fn(); + const featureInvalidate = vi.fn(); + const host = createSurfaceHost({ + chat: { + component: { render: () => ['chat'], invalidate: chatInvalidate }, + focus: component([]), + }, + setFocus: vi.fn(), + requestRender: vi.fn(), + }); + const handle = host.pushFeature({ + screen: { + ...screen('sessions', () => ['sessions']), + invalidate: featureInvalidate, + }, + }); + expect(chatInvalidate).not.toHaveBeenCalled(); + expect(featureInvalidate).toHaveBeenCalledOnce(); + chatInvalidate.mockClear(); + featureInvalidate.mockClear(); + + host.invalidate(); + + expect(chatInvalidate).toHaveBeenCalledOnce(); + expect(featureInvalidate).not.toHaveBeenCalled(); + + chatInvalidate.mockClear(); + expect(handle.close()).toBe(true); + expect(chatInvalidate).not.toHaveBeenCalled(); + }); + + it('invalidates only the active fullscreen feature', () => { + const chatInvalidate = vi.fn(); + const firstInvalidate = vi.fn(); + const secondInvalidate = vi.fn(); + const host = createSurfaceHost({ + chat: { + component: { render: () => ['chat'], invalidate: chatInvalidate }, + focus: component([]), + }, + chatMode: 'fullscreen', + mode: () => 'fullscreen', + setFocus: vi.fn(), + requestRender: vi.fn(), + }); + host.pushFeature({ + screen: { ...screen('first', () => ['first']), invalidate: firstInvalidate }, + }); + host.pushFeature({ + screen: { ...screen('second', () => ['second']), invalidate: secondInvalidate }, + }); + chatInvalidate.mockClear(); + firstInvalidate.mockClear(); + secondInvalidate.mockClear(); + + host.invalidate(); + + expect(chatInvalidate).not.toHaveBeenCalled(); + expect(firstInvalidate).not.toHaveBeenCalled(); + expect(secondInvalidate).toHaveBeenCalledOnce(); + }); + + it('lets a feature screen own invalidation of its Pi layout root', () => { + const overlays: Component[] = []; + const tui = { + showOverlay: vi.fn((overlay: Component) => { + overlays.push(overlay); + return { hide: vi.fn() }; + }), + } as unknown as TUI; + const layoutInvalidate = vi.fn(); + const screenInvalidate = vi.fn(() => layoutInvalidate()); + const feature = { + id: 'sessions', + layoutRoot: { render: () => ['layout'], invalidate: layoutInvalidate }, + render: () => ['sessions'], + invalidate: screenInvalidate, + }; + const presenter = new TuiOverlayRegularFeaturePresenter({ rows: 24 } as Terminal, tui, vi.fn()); + + presenter.show(feature, feature); + overlays[0]?.invalidate(); + + expect(screenInvalidate).toHaveBeenCalledOnce(); + expect(layoutInvalidate).toHaveBeenCalledOnce(); + }); + + it('stops periodic terminal renders while an interaction owns the viewport', () => { + vi.useFakeTimers(); + try { + const requestActivityRender = vi.fn(); + const activity = new TuiActivityLine( + { phase: 'running', runId: 'turn-1' }, + { requestRender: requestActivityRender }, + ); + const host = new TuiInlinePanelHost(); + const surfaces = createSurfaceHost({ + chat: { component: component(['chat']), focus: component([]) }, + setFocus: vi.fn(), + requestRender: vi.fn(), + }); + const interaction = new TuiInteractionSurface(host, surfaces, vi.fn(), (active) => + activity.setAnimationPaused(active), + ); + const panel = component(['question']); + + vi.advanceTimersByTime(160); + expect(requestActivityRender).toHaveBeenCalledTimes(2); + + interaction.show(panel); + requestActivityRender.mockClear(); + vi.advanceTimersByTime(320); + expect(requestActivityRender).not.toHaveBeenCalled(); + + interaction.close(panel); + vi.advanceTimersByTime(80); + expect(requestActivityRender).toHaveBeenCalledOnce(); + + activity.dispose(); + } finally { + vi.useRealTimers(); + } + }); + + it('keeps the default chat surface inline and persistent', () => { + const chat = component(['chat']); + const focus = component([]); + const host = createSurfaceHost({ + chat: { component: chat, focus }, + setFocus: vi.fn(), + requestRender: vi.fn(), + }); + + expect(host.getActiveSurface()).toEqual({ kind: 'chat', id: 'chat' }); + expect(host.render(40)).toEqual(['chat']); + }); + + it('presents a regular feature over the persistent chat document and restores chat focus on close', () => { + const chat = component(['chat']); + const chatFocus = component([]); + const featureFocus = component([]); + const setFocus = vi.fn(); + const requestRender = vi.fn(); + const { presenter, presentations } = createRecordingPresenter(); + const host = createSurfaceHost({ + chat: { component: chat, focus: chatFocus }, + regularFeaturePresenter: presenter, + setFocus, + requestRender, + }); + const feature = screen('sessions', () => ['header', 'body', 'footer']); + + const handle = host.pushFeature({ screen: feature, focus: featureFocus }); + + expect(host.getActiveSurface()).toEqual({ kind: 'feature', id: 'sessions' }); + expect(host.render(40)).toEqual(['chat']); + expect(presentations.map(({ id }) => id)).toEqual(['sessions']); + expect(setFocus).toHaveBeenLastCalledWith(featureFocus); + expect(requestRender).toHaveBeenLastCalledWith(); + + expect(handle.close()).toBe(true); + expect(host.getActiveSurface()).toEqual({ kind: 'chat', id: 'chat' }); + expect(host.render(40)).toEqual(['chat']); + expect(presentations[0]?.close).toHaveBeenCalledOnce(); + expect(setFocus).toHaveBeenLastCalledWith(chatFocus); + expect(requestRender).toHaveBeenLastCalledWith(); + }); + + it('uses a central stack for nested feature screens', () => { + const chatFocus = component([]); + const firstFocus = component([]); + const secondFocus = component([]); + const setFocus = vi.fn(); + const host = createSurfaceHost({ + chat: { component: component(['chat']), focus: chatFocus }, + setFocus, + requestRender: vi.fn(), + }); + const first = host.pushFeature({ + screen: screen('sessions', () => ['sessions']), + focus: firstFocus, + }); + const second = host.pushFeature({ + screen: screen('session-detail', () => ['detail']), + focus: secondFocus, + }); + + expect(first.close()).toBe(false); + expect(second.close()).toBe(true); + expect(host.getActiveSurface()).toEqual({ kind: 'feature', id: 'sessions' }); + expect(setFocus).toHaveBeenLastCalledWith(firstFocus); + expect(first.close()).toBe(true); + expect(setFocus).toHaveBeenLastCalledWith(chatFocus); + }); + + it('keeps a regular renderer stable while a feature stack opens and closes', () => { + let mode: 'regular' | 'fullscreen' = 'regular'; + const switchMode = vi.fn((next: typeof mode) => { + mode = next; + return true; + }); + const host = createSurfaceHost({ + chat: { component: component(['chat']), focus: component([]) }, + mode: () => mode, + setFocus: vi.fn(), + requestRender: vi.fn(), + switchMode, + }); + + const first = host.pushFeature({ screen: screen('first', () => ['first']) }); + const second = host.pushFeature({ screen: screen('second', () => ['second']) }); + + expect(mode).toBe('regular'); + expect(switchMode).not.toHaveBeenCalled(); + second.close(); + first.close(); + expect(mode).toBe('regular'); + expect(switchMode).not.toHaveBeenCalled(); + }); + + it('keeps chat and feature screens fullscreen when fullscreen is the configured chat mode', () => { + const switchMode = vi.fn(() => true); + const host = createSurfaceHost({ + chat: { component: component(['chat']), focus: component([]) }, + chatMode: 'fullscreen', + mode: () => 'fullscreen', + setFocus: vi.fn(), + requestRender: vi.fn(), + switchMode, + }); + + const feature = host.pushFeature({ screen: screen('sessions', () => ['sessions']) }); + + expect(host.getChatMode()).toBe('fullscreen'); + expect(switchMode).not.toHaveBeenCalled(); + expect(feature.close()).toBe(true); + expect(host.getActiveSurface()).toEqual({ kind: 'chat', id: 'chat' }); + expect(switchMode).not.toHaveBeenCalled(); + }); + + it('switches the visible chat immediately when its configured mode changes', () => { + const switchMode = vi.fn(() => true); + const host = createSurfaceHost({ + chat: { component: component(['chat']), focus: component([]) }, + setFocus: vi.fn(), + requestRender: vi.fn(), + switchMode, + }); + + expect(host.setChatMode('fullscreen')).toBe(true); + expect(host.getChatMode()).toBe('fullscreen'); + expect(switchMode).toHaveBeenCalledOnce(); + expect(switchMode).toHaveBeenCalledWith('fullscreen'); + }); + + it('changes the renderer immediately while keeping the active feature in the selected mode', () => { + let mode: 'regular' | 'fullscreen' = 'fullscreen'; + const switchMode = vi.fn((next: typeof mode) => { + mode = next; + return true; + }); + const { presenter, presentations } = createRecordingPresenter(); + const host = createSurfaceHost({ + chat: { component: component(['chat']), focus: component([]) }, + chatMode: 'fullscreen', + mode: () => mode, + regularFeaturePresenter: presenter, + setFocus: vi.fn(), + requestRender: vi.fn(), + switchMode, + }); + const feature = host.pushFeature({ screen: screen('sessions', () => ['sessions']) }); + + expect(host.setChatMode('regular')).toBe(true); + expect(host.getChatMode()).toBe('regular'); + expect(switchMode).toHaveBeenCalledOnce(); + expect(switchMode).toHaveBeenCalledWith('regular'); + expect(presentations.map(({ id }) => id)).toEqual(['sessions']); + + expect(feature.close()).toBe(true); + expect(switchMode).toHaveBeenCalledOnce(); + expect(presentations[0]?.close).toHaveBeenCalledOnce(); + }); + + it('rolls back the configured chat mode when Pi refuses the renderer switch', () => { + const switchMode = vi.fn(() => false); + const host = createSurfaceHost({ + chat: { component: component(['chat']), focus: component([]) }, + setFocus: vi.fn(), + requestRender: vi.fn(), + switchMode, + }); + + expect(host.setChatMode('fullscreen')).toBe(false); + expect(host.getChatMode()).toBe('regular'); + }); + + it('rolls back the configured chat mode when the renderer switch throws', () => { + const host = createSurfaceHost({ + chat: { component: component(['chat']), focus: component([]) }, + setFocus: vi.fn(), + requestRender: vi.fn(), + switchMode: () => { + throw new Error('terminal restart failed'); + }, + }); + + expect(() => host.setChatMode('fullscreen')).toThrow('terminal restart failed'); + expect(host.getChatMode()).toBe('regular'); + expect(host.getActiveSurface()).toEqual({ kind: 'chat', id: 'chat' }); + }); + + it('restores fullscreen when a regular feature cannot be presented after a mode change', () => { + let mode: 'regular' | 'fullscreen' = 'fullscreen'; + const switchMode = vi.fn((next: typeof mode) => { + mode = next; + return true; + }); + const host = createSurfaceHost({ + chat: { component: component(['chat']), focus: component([]) }, + chatMode: 'fullscreen', + mode: () => mode, + regularFeaturePresenter: { + show: () => { + throw new Error('regular feature presentation failed'); + }, + }, + setFocus: vi.fn(), + requestRender: vi.fn(), + switchMode, + }); + host.pushFeature({ screen: screen('sessions', () => ['sessions']) }); + + expect(() => host.setChatMode('regular')).toThrow('regular feature presentation failed'); + expect(host.getChatMode()).toBe('fullscreen'); + expect(mode).toBe('fullscreen'); + expect(switchMode.mock.calls.map(([next]) => next)).toEqual(['regular', 'fullscreen']); + expect(host.getActiveSurface()).toEqual({ kind: 'feature', id: 'sessions' }); + }); + + it('does not mutate the feature stack when the regular presenter refuses a feature', () => { + const host = createSurfaceHost({ + chat: { component: component(['chat']), focus: component([]) }, + regularFeaturePresenter: { + show: () => { + throw new Error('regular feature presentation failed'); + }, + }, + setFocus: vi.fn(), + requestRender: vi.fn(), + }); + const feature = screen('sessions', () => ['sessions']); + + expect(() => host.pushFeature({ screen: feature })).toThrow( + 'regular feature presentation failed', + ); + expect(host.getActiveSurface()).toEqual({ kind: 'chat', id: 'chat' }); + expect(host.render(40)).toEqual(['chat']); + }); + + it('restores a nested feature stack when presenting the previous feature throws on close', () => { + let failNextPresentation = false; + const presentations: Array<{ id: string; close: ReturnType }> = []; + const presenter: TuiRegularFeaturePresenter = { + show: (feature, focus) => { + if (failNextPresentation) { + failNextPresentation = false; + throw new Error('regular feature presentation failed'); + } + const presentation = { id: feature.id, focus, close: vi.fn() }; + presentations.push(presentation); + return presentation; + }, + }; + const firstDispose = vi.fn(); + const secondDispose = vi.fn(); + const host = createSurfaceHost({ + chat: { component: component(['chat']), focus: component([]) }, + regularFeaturePresenter: presenter, + setFocus: vi.fn(), + requestRender: vi.fn(), + }); + + const first = host.pushFeature({ + screen: { ...screen('first', () => ['first']), dispose: firstDispose }, + }); + const second = host.pushFeature({ + screen: { ...screen('second', () => ['second']), dispose: secondDispose }, + }); + + failNextPresentation = true; + expect(() => second.close()).toThrow('regular feature presentation failed'); + expect(host.getActiveSurface()).toEqual({ kind: 'feature', id: 'second' }); + expect(secondDispose).not.toHaveBeenCalled(); + expect(presentations.at(-1)?.id).toBe('second'); + + expect(second.close()).toBe(true); + expect(host.getActiveSurface()).toEqual({ kind: 'feature', id: 'first' }); + expect(secondDispose).toHaveBeenCalledOnce(); + expect(first.close()).toBe(true); + expect(firstDispose).toHaveBeenCalledOnce(); + }); + + it('defers chat focus changes until the feature stack closes', () => { + const initialChatFocus = component([]); + const pendingDecisionFocus = component([]); + const featureFocus = component([]); + const setFocus = vi.fn(); + const host = createSurfaceHost({ + chat: { component: component(['chat']), focus: initialChatFocus }, + setFocus, + requestRender: vi.fn(), + }); + const feature = host.pushFeature({ + screen: screen('sessions', () => ['sessions']), + focus: featureFocus, + }); + + host.setChatFocus(pendingDecisionFocus); + expect(setFocus).toHaveBeenCalledTimes(1); + expect(setFocus).toHaveBeenLastCalledWith(featureFocus); + + feature.close(); + expect(setFocus).toHaveBeenLastCalledWith(pendingDecisionFocus); + }); + + it('restores the highest-priority chat layer around feature navigation', () => { + const composer = component([]); + const interactionFocus = component([]); + const modalFocus = component([]); + const featureFocus = component([]); + const setFocus = vi.fn(); + const host = createSurfaceHost({ + chat: { component: component(['chat']), focus: composer }, + setFocus, + requestRender: vi.fn(), + }); + + const interaction = host.pushChatLayer({ + id: 'interaction', + focus: interactionFocus, + priority: 100, + }); + const modal = host.pushChatLayer({ + id: 'modal', + focus: modalFocus, + priority: 200, + }); + const feature = host.pushFeature({ + screen: screen('sessions', () => ['sessions']), + focus: featureFocus, + }); + + expect(setFocus).toHaveBeenLastCalledWith(featureFocus); + feature.close(); + expect(setFocus).toHaveBeenLastCalledWith(modalFocus); + modal.close(); + expect(setFocus).toHaveBeenLastCalledWith(interactionFocus); + interaction.close(); + expect(setFocus).toHaveBeenLastCalledWith(composer); + }); + + it('temporarily lets a blocking chat layer preempt a feature screen', () => { + const composer = component([]); + const featureFocus = component([]); + const decisionFocus = component([]); + const setFocus = vi.fn(); + const switchMode = vi.fn(() => true); + const { presenter, presentations } = createRecordingPresenter(); + const host = createSurfaceHost({ + chat: { component: component(['chat decision']), focus: composer }, + regularFeaturePresenter: presenter, + setFocus, + requestRender: vi.fn(), + switchMode, + }); + host.pushFeature({ + screen: screen('transcript', () => ['transcript inspector']), + focus: featureFocus, + }); + + const decision = host.pushChatLayer({ + id: 'interaction', + focus: decisionFocus, + priority: 100, + preemptsFeatures: true, + }); + + expect(host.getActiveSurface()).toEqual({ kind: 'chat', id: 'chat' }); + expect(host.render(40)[0]).toBe('chat decision'); + expect(setFocus).toHaveBeenLastCalledWith(decisionFocus); + expect(presentations.map(({ id }) => id)).toEqual(['transcript']); + expect(presentations[0]?.close).toHaveBeenCalledOnce(); + expect(switchMode).not.toHaveBeenCalled(); + + decision.close(); + expect(host.getActiveSurface()).toEqual({ kind: 'feature', id: 'transcript' }); + expect(host.render(40)[0]).toBe('chat decision'); + expect(setFocus).toHaveBeenLastCalledWith(featureFocus); + expect(presentations.map(({ id }) => id)).toEqual(['transcript', 'transcript']); + expect(switchMode).not.toHaveBeenCalled(); + }); + + it('replaces an active interaction without changing the configured fullscreen renderer', () => { + let mode: 'regular' | 'fullscreen' = 'fullscreen'; + const switchMode = vi.fn((next: typeof mode) => { + mode = next; + return true; + }); + const inline = new TuiInlinePanelHost(); + const first = component(['first decision']); + const second = component(['second decision']); + const setFocus = vi.fn(); + const followFullscreenBottom = vi.fn(); + const host = createSurfaceHost({ + chat: { component: inline, focus: component([]) }, + chatMode: 'fullscreen', + mode: () => mode, + setFocus, + requestRender: vi.fn(), + switchMode, + }); + host.pushFeature({ screen: screen('transcript', () => ['transcript']) }); + const interaction = new TuiInteractionSurface( + inline, + host, + vi.fn(), + undefined, + followFullscreenBottom, + ); + + interaction.show(first); + expect(followFullscreenBottom).toHaveBeenCalledOnce(); + expect(mode).toBe('fullscreen'); + expect(switchMode).not.toHaveBeenCalled(); + interaction.show(second); + expect(followFullscreenBottom).toHaveBeenCalledTimes(2); + + expect(switchMode).not.toHaveBeenCalled(); + expect(inline.current()).toBe(second); + expect(setFocus).toHaveBeenLastCalledWith(second); + }); + + it('keeps an interaction mounted when restoring its preempted regular feature throws', () => { + let failNextPresentation = false; + const inline = new TuiInlinePanelHost(); + const host = createSurfaceHost({ + chat: { component: inline, focus: component([]) }, + regularFeaturePresenter: { + show: (_feature, focus) => { + if (failNextPresentation) { + failNextPresentation = false; + throw new Error('feature restore failed'); + } + return { focus, close: () => undefined }; + }, + }, + setFocus: vi.fn(), + requestRender: vi.fn(), + }); + host.pushFeature({ screen: screen('transcript', () => ['transcript']) }); + const interaction = new TuiInteractionSurface(inline, host, vi.fn()); + const activePanel = component(['active decision']); + interaction.show(activePanel); + expect(host.getActiveSurface()).toEqual({ kind: 'chat', id: 'chat' }); + failNextPresentation = true; + expect(() => interaction.close(activePanel)).toThrow('feature restore failed'); + expect(inline.isActive(activePanel)).toBe(true); + expect(host.getActiveSurface()).toEqual({ kind: 'chat', id: 'chat' }); + + expect(interaction.close(activePanel)).toBe(true); + expect(inline.isActive()).toBe(false); + expect(host.getActiveSurface()).toEqual({ kind: 'feature', id: 'transcript' }); + }); + + it('keeps a blocking chat layer in the configured fullscreen chat mode', () => { + const switchMode = vi.fn(() => true); + const host = createSurfaceHost({ + chat: { component: component(['chat']), focus: component([]) }, + chatMode: 'fullscreen', + mode: () => 'fullscreen', + setFocus: vi.fn(), + requestRender: vi.fn(), + switchMode, + }); + host.pushFeature({ screen: screen('transcript', () => ['transcript']) }); + + const interaction = host.pushChatLayer({ + id: 'interaction', + focus: component([]), + priority: 100, + preemptsFeatures: true, + }); + + expect(host.getActiveSurface()).toEqual({ kind: 'chat', id: 'chat' }); + expect(switchMode).not.toHaveBeenCalled(); + expect(interaction.close()).toBe(true); + expect(host.getActiveSurface()).toEqual({ kind: 'feature', id: 'transcript' }); + expect(switchMode).not.toHaveBeenCalled(); + }); + + it('passes the complete feature document to the Pi layout tree', () => { + const host = createSurfaceHost({ + chat: { component: component(['chat']), focus: component([]) }, + chatMode: 'fullscreen', + mode: () => 'fullscreen', + setFocus: vi.fn(), + requestRender: vi.fn(), + }); + host.pushFeature({ + screen: screen('diff', () => ['one', 'two', 'three', 'four']), + focus: component([]), + }); + + expect(host.render(40)).toEqual(['one', 'two', 'three', 'four']); + }); + + it('disposes feature screens and distinct focus owners exactly once', () => { + const screenDispose = vi.fn(); + const focusDispose = vi.fn(); + const feature = { + ...screen('sessions', () => ['sessions']), + dispose: screenDispose, + }; + const focus = { + ...component([]), + dispose: focusDispose, + }; + const host = createSurfaceHost({ + chat: { component: component(['chat']), focus: component([]) }, + setFocus: vi.fn(), + requestRender: vi.fn(), + }); + + const handle = host.pushFeature({ screen: feature, focus }); + + expect(handle.close()).toBe(true); + expect(handle.close()).toBe(false); + expect(screenDispose).toHaveBeenCalledOnce(); + expect(focusDispose).toHaveBeenCalledOnce(); + }); + + it('disposes every feature when clearing a nested stack', () => { + const disposals = [vi.fn(), vi.fn()]; + const host = createSurfaceHost({ + chat: { component: component(['chat']), focus: component([]) }, + setFocus: vi.fn(), + requestRender: vi.fn(), + }); + host.pushFeature({ + screen: { ...screen('first', () => ['first']), dispose: disposals[0] }, + }); + host.pushFeature({ + screen: { ...screen('second', () => ['second']), dispose: disposals[1] }, + }); + + host.clearFeatures(); + + expect(disposals[0]).toHaveBeenCalledOnce(); + expect(disposals[1]).toHaveBeenCalledOnce(); + }); +}); diff --git a/packages/tui/test/unit/tui-transcript-view.test.ts b/packages/tui/test/unit/tui-transcript-view.test.ts index e6e0f6044..ef4686d28 100644 --- a/packages/tui/test/unit/tui-transcript-view.test.ts +++ b/packages/tui/test/unit/tui-transcript-view.test.ts @@ -332,7 +332,7 @@ describe('TranscriptView', () => { expect(updated.join('\n')).toContain('Updated answer'); }); - it('renders rich delivery markup as a compact terminal file receipt', () => { + it.each(['src', 'path'])('renders %s delivery markup as a compact terminal file receipt', (attribute) => { const view = new TranscriptView(() => [ createTranscriptCell({ id: 'assistant-assets', @@ -341,7 +341,7 @@ describe('TranscriptView', () => { content: [ 'Done.', '', - '', + ``, '', ].join('\n'), createdAtMs: 1, diff --git a/release/public-source.json b/release/public-source.json index 6e96c8d26..42e51bd82 100644 --- a/release/public-source.json +++ b/release/public-source.json @@ -18,7 +18,10 @@ ".github/branch-protection.json", ".github/dependabot.yml", ".github/workflows/ci.yml", + ".github/workflows/cli-release.yml", ".github/workflows/compatibility.yml", + ".github/workflows/label-issue-product.yml", + ".github/workflows/performance.yml", ".github/workflows/security.yml", ".github/workflows/source-candidate.yml", ".github/workflows/sync-issue-to-feishu.yml", @@ -53,6 +56,7 @@ "docs/installation.md", "docs/maintainers.md", "docs/open-source-status.md", + "docs/performance-ci.md", "docs/publication-authorization.md", "docs/release-audit.md", "docs/releasing.md", @@ -463,6 +467,7 @@ "packages/config/src/tui-config.ts", "packages/config/src/tui-status-line-write.ts", "packages/config/test/config-file-permissions.test.ts", + "packages/config/test/config-update-preservation.test.ts", "packages/local-runtime-v2/assets/agents/_default/prompt-base-all.md", "packages/local-runtime-v2/assets/agents/_default/prompt-base-all.md.hbs", "packages/local-runtime-v2/assets/agents/_default/prompt-base-windows.md", @@ -3224,6 +3229,7 @@ "packages/tui/test/unit/runtime-product-access.test.ts", "packages/tui/test/unit/tui-activity-line.test.ts", "packages/tui/test/unit/tui-app.test.ts", + "packages/tui/test/unit/tui-assistant-content.test.ts", "packages/tui/test/unit/tui-background-work-panel.test.ts", "packages/tui/test/unit/tui-build-mode-contract.test.ts", "packages/tui/test/unit/tui-capability-inspection.test.ts", @@ -3234,6 +3240,7 @@ "packages/tui/test/unit/tui-delegation-flow.test.ts", "packages/tui/test/unit/tui-duration.test.ts", "packages/tui/test/unit/tui-engine-local-deltas.test.ts", + "packages/tui/test/unit/tui-feature-viewport-contract.test.ts", "packages/tui/test/unit/tui-goal-banner.test.ts", "packages/tui/test/unit/tui-image-preview.test.ts", "packages/tui/test/unit/tui-keybindings.test.ts", @@ -3244,9 +3251,12 @@ "packages/tui/test/unit/tui-provider-onboarding.test.ts", "packages/tui/test/unit/tui-runtime-adapter.test.ts", "packages/tui/test/unit/tui-scrollbar-interaction.test.ts", + "packages/tui/test/unit/tui-session-history-flow.test.ts", "packages/tui/test/unit/tui-session-manager.test.ts", + "packages/tui/test/unit/tui-session-mutation-panels.test.ts", "packages/tui/test/unit/tui-shell-block.test.ts", "packages/tui/test/unit/tui-status-line-items.test.ts", + "packages/tui/test/unit/tui-surface-host.test.ts", "packages/tui/test/unit/tui-terminal-image-paste.test.ts", "packages/tui/test/unit/tui-thinking-preview.test.ts", "packages/tui/test/unit/tui-transcript-presentation.test.ts", @@ -3276,6 +3286,7 @@ "scripts/export-source-preview.mjs", "scripts/gen-tsconfig-paths.mjs", "scripts/lib/builtin-skills.mjs", + "scripts/lib/cli-release.mjs", "scripts/lib/local-runtime-assets.mjs", "scripts/lib/mcode-tools-artifact.mjs", "scripts/lib/package-exports.mjs", @@ -3285,13 +3296,22 @@ "scripts/lib/tui-npm-bundle-profile.mjs", "scripts/lib/tui-package-privacy.mjs", "scripts/lib/vitest-suites.mjs", + "scripts/package-cli-release.mjs", + "scripts/perf/config.json", + "scripts/perf/driver.ts", + "scripts/perf/report.mjs", + "scripts/perf/run.mjs", "scripts/prepare-source-sync.mjs", + "scripts/publish-cli-release.mjs", + "scripts/release-cli.mjs", "scripts/run-vitest-suite.mjs", "scripts/source-candidate.mjs", "scripts/source-inventory.mjs", + "scripts/verify-cli-release.mjs", "scripts/verify.mjs", "test/byok.test.mjs", "test/executable-resolution.test.ts", + "test/history-processing.test.ts", "test/network-deny.mjs", "test/offline-environment.mjs", "test/public-artifact.test.mjs", diff --git a/scripts/build.mjs b/scripts/build.mjs index 9c98e6810..cd77d2e67 100644 --- a/scripts/build.mjs +++ b/scripts/build.mjs @@ -9,6 +9,7 @@ import { writeFileSync, } from "node:fs"; import { createRequire } from "node:module"; +import { execFileSync } from 'node:child_process'; import { fileURLToPath } from "node:url"; import path from "node:path"; import { copyLocalRuntimeAssets } from "./lib/local-runtime-assets.mjs"; @@ -17,6 +18,7 @@ import { shouldCopyTuiRuntimeResource } from "./lib/tui-package-privacy.mjs"; import { TUI_DISABLED_BUILTIN_SKILL_NAMES } from "./lib/builtin-skills.mjs"; import { copyMcodeToolsArtifact } from './lib/mcode-tools-artifact.mjs'; import { readExtraction } from "./lib/release-metadata.mjs"; +import { cliBuildVersion, cliExternalModules } from './lib/cli-release.mjs'; const root = fileURLToPath(new URL("../", import.meta.url)); const metadata = readExtraction(root); @@ -72,7 +74,7 @@ const sourcePlugin = { }); }, }; -const version = packages.get("@minimax/code").manifest.version; +const version = cliBuildVersion(root); const result = await build({ absWorkingDir: root, entryPoints: { @@ -81,12 +83,7 @@ const result = await build({ 'mcode-tools': 'packages/tui/src/cli/mcode-tools-entry.ts', 'matrix-mcp-stdio': 'packages/agent-tools/src/desktop/matrix-mcp-stdio.ts', }, - external: [ - "better-sqlite3", - "@mariozechner/clipboard", - "@vscode/ripgrep", - "@larksuiteoapi/node-sdk", - ], + external: cliExternalModules, outdir, bundle: true, splitting: true, @@ -143,7 +140,12 @@ console.log( writeFileSync( path.join(outdir, "package.json"), JSON.stringify( - { name: "@minimax-ai/code", version, type: "module", private: true }, + { + name: "@minimax-ai/code", version, type: "module", private: true, + ...(process.env.MCODE_RELEASE_TAG ? { + gitHead: execFileSync('git', ['rev-parse', 'HEAD'], { cwd: root, encoding: 'utf8' }).trim(), + } : {}), + }, null, 2, ) + "\n", diff --git a/scripts/lib/cli-release.mjs b/scripts/lib/cli-release.mjs new file mode 100644 index 000000000..3b1eaae0e --- /dev/null +++ b/scripts/lib/cli-release.mjs @@ -0,0 +1,31 @@ +import { readFileSync } from 'node:fs'; +import path from 'node:path'; + +export function versionFromTag(tag) { + // Accept canonical SemVer release/prerelease tags, with no build metadata. + const number = '(?:0|[1-9][0-9]*)'; + const identifier = '(?:0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*)'; + if (typeof tag !== 'string' || tag.trim() !== tag || !new RegExp(`^v${number}\\.${number}\\.${number}(?:-${identifier}(?:\\.${identifier})*)?$`).test(tag)) { + throw new Error('Release tag must be vX.Y.Z or vX.Y.Z-prerelease (canonical SemVer).'); + } + return tag.slice(1); +} + +export function cliBuildVersion(root, tag = process.env.MCODE_RELEASE_TAG) { + const version = JSON.parse(readFileSync(path.join(root, 'package.json'), 'utf8')).version; + const tuiVersion = JSON.parse(readFileSync(path.join(root, 'packages/tui/package.json'), 'utf8')).version; + if (version !== tuiVersion) throw new Error('Root and TUI package versions must match.'); + if (tag != null && versionFromTag(tag) !== version) throw new Error('Release tag must match root and TUI package versions. Run the release command before tagging.'); + return version; +} + +// These modules stay outside the JS bundle and must travel with an installation. +export const cliExternalModules = [ + 'better-sqlite3', + '@mariozechner/clipboard', + '@vscode/ripgrep', + '@larksuiteoapi/node-sdk', +]; + +export const cliReleaseTargets = ['ubuntu-latest', 'macos-latest'].flatMap(os => + ['22.19.0', '24.2.0', '25', '26'].map(node => ({ os, node }))); diff --git a/scripts/lib/mcode-tools-artifact.mjs b/scripts/lib/mcode-tools-artifact.mjs index 98868d243..0c4fe695a 100644 --- a/scripts/lib/mcode-tools-artifact.mjs +++ b/scripts/lib/mcode-tools-artifact.mjs @@ -2,6 +2,7 @@ import { createHash } from 'node:crypto'; import { mkdir, readFile, writeFile } from 'node:fs/promises'; import path from 'node:path'; import { Readable } from 'node:stream'; +import { setTimeout as sleep } from 'node:timers/promises'; import { Parser } from 'tar'; export const MCODE_TOOLS_ARTIFACT = Object.freeze({ @@ -51,15 +52,52 @@ export async function extractMcodeToolsArtifact(bytes) { return { cli, manifest: manifestBytes, notices }; } +const transientNetworkCodes = new Set([ + 'ECONNRESET', 'ECONNREFUSED', 'EPIPE', 'ETIMEDOUT', 'EAI_AGAIN', 'ENETUNREACH', + 'UND_ERR_CONNECT_TIMEOUT', 'UND_ERR_HEADERS_TIMEOUT', 'UND_ERR_BODY_TIMEOUT', 'UND_ERR_SOCKET', +]); +const transientHttpStatuses = new Set([408, 429, 500, 502, 503, 504]); + +export async function downloadMcodeToolsArtifact(fetchImpl = fetch, { wait = sleep, warn = console.warn } = {}) { + const attempts = 3; + for (let attempt = 1; attempt <= attempts; attempt++) { + let retryAfterMs = 0; + try { + // A new deadline covers both connection setup and the complete response body. + const response = await fetchImpl(MCODE_TOOLS_ARTIFACT.url, { signal: AbortSignal.timeout(120_000) }); + if (!response.ok) { + const retryAfter = response.headers.get('retry-after'); + if (retryAfter !== null) { + const milliseconds = /^\d+$/.test(retryAfter) ? Number(retryAfter) * 1000 : Date.parse(retryAfter) - Date.now(); + if (!Number.isNaN(milliseconds)) retryAfterMs = Math.min(30_000, Math.max(0, milliseconds)); + } + await response.body?.cancel().catch(() => {}); + const error = new Error(`Cannot download public mcode-tools artifact: HTTP ${response.status}`); + error.status = response.status; + throw error; + } + return Buffer.from(await response.arrayBuffer()); + } catch (error) { + const code = error.cause?.code ?? error.code; + const retryable = transientHttpStatuses.has(error.status) || + transientNetworkCodes.has(code) || error.name === 'TimeoutError'; + if (!retryable) throw error; + if (attempt === attempts) + throw new Error(`Cannot download public mcode-tools artifact after ${attempts} attempts`, { cause: error }); + const delayMs = Math.max(1000 * 2 ** (attempt - 1), retryAfterMs); + warn(`[mcode-tools] Download attempt ${attempt}/${attempts} failed (${code ?? error.message}); retrying in ${delayMs} ms.`); + await wait(delayMs); + } + } +} + export async function copyMcodeToolsArtifact(root, outdir, fetchImpl = fetch) { const cache = path.join(root, '.cache', 'artifacts', 'code-0.3.11.tgz'); let bytes; try { bytes = await readFile(cache); } catch (error) { if (error.code !== 'ENOENT') throw error; - const response = await fetchImpl(MCODE_TOOLS_ARTIFACT.url, { signal: AbortSignal.timeout(120_000) }); - if (!response.ok) throw new Error(`Cannot download public mcode-tools artifact: HTTP ${response.status}`); - bytes = Buffer.from(await response.arrayBuffer()); + bytes = await downloadMcodeToolsArtifact(fetchImpl); } const artifact = await extractMcodeToolsArtifact(bytes); await mkdir(path.dirname(cache), { recursive: true }); diff --git a/scripts/package-cli-release.mjs b/scripts/package-cli-release.mjs new file mode 100644 index 000000000..e559d95ec --- /dev/null +++ b/scripts/package-cli-release.mjs @@ -0,0 +1,114 @@ +import { copyFileSync, cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, realpathSync, rmSync, writeFileSync } from 'node:fs'; +import { createRequire } from 'node:module'; +import { createHash } from 'node:crypto'; +import { execFileSync } from 'node:child_process'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { c as createTar } from 'tar'; +import { cliBuildVersion, cliExternalModules } from './lib/cli-release.mjs'; +import { readExtraction, dependencyLicensesPath } from './lib/release-metadata.mjs'; + +const root = fileURLToPath(new URL('../', import.meta.url)); +const json = file => JSON.parse(readFileSync(file, 'utf8')); +const digest = bytes => createHash('sha256').update(bytes).digest('hex'); + +function resolvePackage(name, importer) { + const require = createRequire(path.join(importer, 'package.json')); + for (const search of require.resolve.paths(`${name}/package.json`) ?? []) { + const candidate = path.join(search, name); + if (existsSync(path.join(candidate, 'package.json'))) return realpathSync(candidate); + } + return undefined; +} + +function copyLicenses(stage) { + const licenses = path.join(stage, 'licenses'); + mkdirSync(licenses); + for (const name of ['LICENSE', 'NOTICE', 'LICENSE-STATUS.md', 'THIRD_PARTY_NOTICES.md']) copyFileSync(path.join(root, name), path.join(stage, name)); + copyFileSync(path.join(root, dependencyLicensesPath), path.join(licenses, 'dependency-licenses.json')); + for (const directory of ['third_party/pi-mono', 'third_party/sandbox-runtime']) { + copyFileSync(path.join(root, directory, 'LICENSE'), path.join(licenses, `${path.basename(directory)}-LICENSE`)); + } + const metafile = json(path.join(root, 'dist/metafile.json')); + const packageRoots = new Set(); + for (const input of Object.keys(metafile.inputs)) { + if (!input.includes('node_modules/')) continue; + let directory = path.dirname(path.resolve(root, input)); + while (directory !== path.dirname(directory)) { + const manifest = path.join(directory, 'package.json'); + if (existsSync(manifest) && json(manifest).name) break; + directory = path.dirname(directory); + } + if (existsSync(path.join(directory, 'package.json'))) packageRoots.add(realpathSync(directory)); + } + for (const directory of [...packageRoots].sort()) { + const manifest = json(path.join(directory, 'package.json')); + const target = path.join(licenses, `${manifest.name.replaceAll('/', '+')}@${manifest.version}`); + mkdirSync(target, { recursive: true }); + for (const name of readdirSync(directory).filter(name => /^(licen[cs]e|copying|notice)([.-]|$)/i.test(name))) { + cpSync(path.join(directory, name), path.join(target, name), { recursive: true }); + } + } +} + +export function releaseManifest(importers, version) { + const dependencies = {}, optionalDependencies = {}; + for (const name of cliExternalModules) { + const versions = new Set(importers.map(importer => resolvePackage(name, importer)).filter(Boolean) + .map(directory => json(path.join(directory, 'package.json')).version)); + if (versions.size !== 1) throw new Error(`Expected one installed version for ${name}, found ${[...versions]}`); + const target = name === '@mariozechner/clipboard' ? optionalDependencies : dependencies; + target[name] = [...versions][0]; + } + return { + name: '@minimax-ai/code', version, private: true, type: 'module', license: 'MIT', + description: 'MiniMax Code CLI built from the tagged public source.', + bin: { mcode: 'cli.js' }, + engines: json(path.join(root, 'package.json')).engines, + repository: { type: 'git', url: 'https://github.com/MiniMax-AI/minimax-code.git' }, + dependencies, optionalDependencies, + }; +} + +export async function packageCliRelease({ tag, out }) { + const version = cliBuildVersion(root, tag); + const dist = path.join(root, 'dist'); + if (json(path.join(dist, 'package.json')).version !== version) throw new Error('Build version does not match the release tag. Build with MCODE_RELEASE_TAG first.'); + const revision = execFileSync('git', ['rev-parse', 'HEAD'], { cwd: root, encoding: 'utf8' }).trim(); + if (execFileSync('git', ['status', '--porcelain', '--untracked-files=no'], { cwd: root, encoding: 'utf8' }).trim()) throw new Error('Packaging requires a clean committed working tree.'); + if (json(path.join(dist, 'package.json')).gitHead !== revision) throw new Error('Build revision does not match HEAD. Rebuild with MCODE_RELEASE_TAG.'); + const temporary = mkdtempSync(path.join(tmpdir(), 'mcode-release-')); + try { + const stage = path.join(temporary, 'package'); + cpSync(dist, stage, { recursive: true, filter: file => path.basename(file) !== 'metafile.json' }); + const manifest = releaseManifest(readExtraction(root).packageRoots.map(directory => path.join(root, directory)), version); + writeFileSync(path.join(stage, 'package.json'), JSON.stringify(manifest, null, 2) + '\n'); + copyLicenses(stage); + writeFileSync(path.join(stage, 'release.json'), JSON.stringify({ version, tag, revision, buildNode: process.version }, null, 2) + '\n'); + writeFileSync(path.join(stage, 'README.md'), `# MiniMax Code ${version} + +Built from https://github.com/MiniMax-AI/minimax-code/tree/${revision}. +Install this tar.gz with npm. Node.js must satisfy the package engines requirement. +Keep optional dependencies enabled and allow better-sqlite3 installation scripts. +Update by installing a newer GitHub release archive; the built-in updater follows npm. +The archive uses the same package name, mcode command and user data as the official npm CLI. +See https://github.com/MiniMax-AI/minimax-code/blob/${revision}/docs/installation.md. +`); + mkdirSync(out, { recursive: true }); + const archive = path.join(out, `minimax-code-${version}.tar.gz`); + if (existsSync(archive) || existsSync(`${archive}.sha256`)) throw new Error(`Output already exists: ${archive}`); + await createTar({ file: archive, gzip: true, cwd: temporary, portable: true }, ['package']); + writeFileSync(`${archive}.sha256`, `${digest(readFileSync(archive))} ${path.basename(archive)}\n`, { flag: 'wx' }); + console.log(archive); + return archive; + } finally { + rmSync(temporary, { recursive: true, force: true }); + } +} + +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + const [tag, out] = process.argv.slice(2); + if (!tag || !out) throw new Error('Usage: node scripts/package-cli-release.mjs vX.Y.Z /path/to/output'); + await packageCliRelease({ tag, out: path.resolve(out) }); +} diff --git a/scripts/perf/config.json b/scripts/perf/config.json new file mode 100644 index 000000000..7fa2192e7 --- /dev/null +++ b/scripts/perf/config.json @@ -0,0 +1,51 @@ +{ + "benchmarkRepository": "https://github.com/KonghaYao/harness-perf-benchmark", + "benchmarkRevision": "8ad1e09f7653d7e97b83b4f5b4d347ca2fa129c7", + "warmups": 1, + "repetitions": 3, + "intervalMs": 100, + "maxSpread": 0.3, + "suites": { + "basic": ["upstream-100"], + "full": ["startup", "upstream-100", "history-300"] + }, + "thresholds": { + "durationMs": { + "relative": 0.25, + "absolute": 1000 + }, + "cpuSeconds": { + "relative": 0.2, + "absolute": 0.5 + }, + "rssBytes": { + "relative": 0.2, + "absolute": 33554432 + } + }, + "scenarios": [ + { + "id": "startup", + "turns": 1, + "bodyKb": 0, + "chunkSize": 64, + "timeoutMs": 60000 + }, + { + "id": "upstream-100", + "turns": 100, + "bodyKb": 4, + "chunkSize": 64, + "timeoutMs": 180000 + }, + { + "id": "history-300", + "turns": 300, + "bodyKb": 8, + "chunkSize": 64, + "timeoutMs": 360000, + "contextWindow": 2000000, + "minimumTextUnits": 1048577 + } + ] +} diff --git a/scripts/perf/driver.ts b/scripts/perf/driver.ts new file mode 100644 index 000000000..06846f62f --- /dev/null +++ b/scripts/perf/driver.ts @@ -0,0 +1,81 @@ +// Run under Bun. Reuse the pinned upstream generator, mock and sampler unchanged. +import { readFileSync, mkdirSync, writeFileSync, readdirSync, appendFileSync } from 'node:fs'; +import { join, resolve } from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { validateRun, validateRequest } from './report.mjs'; + +const input = JSON.parse(readFileSync(process.argv[2]!, 'utf8')); +const { runPerf } = await import(pathToFileURL(join(input.benchmark, 'scripts/perf/run.ts')).href); +const { loadPerfConfig } = await import(pathToFileURL(join(input.benchmark, 'scripts/perf/config.ts')).href); +const workspace = join(input.directory, 'workspace'); +const data = join(input.directory, 'data'); +mkdirSync(workspace, { recursive: true }); +mkdirSync(data, { recursive: true }); +writeFileSync(join(workspace, 'README.txt'), 'Synthetic performance fixture.\n'); +const server = Bun.serve({ port: 0, hostname: '127.0.0.1', fetch: () => new Response('') }); +const port = server.port; +server.stop(true); +const scenario = JSON.parse(readFileSync(input.script, 'utf8')); +let requests = 0; +let auditFailure: string | undefined; +const proxy = Bun.serve({ port: 0, hostname: '127.0.0.1', async fetch(request) { + const url = new URL(request.url); + const bytes = await request.arrayBuffer(); + if (url.pathname === '/v1/chat/completions' && request.method === 'POST') { + requests++; + const start = performance.now(); + let error: string | undefined; + try { validateRequest(JSON.parse(new TextDecoder().decode(bytes)), scenario.responses, requests, workspace, cfg.prompt); } + catch (failure) { error = String(failure); auditFailure ??= error; } + appendFileSync(join(input.directory, 'requests.jsonl'), JSON.stringify({ request: requests, + bytes: bytes.byteLength, validationMs: performance.now() - start, ok: !error, ...(error ? { error } : {}) }) + '\n'); + } + return fetch(`http://127.0.0.1:${port}${url.pathname}${url.search}`, { + method: request.method, headers: { 'content-type': request.headers.get('content-type') ?? 'application/json' }, + ...(request.method === 'GET' || request.method === 'HEAD' ? {} : { body: bytes }), + }); +} }); +writeFileSync(join(data, 'config.yaml'), [ + 'logLevel: info', 'custom_provider:', ' llm-mock:', ' name: llm-mock', + ' kind: custom', ' enabled: true', ' api: openai-completions', + ' options:', ' apiKey: mock-key', ` baseURL: http://127.0.0.1:${proxy.port}/v1`, + ' authMode: api-key', ' models:', ' llm-mock:', ' reasoning: true', + ' thinking_config:', ' mode: switchable', " default_value: 'true'", + ...(input.scenario.contextWindow ? [' limit:', ` context: ${input.scenario.contextWindow}`, ' output: 16384'] : []), '', +].join('\n')); +const cfg = loadPerfConfig([ + '--script', input.script, '--exhausted', 'stop', '--work-dir', workspace, + '--out-dir', join(input.directory, 'raw'), '--harness', 'minimax-code', + '--port', String(port), '--interval-ms', String(input.intervalMs), + '--timeout-ms', String(input.scenario.timeoutMs), '--sampler', 'rusage', + '--prompt', '压测:请持续用只读命令检查当前目录状态', +]); +let code: number; +try { code = await runPerf(cfg, { + harnessCommand: () => [input.node, + ...(input.profile ? ['--cpu-prof', `--cpu-prof-dir=${input.directory}`] : []), + '--import', pathToFileURL(join(input.control, 'test/network-deny.mjs')).href, + join(input.build, 'dist/cli.js'), 'exec', '--permission', 'off', '--cwd', workspace, + '--model', 'custom_provider:llm-mock/llm-mock', cfg.prompt], + harnessEnv: () => ({ MINIMAX_DATA_DIR: data, PI_TELEMETRY: '0', + MCODE_TEST_ALLOWED_ORIGIN: `http://127.0.0.1:${proxy.port}`, + MCODE_TEST_NETWORK_AUDIT: join(input.directory, 'network.log'), MCODE_TEST_MANAGED_OFFLINE: '1', + }), +}); } finally { proxy.stop(true); } +if (code !== 0) throw new Error(`Upstream runner failed: ${code}`); +if (auditFailure) throw new Error(`Request history validation failed: ${auditFailure}`); +if (requests !== input.scenario.turns + 1) throw new Error('Request audit count mismatch'); +function files(directory: string): string[] { + return readdirSync(directory, { withFileTypes: true }).flatMap(entry => { + const name = join(directory, entry.name); + return entry.isDirectory() ? files(name) : [name]; + }); +} +const metas = files(join(input.directory, 'raw')).filter(p => p.endsWith('/run.json')); +if (metas.length !== 1) throw new Error('Expected exactly one upstream run'); +const histories = files(data).filter(p => p.endsWith('/messages.jsonl')); +if (histories.length !== 1) throw new Error('Expected exactly one canonical session history'); +const meta = JSON.parse(readFileSync(metas[0]!, 'utf8')); +const messages = readFileSync(histories[0]!, 'utf8').trim().split('\n').map(line => JSON.parse(line).message); +const metrics = validateRun(meta, messages, input.scenario, scenario.responses[input.scenario.turns].message.content, scenario.responses, workspace); +writeFileSync(join(input.directory, 'result.json'), JSON.stringify({ metrics, meta: resolve(metas[0]!), history: resolve(histories[0]!) }, null, 2)); diff --git a/scripts/perf/report.mjs b/scripts/perf/report.mjs new file mode 100644 index 000000000..9c3a43510 --- /dev/null +++ b/scripts/perf/report.mjs @@ -0,0 +1,145 @@ +import assert from 'node:assert/strict'; +import { realpathSync } from 'node:fs'; + +const textContent = content => typeof content === 'string' ? content : Array.isArray(content) + ? content.filter(b => b.type === 'text').map(b => b.text).join('') : ''; + +export function selectScenarios(config, { suite = 'basic', scenario } = {}) { + assert.ok(Object.hasOwn(config.suites, suite), 'Unknown performance suite'); + const ids = scenario ? [scenario] : config.suites[suite]; + assert.ok(ids.length > 0 && new Set(ids).size === ids.length, 'Invalid scenario selection'); + return ids.map(id => { + const selected = config.scenarios.find(item => item.id === id); + assert.ok(selected, `Unknown scenario: ${id}`); + return selected; + }); +} + +export function validateToolOutput(content, command, workspace) { + const text = textContent(content).trim(); + if (command.startsWith('echo ')) assert.equal(text, command.slice(5), 'Echo result changed'); + else if (command === 'pwd') assert.equal(text, realpathSync(workspace), 'Working directory result changed'); + else if (command === 'ls' || command === 'ls -la') assert.match(text, /(?:^|\s)README\.txt(?:\s|$)/, 'Directory listing lost fixture'); + else throw new Error('Unknown benchmark command'); +} + +export function validateRequest(body, responses, requestNumber, workspace, prompt) { + const rounds = requestNumber - 1; + assert.ok(rounds >= 0 && rounds < responses.length - 1, 'Unexpected request'); + assert.ok(Array.isArray(body.messages), 'Request messages missing'); + assert.ok(typeof prompt === 'string' && prompt.length > 0, 'Expected task missing'); + const conversation = body.messages.filter(m => m.role !== 'system' && m.role !== 'developer'); + assert.equal(conversation.length, 1 + rounds * 2, 'Request conversation length changed'); + assert.equal(conversation[0]?.role, 'user', 'Initial user task missing'); + assert.ok(textContent(conversation[0].content).includes(prompt), 'Initial user task changed'); + for (let i = 0; i < rounds; i++) { + assert.equal(conversation[i * 2 + 1].role, 'assistant', 'Request conversation order changed'); + assert.equal(conversation[i * 2 + 2].role, 'tool', 'Request conversation order changed'); + } + const assistant = body.messages.filter(m => m.role === 'assistant'); + const tools = body.messages.filter(m => m.role === 'tool'); + assert.equal(assistant.length, rounds, 'Request lost assistant history'); + assert.equal(tools.length, rounds, 'Request lost tool history'); + for (let i = 0; i < rounds; i++) { + const expected = responses[i].message; + assert.equal(textContent(assistant[i].content), expected.content, 'Request history body changed'); + assert.equal(assistant[i].tool_calls?.length, 1, 'Request tool calls changed'); + const call = assistant[i].tool_calls[0]; + assert.equal(call.id, expected.tool_calls[0].id, 'Request call identity changed'); + assert.equal(call.function.name, 'bash', 'Request tool changed'); + const args = typeof call.function.arguments === 'string' ? JSON.parse(call.function.arguments) : call.function.arguments; + assert.deepEqual(args, expected.tool_calls[0].function.arguments, 'Request command changed'); + assert.equal(tools[i].tool_call_id, call.id, 'Request tool result identity changed'); + validateToolOutput(tools[i].content, args.command, workspace); + } +} + +export function median(values) { + assert.ok(values.length > 0 && values.every(Number.isFinite), 'Nonempty finite samples required'); + const sorted = [...values].sort((a, b) => a - b); + const middle = Math.floor(sorted.length / 2); + return sorted.length % 2 ? sorted[middle] : (sorted[middle - 1] + sorted[middle]) / 2; +} + +export function validateRun(meta, messages, scenario, expectedFinal, expectedResponses, workspace) { + assert.equal(meta.schemaVersion, 1, 'Unknown upstream run schema'); + assert.equal(meta.status, 'ok', 'Benchmark did not complete'); + assert.equal(meta.exit?.code, 0, 'CLI failed'); + assert.equal(meta.mock?.requests, scenario.turns + 1, 'Unexpected model request count'); + assert.equal(meta.sampling?.backend, 'rusage', 'macOS rusage sampler is required'); + assert.equal(meta.sampling?.withTree, true, 'Process-tree sampling is required'); + assert.ok(meta.summary?.count >= 3, 'Insufficient samples'); + const calls = messages.flatMap(m => m.role === 'assistant' ? m.content.filter(b => b.type === 'toolCall') : []); + const assistant = messages.filter(m => m.role === 'assistant'); + const results = messages.filter(m => m.role === 'toolResult'); + assert.equal(calls.length, scenario.turns, 'Missing or extra tool calls'); + assert.equal(results.length, scenario.turns, 'Missing or extra tool results'); + const ids = new Set(); + for (let i = 0; i < scenario.turns; i++) { + assert.equal(calls[i].id, `call_long_run_${i + 1}`, 'Unexpected tool sequence'); + assert.equal(calls[i].name, 'bash', 'Unexpected tool'); + if (expectedResponses) { + assert.deepEqual(calls[i].arguments, expectedResponses[i].message.tool_calls[0].function.arguments, 'Tool command changed'); + assert.equal(assistant[i].content.filter(b => b.type === 'text').map(b => b.text).join(''), expectedResponses[i].message.content, 'History body changed'); + } + assert.equal(results[i].toolCallId, calls[i].id, 'Tool result does not match call'); + assert.equal(results[i].isError, false, 'Tool execution failed'); + if (expectedResponses && workspace) validateToolOutput(results[i].content, calls[i].arguments.command, workspace); + assert.ok(!ids.has(results[i].toolCallId), 'Duplicate tool result'); + ids.add(results[i].toolCallId); + } + const last = messages.filter(m => m.role === 'assistant').at(-1); + const final = last?.content.filter(b => b.type === 'text').map(b => b.text).join(''); + assert.equal(final, expectedFinal, 'Final response changed'); + const metrics = { + durationMs: meta.duration?.endToEndMs, + cpuSeconds: meta.cost?.cpuSeconds, + rssBytes: meta.peaks?.treeRssBytes, + }; + for (const [name, value] of Object.entries(metrics)) { + assert.ok(Number.isFinite(value) && value > 0, `Invalid ${name}`); + } + return metrics; +} + +export function compareRuns(base, head, config) { + assert.equal(base.length, config.repetitions, 'Incomplete baseline repetitions'); + assert.equal(head.length, config.repetitions, 'Incomplete candidate repetitions'); + return Object.entries(config.thresholds).map(([metric, threshold]) => { + const before = base.map(r => r[metric]); + const after = head.map(r => r[metric]); + assert.ok([...before, ...after].every(v => Number.isFinite(v) && v > 0), `Invalid ${metric}`); + const baseline = median(before), candidate = median(after); + const spread = values => (Math.max(...values) - Math.min(...values)) / median(values); + const unstable = spread(before) > config.maxSpread || spread(after) > config.maxSpread; + const delta = candidate - baseline; + const regression = delta > Math.max(baseline * threshold.relative, threshold.absolute) + && after.every((v, i) => v > before[i]); + return { metric, baseline, candidate, change: delta / baseline, + baseSpread: spread(before), headSpread: spread(after), + status: unstable ? 'INCONCLUSIVE' : regression ? 'REGRESSION' : 'PASS' }; + }); +} + +export function renderReport(report) { + const lines = ['# Performance comparison', '', + `Status: **${report.status}**`, '', + '| Item | Value |', '| --- | --- |', + `| Base | \`${report.baseRevision}\` |`, `| Candidate | \`${report.headRevision}\` |`, + `| Benchmark | [${report.benchmarkRevision}](${report.benchmarkRepository}/tree/${report.benchmarkRevision}) |`, + `| Machine | ${report.host.cpuModel}; ${report.host.cpus} CPUs; ${Math.round(report.host.totalMemBytes / 2 ** 30)} GiB; ${report.host.platform} ${report.host.osRelease}; ${report.host.arch} |`, + `| Runtime | Node ${report.nodeVersion}; Bun ${report.bunVersion} |`, + `| Suite | ${report.suite}; ${report.selectedScenarios.join(', ')} |`, + `| Method | One warmup per revision/scenario; three alternating serial pairs; 100 ms process-tree rusage sampling |`, + '', '| Scenario | Metric | Base median | Candidate median | Change | Result |', + '| --- | --- | ---: | ---: | ---: | --- |']; + for (const scenario of report.results) for (const row of scenario.comparison ?? []) { + const scale = row.metric === 'rssBytes' ? 2 ** 20 : row.metric === 'durationMs' ? 1000 : 1; + const unit = row.metric === 'rssBytes' ? 'MiB' : row.metric === 'durationMs' ? 's' : 'core-s'; + lines.push(`| ${scenario.id} | ${row.metric} | ${(row.baseline / scale).toFixed(2)} ${unit} | ${(row.candidate / scale).toFixed(2)} ${unit} | ${(row.change * 100).toFixed(1)}% | ${row.status} |`); + } + if (report.error) lines.push('', `Failure: ${String(report.error).replaceAll('\n', ' ').replaceAll('`', "'")}`); + lines.push('', 'Raw run.json, samples.csv, CLI/mock logs, synthetic histories and any diagnostic CPU profile are attached. Warmups and diagnostic runs are excluded from comparisons.', + '', 'This is a mock throughput check, not live-model acceptance. RSS is sampled. INCONCLUSIVE means excessive spread and fails the check; do not treat it as evidence of a regression or a pass.'); + return lines.join('\n') + '\n'; +} diff --git a/scripts/perf/run.mjs b/scripts/perf/run.mjs new file mode 100644 index 000000000..72d1529bd --- /dev/null +++ b/scripts/perf/run.mjs @@ -0,0 +1,110 @@ +import { execFileSync, spawnSync } from 'node:child_process'; +import { readFileSync, writeFileSync, mkdirSync, appendFileSync, existsSync } from 'node:fs'; +import { cpus, totalmem, platform, release, arch } from 'node:os'; +import { resolve, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { parseArgs } from 'node:util'; +import { createHash } from 'node:crypto'; +import { compareRuns, renderReport, selectScenarios } from './report.mjs'; + +const control = fileURLToPath(new URL('../../', import.meta.url)); +const config = JSON.parse(readFileSync(new URL('./config.json', import.meta.url), 'utf8')); +const { values } = parseArgs({ options: { + base: { type: 'string' }, head: { type: 'string' }, benchmark: { type: 'string' }, + out: { type: 'string' }, scenario: { type: 'string' }, + suite: { type: 'string', default: 'basic' }, +}, strict: true }); +for (const name of ['base', 'head', 'benchmark', 'out']) { + if (!values[name]) throw new Error(`Missing --${name}`); +} +const base = resolve(values.base), head = resolve(values.head); +const benchmark = resolve(values.benchmark), out = resolve(values.out); +if (existsSync(out)) throw new Error('Output directory must be new; old results cannot be reused'); +if (platform() !== 'darwin') throw new Error('This benchmark requires macOS process-tree rusage'); +const git = (directory, ...args) => execFileSync('git', ['-C', directory, ...args], { encoding: 'utf8' }).trim(); +if (git(benchmark, 'rev-parse', 'HEAD') !== config.benchmarkRevision) throw new Error('Wrong benchmark revision'); +if (git(benchmark, 'status', '--porcelain', '--untracked-files=no')) throw new Error('Benchmark has tracked edits'); +const bun = execFileSync('which', ['bun'], { encoding: 'utf8' }).trim(); +const scenarios = selectScenarios(config, values); +mkdirSync(out, { recursive: true }); +const report = { + status: 'RUNNING', baseRevision: git(base, 'rev-parse', 'HEAD'), headRevision: git(head, 'rev-parse', 'HEAD'), + benchmarkRepository: config.benchmarkRepository, benchmarkRevision: config.benchmarkRevision, + nodeVersion: process.version, bunVersion: execFileSync(bun, ['--version'], { encoding: 'utf8' }).trim(), + host: { cpuModel: cpus()[0]?.model, cpus: cpus().length, totalMemBytes: totalmem(), platform: platform(), osRelease: release(), arch: arch() }, + config, suite: values.scenario ? 'focused' : values.suite, selectedScenarios: scenarios.map(s => s.id), results: [], + builds: Object.fromEntries([['base', base], ['head', head]].map(([name, directory]) => [name, { + path: directory, entrySha256: createHash('sha256').update(readFileSync(join(directory, 'dist/cli.js'))).digest('hex'), + }])), +}; +function save() { + writeFileSync(join(out, 'comparison.json'), JSON.stringify(report, null, 2) + '\n'); + writeFileSync(join(out, 'comparison.md'), renderReport(report)); +} +// Children receive synthetic state and an explicit environment, never local provider credentials. +const environment = { PATH: process.env.PATH, HOME: join(out, 'home'), TMPDIR: process.env.TMPDIR ?? '/tmp', + LANG: 'en_US.UTF-8', TERM: 'dumb', CI: '1', NO_COLOR: '1' }; +mkdirSync(environment.HOME, { recursive: true }); +function run(build, scenario, script, name, profile = false) { + const directory = join(out, scenario.id, name); + mkdirSync(directory, { recursive: true }); + const input = { benchmark, control, build, script, scenario, directory, profile, + intervalMs: config.intervalMs, node: process.execPath }; + const inputPath = join(directory, 'input.json'); + writeFileSync(inputPath, JSON.stringify(input)); + const result = spawnSync(bun, [join(control, 'scripts/perf/driver.ts'), inputPath], { + cwd: benchmark, env: environment, encoding: 'utf8', timeout: scenario.timeoutMs + 45000, maxBuffer: 16 * 1024 * 1024, + }); + writeFileSync(join(directory, 'driver.log'), (result.stdout ?? '') + (result.stderr ?? '')); + if (result.error || result.status !== 0) throw new Error(`${scenario.id}/${name} failed; see driver.log (${result.error?.message ?? result.status})`); + return { ...JSON.parse(readFileSync(join(directory, 'result.json'), 'utf8')), directory }; +} +save(); +try { + for (const scenario of scenarios) { + const script = join(out, `${scenario.id}.json`); + execFileSync(bun, [join(benchmark, 'scripts/perf/gen-long-run.ts'), '--turns', String(scenario.turns), + '--body-kb', String(scenario.bodyKb), '--tool', 'bash', '--chunk-size', String(scenario.chunkSize), + '--chunk-delay-ms', '0', '--out', script], { cwd: benchmark, env: environment }); + const scriptData = JSON.parse(readFileSync(script, 'utf8')); + const textUnits = scriptData.responses.slice(0, scenario.turns).reduce((sum, entry) => sum + entry.message.content.length, 0); + if (scenario.minimumTextUnits && textUnits < scenario.minimumTextUnits) throw new Error('Long-history fixture no longer exceeds token cache capacity'); + const result = { id: scenario.id, textUnits, scenarioSha256: createHash('sha256').update(readFileSync(script)).digest('hex'), warmups: [], pairs: [] }; + report.results.push(result); + save(); + for (let i = 0; i < config.warmups; i++) for (const [side, directory] of [['base', base], ['head', head]]) { + console.log(`[perf] ${scenario.id}: ${side} warmup`); + result.warmups.push({ side, ...run(directory, scenario, script, `${side}-warmup-${i}`) }); + save(); + } + for (let pair = 0; pair < config.repetitions; pair++) { + const item = { pair, order: pair % 2 ? ['head', 'base'] : ['base', 'head'] }; + result.pairs.push(item); + for (const side of item.order) { + console.log(`[perf] ${scenario.id}: pair ${pair + 1}, ${side}`); + item[side] = run(side === 'base' ? base : head, scenario, script, `${side}-${pair}`); + save(); + } + } + result.comparison = compareRuns(result.pairs.map(p => p.base.metrics), result.pairs.map(p => p.head.metrics), config); + save(); + if (result.comparison.some(row => row.status === 'REGRESSION')) { + console.log(`[perf] ${scenario.id}: separate diagnostic CPU profile`); + try { result.diagnostic = run(head, scenario, script, 'head-profile', true); } + catch (error) { result.diagnosticError = error.message; } + save(); + } + } + const statuses = report.results.flatMap(r => r.comparison.map(row => row.status)); + report.status = statuses.includes('REGRESSION') ? 'REGRESSION' : statuses.includes('INCONCLUSIVE') ? 'INCONCLUSIVE' : 'PASS'; + process.exitCode = report.status === 'PASS' ? 0 : report.status === 'REGRESSION' ? 1 : 2; +} catch (error) { + report.status = 'ERROR'; + report.error = error.message; + process.exitCode = 1; +} finally { + save(); + const markdown = readFileSync(join(out, 'comparison.md'), 'utf8'); + console.log(markdown); + if (process.env.GITHUB_STEP_SUMMARY) appendFileSync(process.env.GITHUB_STEP_SUMMARY, markdown); +} diff --git a/scripts/publish-cli-release.mjs b/scripts/publish-cli-release.mjs new file mode 100644 index 000000000..dec60d46b --- /dev/null +++ b/scripts/publish-cli-release.mjs @@ -0,0 +1,57 @@ +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { readFileSync, writeFileSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { versionFromTag, cliReleaseTargets } from './lib/cli-release.mjs'; + +export function validateReleaseReports({ archive, reports, version, revision }) { + const sha256 = createHash('sha256').update(readFileSync(archive)).digest('hex'); + assert.equal(readFileSync(`${archive}.sha256`, 'utf8'), `${sha256} ${path.basename(archive)}\n`); + for (const target of cliReleaseTargets) { + const directory = path.join(reports, `cli-install-${target.os}-${target.node}`); + const installation = JSON.parse(readFileSync(path.join(directory, 'package-install.json'), 'utf8')); + const verification = JSON.parse(readFileSync(path.join(directory, 'verification.json'), 'utf8')); + assert.equal(verification.status, 'PASS'); + assert.equal(verification.profile, 'package'); + assert.equal(verification.revision, revision); + assert.equal(verification.gates.find(gate => gate.name === 'test:release-package')?.status, 'PASS'); + assert.equal(installation.status, 'PASS'); + assert.equal(installation.version, version); + assert.equal(installation.revision, revision); + assert.equal(installation.sha256, sha256); + assert.equal(installation.platform, target.os.startsWith('ubuntu') ? 'linux' : 'darwin'); + assert.ok(installation.node === `v${target.node}` || installation.node.startsWith(`v${target.node}.`)); + } + return sha256; +} + +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + const tag = process.env.MCODE_RELEASE_TAG; + const version = versionFromTag(tag); + const revision = execFileSync('git', ['rev-parse', 'HEAD'], { encoding: 'utf8' }).trim(); + if (!process.env.MCODE_RELEASE_DIRECTORY || !process.env.MCODE_RELEASE_REPORTS) throw new Error('Release directory and reports are required.'); + const archive = path.join(process.env.MCODE_RELEASE_DIRECTORY, `minimax-code-${version}.tar.gz`); + const sha256 = validateReleaseReports({ archive, reports: process.env.MCODE_RELEASE_REPORTS, version, revision }); + const notes = path.join(process.env.MCODE_RELEASE_DIRECTORY, 'release-notes.md'); + writeFileSync(notes, `Built from public source commit ${revision}. SHA-256: \`${sha256}\`. + +Download the tar.gz and its checksum, verify the checksum, then install: + +\`\`\`sh +npm install --global ./minimax-code-${version}.tar.gz --registry=https://registry.npmjs.org/ --include=optional --ignore-scripts=false --allow-scripts=better-sqlite3 +mcode --version +\`\`\` + +Requires Node.js 22.19+ (22.x), 24.2+ (24.x), 25 or 26 and network access to public npm for runtime dependencies. Native dependencies may require a C/C++ toolchain and Python when no prebuilt binary is available. + +The same archive passed npm installation and offline CLI/BYOK tests on Linux and macOS across the supported Node lines. Windows and live-service acceptance were not run. This package shares the official npm CLI's package name and user data. Install future GitHub archives explicitly; the built-in updater follows the npm registry channel. +`); + // An existing release is never overwritten. Upload to a draft so failures + // cannot expose a release with missing assets; maintainers can inspect/retry. + const gh = (...args) => execFileSync('gh', args, { stdio: 'inherit' }); + gh('release', 'create', tag, '--verify-tag', '--draft', '--title', `MiniMax Code ${version}`, '--notes-file', notes, ...(version.includes('-') ? ['--prerelease'] : [])); + gh('release', 'upload', tag, archive, `${archive}.sha256`); + gh('release', 'edit', tag, '--draft=false'); +} diff --git a/scripts/release-cli.mjs b/scripts/release-cli.mjs new file mode 100644 index 000000000..ce7efbe2e --- /dev/null +++ b/scripts/release-cli.mjs @@ -0,0 +1,99 @@ +import { execFileSync } from 'node:child_process'; +import { readFileSync, writeFileSync, mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { parseArgs } from 'node:util'; +import { cliBuildVersion, versionFromTag } from './lib/cli-release.mjs'; + +const versionFiles = ['package.json', 'packages/tui/package.json']; + +export function compareVersions(left, right) { + versionFromTag(`v${left}`); versionFromTag(`v${right}`); + const parse = version => { + const index = version.indexOf('-'); + return { core: (index < 0 ? version : version.slice(0, index)).split('.').map(BigInt), + pre: index < 0 ? undefined : version.slice(index + 1).split('.') }; + }; + const a = parse(left), b = parse(right); + const compare = (x, y) => x === y ? 0 : x > y ? 1 : -1; + for (let i = 0; i < 3; i++) if (a.core[i] !== b.core[i]) return compare(a.core[i], b.core[i]); + if (!a.pre || !b.pre) return compare(a.pre ? 0 : 1, b.pre ? 0 : 1); + for (let i = 0; i < Math.max(a.pre.length, b.pre.length); i++) { + const x = a.pre[i], y = b.pre[i]; + if (x === y) continue; + if (x === undefined || y === undefined) return x === undefined ? -1 : 1; + const nx = /^[0-9]+$/.test(x), ny = /^[0-9]+$/.test(y); + return nx && ny ? compare(BigInt(x), BigInt(y)) : nx !== ny ? (nx ? -1 : 1) : compare(x, y); + } + return 0; +} + +function githubCli(root) { + for (const command of ['gh-axi', 'gh']) { + try { + execFileSync(command, ['--help'], { cwd: root, stdio: 'ignore' }); + return command; + } catch { /* Try the standard GitHub CLI when the wrapper is unavailable. */ } + } + throw new Error('Install and authenticate gh (or gh-axi) to create the version PR.'); +} + +function createVersionPullRequest({ root, branch, version, tag }) { + const temporary = mkdtempSync(path.join(tmpdir(), 'mcode-version-pr-')); + try { + const body = path.join(temporary, 'body.md'); + writeFileSync(body, `Update the root and TUI source versions to ${version}.\n\nTag \`${tag}\` points to this version commit. The tag-triggered CLI release workflow builds and validates the npm installation archive. Merge this PR to carry the released source version back to main; do not move or recreate the release tag.\n`); + execFileSync(githubCli(root), ['pr', 'create', '--base', 'main', '--head', branch, + '--title', `chore: release MiniMax Code ${version}`, '--body-file', body], { cwd: root, stdio: 'inherit' }); + } finally { rmSync(temporary, { recursive: true, force: true }); } +} + +export function releaseCli({ root, version, dryRun = false, openPullRequest = createVersionPullRequest }) { + const tag = `v${version}`; + versionFromTag(tag); + const branch = `release/${tag}`; + const git = (...args) => execFileSync('git', args, { cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim(); + if (git('status', '--porcelain')) throw new Error('Release requires a clean working tree, including untracked files.'); + const current = cliBuildVersion(root, null); + if (compareVersions(version, current) <= 0) throw new Error(`Release version must be newer than ${current}.`); + git('fetch', '--no-tags', 'origin', 'main'); + const base = git('rev-parse', 'HEAD'); + if (base !== git('rev-parse', 'refs/remotes/origin/main')) throw new Error('Start the release from the latest origin/main commit.'); + for (const ref of [`refs/tags/${tag}`, `refs/heads/${branch}`]) { + let exists = false; + try { git('show-ref', '--verify', '--quiet', ref); exists = true; } catch (error) { if (error.status !== 1) throw error; } + if (exists) throw new Error(`Release ref already exists locally: ${ref}`); + } + if (git('ls-remote', 'origin', `refs/tags/${tag}`, `refs/heads/${branch}`)) throw new Error('Release tag or branch already exists on origin.'); + const plan = { current, version, tag, branch, base, files: versionFiles }; + console.log(JSON.stringify({ ...plan, dryRun }, null, 2)); + if (dryRun) return plan; + // Fail before making local commits if the required PR client is unavailable. + if (openPullRequest === createVersionPullRequest) + execFileSync(githubCli(root), ['api', 'user', '--jq', '.login'], { cwd: root, stdio: 'ignore' }); + git('switch', '-c', branch); + for (const name of versionFiles) { + const file = path.join(root, name); + const manifest = JSON.parse(readFileSync(file, 'utf8')); + manifest.version = version; + writeFileSync(file, JSON.stringify(manifest, null, 2) + '\n'); + } + git('add', '--', ...versionFiles); + git('commit', '-m', `chore: release MiniMax Code ${version}`); + if (git('status', '--porcelain')) throw new Error('Working tree changed during the version commit; inspect it before tagging.'); + if (git('diff', '--name-only', base, 'HEAD') !== [...versionFiles].sort().join('\n')) throw new Error('Version commit must change only the two package manifests.'); + cliBuildVersion(root, tag); + git('tag', '-a', tag, '-m', `MiniMax Code ${version}`); + // Push both refs or neither. Never force an existing tag or update main. + git('push', '--atomic', '--set-upstream', 'origin', `refs/heads/${branch}:refs/heads/${branch}`, `refs/tags/${tag}:refs/tags/${tag}`); + console.log(`Pushed ${branch} and ${tag}; CI will build and publish the archive after validation.`); + openPullRequest({ root, branch, version, tag }); + return { ...plan, revision: git('rev-parse', 'HEAD') }; +} + +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + const { values } = parseArgs({ options: { version: { type: 'string' }, 'dry-run': { type: 'boolean', default: false } } }); + if (!values.version) throw new Error('Usage: pnpm release:cli --version X.Y.Z [--dry-run]'); + releaseCli({ root: fileURLToPath(new URL('../', import.meta.url)), version: values.version, dryRun: values['dry-run'] }); +} diff --git a/scripts/source-candidate.mjs b/scripts/source-candidate.mjs index 6635694be..18cd8a172 100644 --- a/scripts/source-candidate.mjs +++ b/scripts/source-candidate.mjs @@ -53,12 +53,14 @@ if (command === "unpack") { ), ), ); + // Windows validation is temporarily paused in the candidate workflow. + const requiredPlatforms = ["linux", "darwin"]; if ( - reports.length !== 3 || - new Set(reports.map((r) => r.platform)).size !== 3 || + reports.length !== requiredPlatforms.length || + new Set(reports.map((r) => r.platform)).size !== requiredPlatforms.length || reports.some( (r) => - !["linux", "darwin", "win32"].includes(r.platform) || + !requiredPlatforms.includes(r.platform) || r.status !== "PASS" || r.profile !== "archive" || r.revision !== receipt.revision || @@ -67,7 +69,7 @@ if (command === "unpack") { ) ) throw new Error( - "Candidate requires successful same-revision archive reports from all three platforms", + "Candidate requires successful same-revision archive reports from Linux and macOS", ); const manifest = { schemaVersion: 1, @@ -88,6 +90,6 @@ if (command === "unpack") { ); writeFileSync(`${archive}.sha256`, `${digest} ${path.basename(archive)}\n`); console.log( - `Source candidate verified on all three platforms: ${receipt.revision}`, + `Source candidate verified on Linux and macOS: ${receipt.revision}`, ); } diff --git a/scripts/verify-cli-release.mjs b/scripts/verify-cli-release.mjs new file mode 100644 index 000000000..09f64753d --- /dev/null +++ b/scripts/verify-cli-release.mjs @@ -0,0 +1,68 @@ +import assert from 'node:assert/strict'; +import { createHash } from 'node:crypto'; +import { createRequire } from 'node:module'; +import { execFileSync } from 'node:child_process'; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { versionFromTag } from './lib/cli-release.mjs'; + +const root = fileURLToPath(new URL('../', import.meta.url)); +const version = versionFromTag(process.env.MCODE_RELEASE_TAG); +if (!process.env.MCODE_RELEASE_ARCHIVE) throw new Error('MCODE_RELEASE_ARCHIVE is required.'); +if (!['linux', 'darwin'].includes(process.platform)) throw new Error('Package validation currently supports Linux and macOS.'); +const archive = path.resolve(process.env.MCODE_RELEASE_ARCHIVE); +const sha256 = createHash('sha256').update(readFileSync(archive)).digest('hex'); +assert.equal(readFileSync(`${archive}.sha256`, 'utf8'), `${sha256} ${path.basename(archive)}\n`, 'Release archive checksum mismatch'); +const revision = execFileSync('git', ['rev-parse', 'HEAD'], { cwd: root, encoding: 'utf8' }).trim(); +const temporary = mkdtempSync(path.join(tmpdir(), 'mcode-package-install-')); +try { + // No global install or real user profile is modified. Dependencies and the + // generated npm launcher must resolve from this fresh installation alone. + const prefix = path.join(temporary, 'install'); + const home = path.join(temporary, 'home'); + mkdirSync(home); + const env = { + ...process.env, HOME: home, USERPROFILE: home, + MINIMAX_DATA_DIR: path.join(home, 'data'), MAVIS_DATA_DIR: path.join(home, 'data'), + MCODE_DISABLE_TELEMETRY: '1', + }; + for (const name of Object.keys(env)) { + if (/^npm_config_/i.test(name)) delete env[name]; + } + Object.assign(env, { + npm_config_cache: path.join(temporary, 'npm-cache'), + npm_config_userconfig: path.join(home, '.npmrc'), + }); + delete env.NODE_PATH; + delete env.NODE_OPTIONS; + execFileSync('npm', ['install', '--global', '--prefix', prefix, + '--registry=https://registry.npmjs.org/', '--include=optional', '--ignore-scripts=false', + '--allow-scripts=better-sqlite3', '--no-audit', '--no-fund', archive], + { cwd: home, env, stdio: 'inherit', timeout: 300000 }); + const installed = path.join(prefix, 'lib/node_modules/@minimax-ai/code'); + const release = JSON.parse(readFileSync(path.join(installed, 'release.json'), 'utf8')); + assert.equal(release.version, version); + assert.equal(release.tag, process.env.MCODE_RELEASE_TAG); + assert.equal(release.revision, revision); + const result = execFileSync(path.join(prefix, 'bin/mcode'), ['--version'], { cwd: home, env, encoding: 'utf8', timeout: 30000 }); + assert.equal(result.trim(), version); + const require = createRequire(path.join(installed, 'package.json')); + const Database = require('better-sqlite3'); + const db = new Database(':memory:'); + try { assert.equal(db.prepare('select 42 as value').get().value, 42); } finally { db.close(); } + assert.match(execFileSync(require('@vscode/ripgrep').rgPath, ['--version'], { encoding: 'utf8' }), /ripgrep/); + execFileSync(process.execPath, ['--test', 'test/smoke.test.mjs', 'test/byok.test.mjs'], { + cwd: root, env: { ...env, MCODE_TEST_CLI: path.join(installed, 'cli.js') }, stdio: 'inherit', timeout: 240000, + }); + if (process.env.MCODE_VERIFY_REPORT_DIR) { + mkdirSync(process.env.MCODE_VERIFY_REPORT_DIR, { recursive: true }); + writeFileSync(path.join(process.env.MCODE_VERIFY_REPORT_DIR, 'package-install.json'), JSON.stringify({ + status: 'PASS', version, revision, sha256, platform: process.platform, arch: process.arch, node: process.version, + }, null, 2) + '\n'); + } + console.log(`Verified npm installation of ${path.basename(archive)} (${sha256}).`); +} finally { + rmSync(temporary, { recursive: true, force: true }); +} diff --git a/scripts/verify.mjs b/scripts/verify.mjs index 88700a8f7..2fa4c2efd 100644 --- a/scripts/verify.mjs +++ b/scripts/verify.mjs @@ -25,8 +25,10 @@ const { values } = parseArgs({ }, }); const profile = values.profile; -if (!["full", "platform", "docs", "archive"].includes(profile)) +if (!["full", "platform", "docs", "archive", "package"].includes(profile)) throw new Error(`Unknown verification profile: ${profile}`); +if (profile === 'package' && !['darwin', 'linux'].includes(process.platform)) + throw new Error('Package verification currently supports Linux and macOS only.'); // Listing must not leave a temporary export directory behind. const temporary = values.list ? undefined @@ -62,9 +64,17 @@ const steps = [ }, // Seatbelt sandbox backend; only macOS provides the native helper. { name: "test:sandbox", script: "test:sandbox", platforms: ["darwin"] }, + { + name: "test:release-package", + command: ['scripts/verify-cli-release.mjs'], + packageOnly: true, + platforms: ['darwin', 'linux'], + }, ]; function skipReason(step) { + if (profile === 'package' && !step.packageOnly) return 'validating an npm release archive'; + if (profile !== 'package' && step.packageOnly) return 'requires an npm release archive'; if (profile === "docs" && !step.docs) return "documentation-only change"; if (profile === "archive" && step.requiresGit) return "validating an already exported archive"; diff --git a/test/byok.test.mjs b/test/byok.test.mjs index b9fd5e4b3..422c219a4 100644 --- a/test/byok.test.mjs +++ b/test/byok.test.mjs @@ -17,7 +17,7 @@ import Database from "better-sqlite3"; import { parse as parseYaml, stringify as stringifyYaml } from "yaml"; import { withoutProxyEnvironment } from "./offline-environment.mjs"; -const cli = fileURLToPath(new URL("../dist/cli.js", import.meta.url)); +const cli = process.env.MCODE_TEST_CLI ?? fileURLToPath(new URL("../dist/cli.js", import.meta.url)); // This fixture validates BYOK transport and real Runtime persistence, not model quality. test( "BYOK runs without managed login and resumes its saved conversation", @@ -264,25 +264,28 @@ test( proxyNames.map((name) => [name, proxyValue(name)]), )], ]) { - await t.test(`offline BYOK ignores ambient proxies: ${label}`, async () => { - const environment = Object.freeze({ ...cleanEnvironment, ...proxies }); - // NO_PROXY alone does not enable proxy mode, so check its isolation explicitly. - const isolated = withoutProxyEnvironment(environment); - for (const name of proxyNames) assert.equal(isolated[name], ""); - assert.equal(isolated.PATH, environment.PATH); - const beforeRequests = requests.length; - await run([ - "provider", "test", selected.providerId, "--model", "fixture-model", - ], environment); - assert.ok(requests.length > beforeRequests, "The local provider must receive the request"); - assert.equal(requests[beforeRequests].body.model, "fixture-model"); - // Fork adaptation: the default-deny egress guard refuses managed-service - // hosts before the offline fixture can log a managed catalog attempt, and - // that catalog request is optional (upstream ec38e13). Only the strict - // deny-log assertion remains meaningful here. - assert.equal(existsSync(networkAudit), false, "No outbound network attempt is allowed"); - for (const [name, value] of Object.entries(proxies)) assert.equal(environment[name], value); - }); + // Node 24.0–24.2 returns undefined from t.test(), so awaiting it does + // not wait for the proxy checks. Keep this shared-fixture phase directly + // sequential before changing rejectConnection or checking request counts. + // https://github.com/nodejs/node/issues/58227 + t.diagnostic(`offline BYOK ignores ambient proxies: ${label}`); + const environment = Object.freeze({ ...cleanEnvironment, ...proxies }); + // NO_PROXY alone does not enable proxy mode, so check its isolation explicitly. + const isolated = withoutProxyEnvironment(environment); + for (const name of proxyNames) assert.equal(isolated[name], ""); + assert.equal(isolated.PATH, environment.PATH); + const beforeRequests = requests.length; + await run([ + "provider", "test", selected.providerId, "--model", "fixture-model", + ], environment); + assert.ok(requests.length > beforeRequests, "The local provider must receive the request"); + assert.equal(requests[beforeRequests].body.model, "fixture-model"); + // Fork adaptation: the default-deny egress guard refuses managed-service + // hosts before the offline fixture can log a managed catalog attempt, and + // that catalog request is optional (upstream ec38e13). Only the strict + // deny-log assertion remains meaningful here. + assert.equal(existsSync(networkAudit), false, "No outbound network attempt is allowed"); + for (const [name, value] of Object.entries(proxies)) assert.equal(environment[name], value); } const configPath = path.join(dataDir, "config.yaml"); const savedConfig = () => parseYaml(readFileSync(configPath, "utf8")); diff --git a/test/history-processing.test.ts b/test/history-processing.test.ts new file mode 100644 index 000000000..634e5d962 --- /dev/null +++ b/test/history-processing.test.ts @@ -0,0 +1,516 @@ +import { createHash } from 'node:crypto'; +import { CanonicalHistoryJsonlDataSource } from '../packages/local-runtime-v2/src/infra/file/canonical-history-jsonl.js'; +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { describe, expect, it, vi } from 'vitest'; +import { IncrementalSha256 } from '../packages/local-runtime-v2/src/service/turn-system/agent-host/history/incremental-sha256.js'; +import { captureSemanticSnapshot } from '../packages/local-runtime-v2/src/service/turn-system/agent-host/history/semantic-identity.js'; +import { DurableCanonicalHistoryStore } from '../packages/local-runtime-v2/src/service/turn-system/agent-host/history/durable-canonical-history-store.js'; +import type { CanonicalHistoryChange } from '../packages/local-runtime-v2/src/service/turn-system/agent-host/history/contracts.js'; +import { BpeTokenEstimator } from '../packages/agent-modules/context-manager/src/token-estimator.js'; +import { ensureCanonicalHistoryMaterialized } from '../packages/local-runtime-v2/src/service/session-system/messages/history/canonical-history-materializer.js'; +import { createSessionSystemCanonicalHistoryProvider } from '../packages/local-runtime-v2/src/service/session-system/messages/history/canonical-history-provider.js'; +import { createCanonicalHistoryFileAdapter } from '../packages/local-runtime-v2/src/service/session-system/sessions/representation/canonical-history.js'; +import { + resolveSessionHistoryPaths, + utcSessionHistoryRelativeDir, +} from '../packages/local-runtime-v2/src/service/session-system/messages/history/session-history-paths.js'; +import type { SessionRecord } from '../packages/local-runtime-v2/src/service/session-system/sessions/repo/contract.js'; + +describe('native incremental semantic hashing', () => { + const inputs = ['', 'abc', '中文🙂', '\ud800', '\udc00', 'a'.repeat(8191) + '🙂tail']; + for (const length of [55, 56, 63, 64, 65, 8191, 8192, 8193, 32769]) { + inputs.push('x'.repeat(length), '中'.repeat(length)); + } + it.each(inputs)('preserves UTF-8 hashing %#', (input) => { + const hash = new IncrementalSha256(); + hash.update(input); + expect(hash.digestHex()).toBe(createHash('sha256').update(input).digest('hex')); + expect(() => hash.update('')).toThrow('finalized'); + expect(() => hash.digestHex()).toThrow('finalized'); + }); + it('preserves encoding boundaries between separate updates', () => { + const hash = new IncrementalSha256(); + hash.update('\ud83d'); + hash.update('\ude42'); + expect(hash.digestHex()).toBe( + createHash('sha256').update('\ud83d').update('\ude42').digest('hex'), + ); + }); +}); + +describe('semantic snapshots', () => { + it('detaches and freezes eagerly but hashes only when identity is requested', () => { + const hash = vi.spyOn(IncrementalSha256.prototype, 'update'); + try { + const original = { messages: [{ text: 'before' }] }; + const snapshot = captureSemanticSnapshot(original); + original.messages[0]!.text = 'after'; + expect(snapshot.value.messages[0]!.text).toBe('before'); + expect(Object.isFrozen(snapshot.value.messages[0])).toBe(true); + expect(hash).not.toHaveBeenCalled(); + const reused = captureSemanticSnapshot(snapshot.value); + expect(reused.value).toBe(snapshot.value); + const wrapped = captureSemanticSnapshot({ + history: snapshot.value.messages, + }); + expect(wrapped.value.history).toBe(snapshot.value.messages); + expect(wrapped.value.history).toEqual(snapshot.value.messages); + const fingerprint = snapshot.fingerprint; + expect(hash).toHaveBeenCalled(); + hash.mockClear(); + expect(reused.fingerprint).toBe(fingerprint); + expect(hash).not.toHaveBeenCalled(); + expect(captureSemanticSnapshot(original).fingerprint).not.toBe(fingerprint); + } finally { + hash.mockRestore(); + } + }); + it('shares owned history through delivery wrappers and detaches other branches', () => { + const history = captureSemanticSnapshot({ + messages: [{ text: 'body' }], + }).value; + const external = { nested: { id: 1 } }; + const delivered = captureSemanticSnapshot({ + committedMessages: history.messages, + external, + }); + const event = captureSemanticSnapshot({ + context: external, + change: delivered.value, + }); + external.nested.id = 2; + expect(delivered.value.committedMessages).toBe(history.messages); + expect(event.value.change).toBe(delivered.value); + expect(event.value.context.nested.id).toBe(1); + expect(event.value.change.external.nested.id).toBe(1); + expect(Object.isFrozen(event.value.context.nested)).toBe(true); + expect(event.fingerprint).toBe( + captureSemanticSnapshot(structuredClone(event.value)).fingerprint, + ); + }); + it('preserves aliases, sparse arrays and own __proto__ fields in owned wrappers', () => { + const owned = captureSemanticSnapshot({ value: 'owned' }).value; + const shared = { owned }; + const array = new Array(3); + array[2] = shared; + const input = { owned, a: shared, b: shared, array, ['__proto__']: shared }; + const snapshot = captureSemanticSnapshot(input); + expect(snapshot.value.a).toBe(snapshot.value.b); + expect(snapshot.value.a).toBe(snapshot.value.array[2]); + expect(snapshot.value['__proto__']).toBe(snapshot.value.a); + expect(0 in snapshot.value.array).toBe(false); + expect(snapshot.value).toEqual(structuredClone(input)); + const cycle: Record = { owned }; + cycle.self = cycle; + expect(() => captureSemanticSnapshot(cycle)).toThrow('Cyclic'); + }); + it('uses native getter ordering and proxy rejection even beside owned values', () => { + const owned = captureSemanticSnapshot({ text: 'owned' }).value; + const shared = { value: 1 }; + const getter = vi.fn(() => { + shared.value = 2; + return shared; + }); + const snapshot = captureSemanticSnapshot({ + owned, + shared, + get later() { + return getter(); + }, + }); + expect(snapshot.value.shared).toBe(snapshot.value.later); + expect(snapshot.value.shared.value).toBe(1); + expect(getter).toHaveBeenCalledTimes(1); + const trap = vi.fn(); + expect(() => + captureSemanticSnapshot({ + owned, + proxy: new Proxy({}, { ownKeys: trap }), + }), + ).toThrow(); + expect(trap).not.toHaveBeenCalled(); + }); + it('does not trust externally frozen objects with mutable descendants', () => { + const original = Object.freeze({ nested: { text: 'before' } }); + const snapshot = captureSemanticSnapshot(original); + original.nested.text = 'after'; + expect(snapshot.value.nested.text).toBe('before'); + }); + it('preserves native rejection of proxies without invoking their traps', () => { + const trap = vi.fn(); + const value = new Proxy({}, { ownKeys: trap }); + expect(() => captureSemanticSnapshot(value)).toThrow(); + expect(trap).not.toHaveBeenCalled(); + }); + it('preserves native enumeration when an accessor deletes another field', () => { + const value = { + get first() { + delete this.second; + return 'first'; + }, + second: 'deleted' as string | undefined, + }; + expect(captureSemanticSnapshot(value).value).toEqual({ first: 'first' }); + }); + it.each([false, true])( + 'preserves shared references across a mutating getter (getter first: %s)', + (getterFirst) => { + const makeInput = () => { + const shared = { value: 1 }; + const getter = vi.fn(() => { + shared.value = 2; + return shared; + }); + const nested = { + get shared() { + return getter(); + }, + }; + return { + input: getterFirst ? { nested, shared } : { shared, nested }, + getter, + }; + }; + const expected = structuredClone(makeInput().input); + const { input, getter } = makeInput(); + const snapshot = captureSemanticSnapshot(input); + expect(snapshot.value).toEqual(expected); + expect(snapshot.value.shared).toBe(snapshot.value.nested.shared); + expect(snapshot.fingerprint).toBe(captureSemanticSnapshot(expected).fingerprint); + expect(getter).toHaveBeenCalledTimes(1); + }, + ); + it('preserves shared references across a class instance normalized by native cloning', () => { + const shared = { value: 1 }; + class Container { + child = shared; + } + const snapshot = captureSemanticSnapshot({ + shared, + nested: new Container(), + }); + expect(snapshot.value.shared).toBe(snapshot.value.nested.child); + expect(Object.getPrototypeOf(snapshot.value.nested)).toBe(Object.prototype); + }); + it.each([new Date(), new Map(), new Uint8Array([1]), 1n])( + 'rejects unsupported value-only payloads %#', + (value) => { + expect(() => captureSemanticSnapshot(value).value).toThrow(); + }, + ); + it('rejects cycles eagerly and accepts shared acyclic objects', () => { + const cyclic: { self?: unknown } = {}; + cyclic.self = cyclic; + expect(() => captureSemanticSnapshot(cyclic).value).toThrow('Cyclic'); + const shared = { value: 'same' }; + expect(captureSemanticSnapshot([shared, shared]).fingerprint).toBe( + captureSemanticSnapshot([{ value: 'same' }, { value: 'same' }]).fingerprint, + ); + }); + it('retains ordering, framing and special number distinctions', () => { + const fingerprint = (value: unknown) => captureSemanticSnapshot(value).fingerprint; + expect(fingerprint({ b: 2, a: 1 })).toBe(fingerprint({ a: 1, b: 2 })); + expect( + new Set( + [ + undefined, + null, + NaN, + Infinity, + -Infinity, + -0, + 0, + '', + [], + Array(1), + [undefined], + {}, + { a: undefined }, + ].map(fingerprint), + ).size, + ).toBe(13); + expect(fingerprint(['ab', 'c'])).not.toBe(fingerprint(['a', 'bc'])); + }); +}); + +describe('bounded token count reuse', () => { + it('reuses text across detached histories and observes content edits and compaction', () => { + const encode = vi.fn((text: string) => [...text]); + const estimator = new BpeTokenEstimator(encode); + const messages = [ + { + role: 'user' as const, + content: [{ type: 'text' as const, text: 'old' }], + timestamp: 0, + }, + ]; + expect(estimator.estimateMessages(messages)).toBe(7); + expect(estimator.estimateMessages(structuredClone(messages))).toBe(7); + expect(encode).toHaveBeenCalledTimes(1); + messages[0]!.content[0]!.text = 'replacement'; + expect(estimator.estimateMessages(messages)).toBe(15); + expect(estimator.estimateMessages([])).toBe(0); + expect(estimator.estimateMessages([{ role: 'user', content: 'summary', timestamp: 1 }])).toBe( + 11, + ); + expect(encode).toHaveBeenCalledTimes(3); + }); + it.each([256, 257, 300])( + 'reuses detached histories above the former text budget (%i messages)', + (count) => { + const encode = vi.fn(() => [1]); + const estimator = new BpeTokenEstimator(encode); + const messages = Array.from({ length: count }, (_, i) => ({ + role: 'user' as const, + content: String(i).padStart(8, '0') + 'x '.repeat(2044), + timestamp: i, + })); + const first = estimator.estimateMessages(messages); + encode.mockClear(); + expect(estimator.estimateMessages(structuredClone(messages))).toBe(first); + expect(encode).not.toHaveBeenCalled(); + messages[0]!.content = 'changed ' + messages[0]!.content.slice(8); + estimator.estimateMessages(messages); + expect(encode).toHaveBeenCalledTimes(1); + }, + ); + it('keeps distinct malformed UTF-16 strings separate in long-text keys', () => { + const prefix = 'x '.repeat(256); + const estimator = new BpeTokenEstimator((text) => (text.endsWith('\ud800') ? [1] : [1, 2])); + expect(estimator.estimateTextTokens(prefix + '\ud800')).toBe(1); + expect(estimator.estimateTextTokens(prefix + '\ud801')).toBe(2); + }); + it('isolates different estimators and evicts least-recently-used text', () => { + const encode = vi.fn((text: string) => [...text]); + const estimator = new BpeTokenEstimator(encode); + estimator.estimateTextTokens('old'); + for (let i = 0; i < 2048; i++) estimator.estimateTextTokens(`text ${i}`); + encode.mockClear(); + estimator.estimateTextTokens('old'); + expect(encode).toHaveBeenCalledTimes(1); + expect(new BpeTokenEstimator(() => [0]).estimateTextTokens('old')).toBe(1); + }); + it('reuses large texts without retaining their bodies', () => { + const encode = vi.fn(() => [1]); + const estimator = new BpeTokenEstimator(encode); + const first = 'a '.repeat(300_000); + const second = 'b '.repeat(300_000); + estimator.estimateTextTokens(first); + estimator.estimateTextTokens(second); + estimator.estimateTextTokens(first); + expect(encode).toHaveBeenCalledTimes(2); + }); + it('does not cache tokenizer failures', () => { + const encode = vi + .fn() + .mockImplementationOnce(() => { + throw new Error('transient'); + }) + .mockReturnValue([1]); + const estimator = new BpeTokenEstimator(encode); + expect(estimator.estimateTextTokens('中文')).toBe(6); + expect(estimator.estimateTextTokens('中文')).toBe(1); + }); +}); + +describe('committed history read reuse', () => { + it('reuses verified records for indexing while observing external changes and corruption', async () => { + const dataDir = await mkdtemp(join(tmpdir(), 'mcode-history-reuse-')); + const redundantRead = vi.spyOn(CanonicalHistoryJsonlDataSource.prototype, 'readActive'); + try { + const session: SessionRecord = { + sessionId: 'synthetic-session', + agentName: 'test', + workspaceDir: dataDir, + runtime: 'pi-agent', + sessionType: 'root', + sessionKind: 'conversation', + archived: false, + status: 'idle', + createdAtMs: 0, + updatedAtMs: 0, + historyRelativeDir: utcSessionHistoryRelativeDir('synthetic-session', 0), + }; + const files = createCanonicalHistoryFileAdapter(); + const strictRead = vi.spyOn(files, 'readActiveStrict'); + const provider = createSessionSystemCanonicalHistoryProvider({ + dataDir, + sessions: { get: async () => session }, + files, + }); + await provider.initialize(session.sessionId); + strictRead.mockClear(); + redundantRead.mockClear(); + expect((await provider.readActive(session.sessionId)).messages).toEqual([]); + expect(strictRead).not.toHaveBeenCalled(); + const committed = await provider.append({ + sessionId: session.sessionId, + turnId: 'turn-1', + reason: 'messageDelta', + messages: [{ role: 'user', content: 'first', timestamp: 1 }], + operation: { id: 'append-1', kind: 'append' }, + }); + expect(committed.messages).toHaveLength(1); + expect(strictRead).toHaveBeenCalledTimes(1); + expect(redundantRead).not.toHaveBeenCalled(); + const paths = resolveSessionHistoryPaths(dataDir, session); + const external = createCanonicalHistoryFileAdapter(); + await external.replace(paths.messages, [ + { + message_id: 'msg-external-id', + turn_id: 'turn-2', + message: { role: 'user', content: 'edited', timestamp: 2 }, + }, + ]); + expect((await provider.readActive(session.sessionId)).messages[0]).toMatchObject({ + content: 'edited', + }); + await writeFile(paths.messages, '{invalid json}\n'); + await expect(provider.readActive(session.sessionId)).rejects.toThrow(); + await expect( + provider.append({ + sessionId: session.sessionId, + turnId: 'turn-3', + reason: 'messageDelta', + messages: [{ role: 'user', content: 'must not append', timestamp: 3 }], + operation: { id: 'append-3', kind: 'append' }, + }), + ).rejects.toThrow(); + } finally { + redundantRead.mockRestore(); + await rm(dataDir, { recursive: true, force: true }); + } + }); + it('checks migration target existence without reparsing and preserves legacy adapter fallback', async () => { + const dataDir = await mkdtemp(join(tmpdir(), 'mcode-materialized-')); + try { + const sessionId = 'synthetic-session'; + const paths = resolveSessionHistoryPaths(dataDir, { + sessionId, + createdAtMs: 0, + }); + const files = createCanonicalHistoryFileAdapter(); + const read = vi.spyOn(files, 'readTarget'); + const options = { sessionId, paths, files, nowMs: () => 0 }; + await ensureCanonicalHistoryMaterialized(options); + const legacyHistory = { + sources: { + readLedgerSnapshot: vi.fn(async () => undefined), + readSqliteRows: vi.fn(async () => []), + readSqliteBlob: vi.fn(async () => []), + }, + checkpoints: { + getCheckpoint: async () => ({ + sessionId, + migratedAtMs: 0, + source: 'empty' as const, + messageCount: 0, + targetRevision: 'recorded', + }), + upsertCheckpoint: vi.fn(async () => {}), + }, + }; + await ensureCanonicalHistoryMaterialized({ ...options, legacyHistory }); + expect(read).not.toHaveBeenCalled(); + expect(legacyHistory.sources.readLedgerSnapshot).not.toHaveBeenCalled(); + Object.defineProperty(files, 'targetExists', { value: undefined }); + await ensureCanonicalHistoryMaterialized({ ...options, legacyHistory }); + expect(read).toHaveBeenCalledTimes(1); + await rm(paths.messages); + await expect( + ensureCanonicalHistoryMaterialized({ ...options, legacyHistory }), + ).rejects.toThrow('missing after migration'); + } finally { + await rm(dataDir, { recursive: true, force: true }); + } + }); + it('retains disk reads for standalone appends and validates supplied prefix identities', async () => { + const dataDir = await mkdtemp(join(tmpdir(), 'mcode-append-prefix-')); + try { + const source = new CanonicalHistoryJsonlDataSource({ + activePath: join(dataDir, 'messages.jsonl'), + }); + await source.publishInitial([]); + const first = { + message_id: 'msg-first', + turn_id: 'turn-first', + message: { role: 'user', content: 'first', timestamp: 1 }, + }; + const second = { + message_id: 'msg-second', + turn_id: 'turn-second', + message: { role: 'user', content: 'second', timestamp: 2 }, + }; + const read = vi.spyOn(source, 'readActive'); + await source.append([first]); + expect(read).toHaveBeenCalledTimes(1); + const verified = await source.readActiveStrict(); + read.mockClear(); + await expect(source.append([first], verified)).rejects.toThrow(); + await source.append([second], verified); + expect(read).not.toHaveBeenCalled(); + expect((await source.readActiveStrict()).map((row) => row.message_id)).toEqual([ + 'msg-first', + 'msg-second', + ]); + } finally { + await rm(dataDir, { recursive: true, force: true }); + } + }); + const change: CanonicalHistoryChange = { + sessionId: 'synthetic-session', + turnId: 'synthetic-turn', + reason: 'messageDelta', + messages: [], + operation: { id: 'append-1', kind: 'append' }, + }; + it('uses the provider commit without a second read and still reads fresh on demand', async () => { + const committed = { revision: 'r1', messages: [], identityVector: [] }; + const read = vi.fn(async () => ({ + revision: 'r2', + messages: [], + identityVector: [], + })); + const readActive = vi.fn(async () => committed); + const store = new DurableCanonicalHistoryStore({ + read, + readActive, + append: async () => committed, + replace: async () => committed, + }); + const result = await store.append(change); + committed.revision = 'mutated'; + expect(result.revision).toBe('r1'); + expect(readActive).not.toHaveBeenCalled(); + expect((await store.read('synthetic-session')).revision).toBe('r2'); + }); + it('retains legacy rereads and rejects invalid commits or write failures', async () => { + const readActive = vi.fn(async () => ({ + revision: 'r1', + messages: [], + identityVector: [], + })); + const append = vi.fn< + () => Promise extends Promise ? T | void : never> + >(async () => undefined); + const store = new DurableCanonicalHistoryStore({ + read: readActive, + readActive, + append, + replace: async () => undefined, + }); + expect((await store.append(change)).revision).toBe('r1'); + expect(readActive).toHaveBeenCalledTimes(1); + append.mockResolvedValueOnce({ + revision: '', + messages: [], + identityVector: [], + }); + await expect(store.append(change)).rejects.toThrow(); + append.mockRejectedValueOnce(new Error('write failed')); + await expect(store.append(change)).rejects.toThrow('write failed'); + expect(readActive).toHaveBeenCalledTimes(1); + }); +}); diff --git a/test/smoke.test.mjs b/test/smoke.test.mjs index db50766f9..263532540 100644 --- a/test/smoke.test.mjs +++ b/test/smoke.test.mjs @@ -8,9 +8,10 @@ import { fileURLToPath } from "node:url"; import Database from "better-sqlite3"; import { parse as parseYaml } from "yaml"; import { withoutProxyEnvironment } from "./offline-environment.mjs"; +import { cliBuildVersion } from '../scripts/lib/cli-release.mjs'; const root = fileURLToPath(new URL("../", import.meta.url)); -const cli = path.join(root, "dist/cli.js"); +const cli = process.env.MCODE_TEST_CLI ?? path.join(root, "dist/cli.js"); // Full runtime startup can exceed 15s on Windows CI (ACP took 22s). // Match the ACP startup budget; lightweight help/validation stays at 15s. const runtimeTimeoutMs = process.platform === "win32" ? 30000 : 15000; @@ -19,9 +20,7 @@ function assertSuccessfulChild(result) { `CLI spawn failed: ${result.error?.message}; signal=${result.signal}; stderr=${result.stderr}`); assert.equal(result.status, 0, result.stderr); } -const version = JSON.parse( - readFileSync(path.join(root, "packages/tui/package.json"), "utf8"), -).version; +const version = cliBuildVersion(root); function fixture(t, environment = process.env) { const dataDir = mkdtempSync(path.join(tmpdir(), "minimax-code-smoke-")); const audit = path.join(dataDir, "network-attempts.log"); diff --git a/test/source-sync.test.mjs b/test/source-sync.test.mjs index 3b7a5f653..2833c05fc 100644 --- a/test/source-sync.test.mjs +++ b/test/source-sync.test.mjs @@ -14,6 +14,378 @@ import { gzipSync } from 'node:zlib'; import { createHash } from 'node:crypto'; import { fileURLToPath } from 'node:url'; import { parse as parseYaml } from 'yaml'; +import { cliBuildVersion, cliExternalModules, cliReleaseTargets, versionFromTag } from '../scripts/lib/cli-release.mjs'; +import { releaseManifest } from '../scripts/package-cli-release.mjs'; +import { validateReleaseReports } from '../scripts/publish-cli-release.mjs'; +import { compareVersions, releaseCli } from '../scripts/release-cli.mjs'; +import { compareRuns, validateRun, validateRequest, validateToolOutput, median, selectScenarios } from '../scripts/perf/report.mjs'; +import { copyMcodeToolsArtifact, downloadMcodeToolsArtifact, MCODE_TOOLS_ARTIFACT } from '../scripts/lib/mcode-tools-artifact.mjs'; + +test('artifact download recovers from TLS reset and interrupted response bodies', async () => { + const reset = new TypeError('fetch failed', { cause: Object.assign(new Error('connection reset'), { code: 'ECONNRESET' }) }); + const interrupted = new TypeError('terminated', { cause: Object.assign(new Error('socket closed'), { code: 'UND_ERR_SOCKET' }) }); + const signals = [], delays = [], warnings = []; + const result = await downloadMcodeToolsArtifact(async (url, { signal }) => { + assert.equal(url, MCODE_TOOLS_ARTIFACT.url); + assert.ok(signal instanceof AbortSignal); + signals.push(signal); + if (signals.length === 1) throw reset; + if (signals.length === 2) return new Response(new ReadableStream({ + start(controller) { controller.enqueue(new Uint8Array([1, 2])); }, + pull(controller) { controller.error(interrupted); }, + })); + return new Response('complete archive'); + }, { wait: async ms => delays.push(ms), warn: message => warnings.push(message) }); + assert.equal(result.toString(), 'complete archive'); + assert.equal(new Set(signals).size, 3); + assert.deepEqual(delays, [1000, 2000]); + assert.equal(warnings.length, 2); + assert.match(warnings[0], /1\/3.*ECONNRESET/); + assert.match(warnings[1], /2\/3.*UND_ERR_SOCKET/); +}); + +test('artifact download retries temporary HTTP failures and releases rejected bodies', async () => { + for (const status of [408, 429, 500, 502, 503, 504]) { + let calls = 0, cancelled = false; + const delays = []; + const result = await downloadMcodeToolsArtifact(async () => { + if (++calls > 1) return new Response('ok'); + return new Response(new ReadableStream({ cancel() { cancelled = true; } }), { status }); + }, { wait: async ms => delays.push(ms), warn() {} }); + assert.equal(result.toString(), 'ok'); + assert.equal(calls, 2); + assert.equal(cancelled, true); + assert.deepEqual(delays, [1000]); + } +}); + +test('artifact download bounds Retry-After delays and rejects permanent failures immediately', async () => { + for (const [header, expectedDelay] of [['5', 5000], ['999999', 30000], ['invalid', 1000], ['0', 1000]]) { + let calls = 0; + const delays = []; + await downloadMcodeToolsArtifact(async () => ++calls === 1 + ? new Response(null, { status: 429, headers: { 'Retry-After': header } }) : new Response('ok'), + { wait: async ms => delays.push(ms), warn() {} }); + assert.deepEqual(delays, [expectedDelay]); + } + const certificateError = new TypeError('fetch failed', { cause: Object.assign(new Error('certificate expired'), { code: 'CERT_HAS_EXPIRED' }) }); + for (const failure of [new Response(null, { status: 403 }), new Response(null, { status: 404 }), certificateError]) { + let calls = 0; + await assert.rejects(downloadMcodeToolsArtifact(async () => { + calls++; + if (failure instanceof Error) throw failure; + return failure; + }, { wait: async () => assert.fail('permanent failures must not wait'), warn: () => assert.fail('permanent failures must not retry') })); + assert.equal(calls, 1); + } +}); + +test('artifact download stops after three timeouts and preserves the final cause', async () => { + const timeout = new DOMException('The operation timed out', 'TimeoutError'); + let calls = 0; + const delays = []; + await assert.rejects(downloadMcodeToolsArtifact(async () => { calls++; throw timeout; }, + { wait: async ms => delays.push(ms), warn() {} }), error => { + assert.match(error.message, /after 3 attempts/); + assert.equal(error.cause, timeout); + return true; + }); + assert.equal(calls, 3); + assert.deepEqual(delays, [1000, 2000]); +}); + +test('artifact download never retries or caches an archive that fails integrity', async t => { + const root = mkdtempSync(path.join(tmpdir(), 'mcode-artifact-download-')); + t.after(() => rmSync(root, { recursive: true, force: true })); + let calls = 0; + await assert.rejects(copyMcodeToolsArtifact(root, path.join(root, 'dist'), async () => { + calls++; + return new Response('corrupt archive'); + }), /integrity mismatch/); + assert.equal(calls, 1); + assert.equal(existsSync(path.join(root, '.cache', 'artifacts', 'code-0.3.11.tgz')), false); + assert.equal(existsSync(path.join(root, 'dist')), false); +}); + +test('performance defaults to the 100-round suite; long history requires explicit selection', () => { + const config = JSON.parse(readFileSync(new URL('../scripts/perf/config.json', import.meta.url), 'utf8')); + assert.deepEqual(selectScenarios(config).map(s => s.id), ['upstream-100']); + assert.deepEqual(selectScenarios(config, { suite: 'full' }).map(s => s.id), ['startup', 'upstream-100', 'history-300']); + assert.deepEqual(selectScenarios(config, { scenario: 'startup' }).map(s => s.id), ['startup']); + assert.throws(() => selectScenarios(config, { suite: 'typo' })); + assert.throws(() => selectScenarios(config, { scenario: 'typo' })); + const workflow = parseYaml(readFileSync(new URL('../.github/workflows/performance.yml', import.meta.url), 'utf8')); + assert.equal(workflow.on.workflow_dispatch.inputs.suite.default, 'full'); + assert.deepEqual(workflow.on.workflow_dispatch.inputs.suite.options, ['full', 'basic']); + const compare = workflow.jobs.performance.steps.find(s => s.name === 'Compare on this runner'); + assert.equal(compare.env.PERF_SUITE, "${{ matrix.suite }}"); + assert.match(compare.run, /--suite "\$PERF_SUITE"/); +}); + +test('performance labels select full coverage without interrupting checks for unrelated labels', () => { + const workflow = parseYaml(readFileSync(new URL('../.github/workflows/performance.yml', import.meta.url), 'utf8')); + const job = workflow.jobs.performance; + assert.deepEqual(workflow.on.pull_request.types, ['opened', 'synchronize', 'reopened', 'labeled', 'unlabeled']); + assert.equal(job.strategy.matrix.suite, `\${{ fromJSON((inputs.suite == 'full' || (github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'perf:full'))) && '["full"]' || '["basic"]') }}`); + assert.equal(job.if, `\${{ !contains(fromJSON('["labeled","unlabeled"]'), github.event.action) || github.event.label.name == 'perf:full' }}`); + assert.equal(job.name, `\${{ contains(fromJSON('["labeled","unlabeled"]'), github.event.action) && github.event.label.name != 'perf:full' && 'performance (label ignored)' || 'performance' }}`); + assert.equal(workflow.concurrency, undefined); + assert.equal(job.concurrency.group, `performance-\${{ github.event_name }}-\${{ github.ref }}-\${{ github.event_name == 'workflow_dispatch' && matrix.suite || 'auto' }}`); + assert.equal(job.concurrency['cancel-in-progress'], true); + assert.equal(job['timeout-minutes'], `\${{ matrix.suite == 'full' && 45 || 15 }}`); +}); + +test('performance request audit rejects truncated wire history and empty tool results', () => { + const responses = [ + { message: { content: 'full history', tool_calls: [{ id: 'call_long_run_1', function: { name: 'bash', arguments: { command: 'ls' } } }] } }, + { message: { content: 'done' } }, { message: { content: ' ' } }, + ]; + const messages = [{ role: 'user', content: 'task' }, + { role: 'assistant', content: 'full history', tool_calls: [{ id: 'call_long_run_1', function: { name: 'bash', arguments: '{"command":"ls"}' } }] }, + { role: 'tool', tool_call_id: 'call_long_run_1', content: 'README.txt\n' }]; + assert.doesNotThrow(() => validateRequest({ messages }, responses, 2, process.cwd(), 'task')); + assert.throws(() => validateRequest({ messages: messages.slice(0, 1) }, responses, 2, process.cwd(), 'task'), /conversation length/); + assert.throws(() => validateRequest({ messages: [] }, responses, 1, process.cwd(), 'task'), /conversation length/); + assert.throws(() => validateRequest({ messages: [messages[0], messages[2], messages[1]] }, responses, 2, process.cwd(), 'task'), /order changed/); + assert.throws(() => validateRequest({ messages: [{ role: 'user', content: 'wrong' }] }, responses, 1, process.cwd(), 'task'), /task changed/); + assert.doesNotThrow(() => validateRequest({ messages: [messages[0]] }, responses, 1, process.cwd(), 'task')); + const changed = structuredClone(messages); + changed[1].content = 'shortened'; + assert.throws(() => validateRequest({ messages: changed }, responses, 2, process.cwd(), 'task'), /body changed/); + changed[1].content = 'full history'; changed[2].content = ''; + assert.throws(() => validateRequest({ messages: changed }, responses, 2, process.cwd(), 'task'), /lost fixture/); + assert.throws(() => validateToolOutput([], 'ls', process.cwd()), /lost fixture/); + assert.throws(() => validateToolOutput('wrong', 'echo expected', process.cwd()), /Echo/); + assert.throws(() => validateToolOutput('/wrong', 'pwd', process.cwd()), /directory/); + assert.doesNotThrow(() => validateToolOutput(realpathSync(process.cwd()) + '\n', 'pwd', process.cwd())); +}); + +test('performance comparison rejects incomplete, invalid and unstable samples', () => { + const config = { repetitions: 3, maxSpread: 0.3, thresholds: { cpuSeconds: { relative: 0.2, absolute: 0.5 } } }; + const runs = values => values.map(cpuSeconds => ({ cpuSeconds })); + assert.equal(median([3, 1, 2]), 2); + assert.equal(compareRuns(runs([10, 10, 10]), runs([10, 10, 10]), config)[0].status, 'PASS'); + assert.equal(compareRuns(runs([10, 10, 10]), runs([13, 13, 13]), config)[0].status, 'REGRESSION'); + assert.equal(compareRuns(runs([1, 1, 1]), runs([1.4, 1.4, 1.4]), config)[0].status, 'PASS'); + assert.equal(compareRuns(runs([10, 10, 10]), runs([10, 10, 20]), config)[0].status, 'INCONCLUSIVE'); + assert.throws(() => compareRuns(runs([10, 10]), runs([10, 10, 10]), config)); + assert.throws(() => compareRuns(runs([10, NaN, 10]), runs([10, 10, 10]), config)); + assert.throws(() => compareRuns(runs([0, 0, 0]), runs([10, 10, 10]), config)); +}); + +test('performance measurements require complete successful tool execution', () => { + const meta = { schemaVersion: 1, status: 'ok', exit: { code: 0 }, mock: { requests: 2 }, + sampling: { backend: 'rusage', withTree: true }, summary: { count: 10 }, + duration: { endToEndMs: 1000 }, cost: { cpuSeconds: 0.5 }, peaks: { treeRssBytes: 1024 } }; + const messages = [ + { role: 'assistant', content: [{ type: 'toolCall', id: 'call_long_run_1', name: 'bash' }] }, + { role: 'toolResult', toolCallId: 'call_long_run_1', isError: false }, + { role: 'assistant', content: [{ type: 'text', text: 'done' }] }, + ]; + assert.equal(validateRun(meta, messages, { turns: 1 }, 'done').rssBytes, 1024); + const expected = [{ message: { content: '', tool_calls: [{ function: { arguments: { command: 'pwd' } } }] } }]; + const withArguments = structuredClone(messages); + withArguments[0].content[0].arguments = { command: 'pwd' }; + assert.doesNotThrow(() => validateRun(meta, withArguments, { turns: 1 }, 'done', expected)); + withArguments[0].content[0].arguments.command = 'true'; + assert.throws(() => validateRun(meta, withArguments, { turns: 1 }, 'done', expected), /command changed/); + withArguments[0].content[0].arguments.command = 'pwd'; + expected[0].message.content = 'body that must be retained'; + assert.throws(() => validateRun(meta, withArguments, { turns: 1 }, 'done', expected), /body changed/); + for (const mutate of [ + (m, _) => { m.status = 'timeout'; }, (m, _) => { m.mock.requests = 1; }, + (m, _) => { m.sampling.backend = 'ps'; }, (m, _) => { m.summary.count = 0; }, + (m, _) => { m.cost.cpuSeconds = null; }, (_, rows) => { rows[1].isError = true; }, + (_, rows) => { rows[1].toolCallId = 'wrong'; }, (_, rows) => { rows.pop(); }, + (_, rows) => { rows.push(rows[1]); }, + ]) { + const copiedMeta = structuredClone(meta), copiedMessages = structuredClone(messages); + mutate(copiedMeta, copiedMessages); + assert.throws(() => validateRun(copiedMeta, copiedMessages, { turns: 1 }, 'done')); + } +}); + +test('performance workflow uses pinned mock input and an unprivileged PR job', () => { + const workflow = parseYaml(readFileSync(new URL('../.github/workflows/performance.yml', import.meta.url), 'utf8')); + const config = JSON.parse(readFileSync(new URL('../scripts/perf/config.json', import.meta.url), 'utf8')); + assert.ok(Object.hasOwn(workflow.on, 'pull_request')); + assert.ok(!Object.hasOwn(workflow.on, 'pull_request_target')); + assert.deepEqual(workflow.permissions, { contents: 'read' }); + const steps = workflow.jobs.performance.steps; + const benchmark = steps.find(s => s.with?.repository === 'KonghaYao/harness-perf-benchmark'); + assert.equal(benchmark.with.ref, config.benchmarkRevision); + assert.match(config.benchmarkRevision, /^[a-f0-9]{40}$/); + for (const step of steps.filter(s => s.uses)) assert.match(step.uses, /@[a-f0-9]{40}$/); + const standard = config.scenarios.find(s => s.id === 'upstream-100'); + assert.deepEqual([standard.turns, standard.bodyKb, standard.chunkSize], [100, 4, 64]); + assert.ok(config.scenarios.some(s => s.minimumTextUnits > 1048576)); +}); + +test('release tags are canonical and must match both source versions without overriding them', t => { + for (const tag of ['v0.4.13', 'v1.0.0-rc.1', 'v0.0.0', 'v2.3.4-beta-test.0']) + assert.equal(versionFromTag(tag), tag.slice(1)); + for (const tag of [undefined, '', '0.4.13', 'v01.2.3', 'v1.02.3', 'v1.2.03', 'v1.2.3-01', 'v1.2.3+build', 'v1.2.3\n', 'v1.2.3;echo bad', 'v1.2.3/../bad']) + assert.throws(() => versionFromTag(tag), /Release tag/); + const f = fixture(t); + mkdirSync(path.join(f.root, 'packages/tui'), { recursive: true }); + const manifest = path.join(f.root, 'packages/tui/package.json'); + writeFileSync(manifest, JSON.stringify({ version: '0.4.12', private: true })); + writeFileSync(path.join(f.root, 'package.json'), JSON.stringify({ version: '0.4.12', private: true })); + const before = readFileSync(manifest); + assert.equal(cliBuildVersion(f.root, 'v0.4.12'), '0.4.12'); + assert.throws(() => cliBuildVersion(f.root, 'v0.4.13-rc.1'), /Release tag must match/); + assert.deepEqual(readFileSync(manifest), before); + writeFileSync(manifest, JSON.stringify({ version: '0.4.13' })); + assert.throws(() => cliBuildVersion(f.root, null), /Root and TUI/); +}); + +function cliReleaseFixture(t) { + const home = mkdtempSync(path.join(tmpdir(), 'cli-release-')); + t.after(() => rmSync(home, { recursive: true, force: true })); + const root = path.join(home, 'checkout'), remote = path.join(home, 'remote.git'); + execFileSync('git', ['init', '--bare', remote], { stdio: 'ignore' }); + execFileSync('git', ['init', '--initial-branch=main', root], { stdio: 'ignore' }); + const git = (...args) => execFileSync('git', ['-C', root, ...args], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim(); + git('config', 'user.name', 'Release Fixture'); git('config', 'user.email', 'release@example.invalid'); + git('config', 'core.hooksPath', path.join(home, 'no-hooks')); + mkdirSync(path.join(root, 'packages/tui'), { recursive: true }); + for (const name of ['package.json', 'packages/tui/package.json']) writeFileSync(path.join(root, name), JSON.stringify({ name: 'fixture', version: '1.2.3', private: true }, null, 2) + '\n'); + git('add', '.'); git('commit', '-m', 'Fixture baseline'); git('remote', 'add', 'origin', remote); git('push', '-u', 'origin', 'main'); + return { root, remote, git, base: git('rev-parse', 'HEAD') }; +} + +test('release command bumps source before tagging, pushes a release branch and opens its version PR', t => { + const f = cliReleaseFixture(t); + const prs = []; + const plan = releaseCli({ root: f.root, version: '1.2.4', dryRun: true }); + assert.equal(plan.tag, 'v1.2.4'); + assert.equal(f.git('status', '--porcelain'), ''); + assert.equal(f.git('rev-parse', 'HEAD'), f.base); + const result = releaseCli({ root: f.root, version: '1.2.4', openPullRequest: request => prs.push(request) }); + assert.equal(f.git('branch', '--show-current'), 'release/v1.2.4'); + assert.equal(f.git('cat-file', '-t', 'v1.2.4'), 'tag'); + assert.equal(f.git('rev-parse', 'v1.2.4^{commit}'), result.revision); + assert.equal(f.git('rev-parse', 'origin/main'), f.base); + assert.equal(f.git('rev-parse', 'origin/release/v1.2.4'), result.revision); + for (const name of ['package.json', 'packages/tui/package.json']) + assert.equal(JSON.parse(f.git('show', `v1.2.4:${name}`)).version, '1.2.4'); + assert.equal(prs.length, 1); + assert.equal(prs[0].branch, 'release/v1.2.4'); + assert.equal(f.git('ls-remote', 'origin', 'refs/heads/main').split('\t')[0], f.base); + assert.ok(f.git('ls-remote', 'origin', 'refs/tags/v1.2.4')); +}); + +test('release command rejects dirty trees, version regressions, stale bases and existing remote tags', t => { + const f = cliReleaseFixture(t); + const release = version => releaseCli({ root: f.root, version, dryRun: true }); + for (const version of ['1.2.3', '1.2.2', '1.2.3-rc.1']) assert.throws(() => release(version), /must be newer/); + writeFileSync(path.join(f.root, 'untracked'), 'unfinished'); + assert.throws(() => release('1.2.4'), /clean working tree/); + f.git('add', 'untracked'); f.git('commit', '-m', 'Unreviewed change'); + assert.throws(() => release('1.2.4'), /latest origin\/main/); + f.git('switch', '--detach', f.base); + f.git('tag', 'v1.2.4'); f.git('push', 'origin', 'refs/tags/v1.2.4'); f.git('tag', '-d', 'v1.2.4'); + assert.throws(() => release('1.2.4'), /already exists on origin/); + assert.equal(f.git('rev-parse', 'HEAD'), f.base); + assert.equal(f.git('status', '--porcelain'), ''); + for (const [a, b] of [['1.2.4', '1.2.3'], ['1.2.4', '1.2.4-rc.1'], ['1.2.4-rc.10', '1.2.4-rc.2'], ['1.2.4-beta', '1.2.4-1']]) { + assert.equal(compareVersions(a, b), 1); assert.equal(compareVersions(b, a), -1); + } +}); + +test('rejected tag pushes cannot leave a partial remote release branch or open a version PR', { skip: process.platform === 'win32' }, t => { + const f = cliReleaseFixture(t); + execFileSync('git', ['--git-dir', f.remote, 'config', 'core.hooksPath', path.join(f.remote, 'hooks')]); + writeFileSync(path.join(f.remote, 'hooks/update'), '#!/bin/sh\ncase "$1" in refs/tags/*) exit 1 ;; esac\nexit 0\n', { mode: 0o755 }); + let opened = false; + assert.throws(() => releaseCli({ root: f.root, version: '1.2.4', openPullRequest: () => { opened = true; } })); + assert.equal(opened, false); + assert.equal(f.git('ls-remote', 'origin', 'refs/tags/v1.2.4', 'refs/heads/release/v1.2.4'), ''); + assert.equal(f.git('rev-parse', 'origin/main'), f.base); + assert.equal(f.git('rev-parse', 'v1.2.4^{commit}'), f.git('rev-parse', 'HEAD')); +}); + +test('npm release manifests require native SQLite and pin installed external dependencies', t => { + const f = fixture(t); + for (const name of cliExternalModules) { + const directory = path.join(f.root, 'node_modules', name); + mkdirSync(directory, { recursive: true }); + writeFileSync(path.join(directory, 'package.json'), JSON.stringify({ name, version: '1.2.3' })); + } + const manifest = releaseManifest([f.root], '0.4.13'); + assert.equal(manifest.version, '0.4.13'); + assert.equal(manifest.private, true); + assert.equal(manifest.bin.mcode, 'cli.js'); + assert.equal(manifest.dependencies['better-sqlite3'], '1.2.3'); + assert.equal(manifest.dependencies['@vscode/ripgrep'], '1.2.3'); + assert.equal(manifest.optionalDependencies['@mariozechner/clipboard'], '1.2.3'); + assert.equal(manifest.scripts, undefined); + const conflicting = path.join(f.source, 'node_modules/better-sqlite3'); + mkdirSync(conflicting, { recursive: true }); + writeFileSync(path.join(conflicting, 'package.json'), JSON.stringify({ name: 'better-sqlite3', version: '9.9.9' })); + assert.throws(() => releaseManifest([f.root, f.source], '0.4.13'), /Expected one installed version/); +}); + +test('CLI publication requires every supported installation receipt for the exact archive and revision', t => { + const f = fixture(t); + const archive = path.join(f.root, 'minimax-code-0.4.13.tar.gz'); + writeFileSync(archive, 'synthetic archive'); + const sha256 = createHash('sha256').update(readFileSync(archive)).digest('hex'); + writeFileSync(`${archive}.sha256`, `${sha256} ${path.basename(archive)}\n`); + const revision = 'a'.repeat(40); + const reports = path.join(f.root, 'reports'); + for (const target of cliReleaseTargets) { + const directory = path.join(reports, `cli-install-${target.os}-${target.node}`); + mkdirSync(directory, { recursive: true }); + writeFileSync(path.join(directory, 'package-install.json'), JSON.stringify({ + status: 'PASS', version: '0.4.13', revision, sha256, + platform: target.os.startsWith('ubuntu') ? 'linux' : 'darwin', node: `v${target.node}`, + })); + writeFileSync(path.join(directory, 'verification.json'), JSON.stringify({ + status: 'PASS', profile: 'package', revision, gates: [{ name: 'test:release-package', status: 'PASS' }], + })); + } + const options = { archive, reports, version: '0.4.13', revision }; + assert.equal(validateReleaseReports(options), sha256); + assert.throws(() => validateReleaseReports({ ...options, version: '0.4.14' })); + assert.throws(() => validateReleaseReports({ ...options, revision: 'b'.repeat(40) })); + const target = cliReleaseTargets.at(-1); + const receipt = path.join(reports, `cli-install-${target.os}-${target.node}`, 'package-install.json'); + const original = readFileSync(receipt, 'utf8'); + for (const override of [{ sha256: '0'.repeat(64) }, { status: 'FAIL' }, { node: 'v20.0.0' }]) { + writeFileSync(receipt, JSON.stringify({ ...JSON.parse(original), ...override })); + assert.throws(() => validateReleaseReports(options)); + } + rmSync(receipt); + assert.throws(() => validateReleaseReports(options)); + writeFileSync(receipt, original); + writeFileSync(archive, 'changed archive'); + assert.throws(() => validateReleaseReports(options)); +}); + +test('CLI release publishes only tag pushes after full verification and archive installation', () => { + const workflow = parseYaml(readFileSync(new URL('../.github/workflows/cli-release.yml', import.meta.url), 'utf8')); + assert.deepEqual(workflow.on.push, { tags: ['v*'] }); + assert.equal(workflow.on.workflow_dispatch.inputs.tag.required, false); + assert.equal(workflow.permissions.contents, 'read'); + assert.equal(workflow.concurrency['cancel-in-progress'], false); + assert.ok(workflow.jobs.build.steps.some(step => step.run === 'pnpm verify')); + assert.ok(workflow.jobs.build.steps.some(step => step.run?.includes('cliBuildVersion(process.cwd(), tag)'))); + assert.deepEqual(workflow.jobs.publish.needs, ['build', 'install']); + assert.equal(workflow.jobs.publish.if, "github.event_name == 'push'"); + assert.equal(workflow.jobs.publish.permissions.contents, 'write'); + assert.equal(workflow.jobs.install.strategy.matrix, '${{ fromJSON(needs.build.outputs.matrix) }}'); + const install = workflow.jobs.install.steps.find(step => step.run === 'pnpm verify --profile package'); + assert.ok(install.env.MCODE_RELEASE_ARCHIVE.endsWith('.tar.gz')); + for (const job of Object.values(workflow.jobs)) { + for (const step of job.steps) { + if (step.uses && !step.uses.startsWith('./')) assert.match(step.uses, /@[a-f0-9]{40}$/); + if (step.uses?.startsWith('actions/checkout@')) assert.equal(step.with['persist-credentials'], false); + if (step.run) assert.doesNotMatch(step.run, /\$\{\{.*(?:inputs|github\.(?:ref|event))/); + } + } +}); +import { runInNewContext } from 'node:vm'; function fixture(t) { const home = mkdtempSync(path.join(tmpdir(), 'source-sync-')); @@ -193,6 +565,11 @@ test('documentation and archive profiles preserve their required validation gate const archive = f.run(['--profile', 'archive', '--list']); assert.equal(archive.status, 0, archive.stderr); assert.deepEqual(archive.stdout.trim().split('\n'), full.filter(g => g !== 'export source preview')); + const packageProfile = f.run(['--profile', 'package', '--list']); + if (['linux', 'darwin'].includes(process.platform)) { + assert.equal(packageProfile.status, 0, packageProfile.stderr); + assert.equal(packageProfile.stdout.trim(), 'test:release-package'); + } else assert.notEqual(packageProfile.status, 0); const failure = f.run(['--profile', 'docs'], { VERIFY_FIXTURE_FAIL: 'check:source' }); assert.equal(failure.status, 1); assert.equal(f.report().status, 'FAIL'); @@ -230,13 +607,13 @@ test('CI aggregate rejects failed, cancelled, missing and unexpectedly skipped c assert.notEqual(run({ ...full, DOCS_ONLY: scope }), 0); }); -test('ordinary CI preserves three platforms without invoking release-only matrices', () => { +test('ordinary CI pauses Windows without invoking release-only matrices', () => { const readWorkflow = name => parseYaml(readFileSync(new URL(`../.github/workflows/${name}.yml`, import.meta.url), 'utf8')); const ci = readWorkflow('ci'); assert.ok(Object.hasOwn(ci.on, 'pull_request')); assert.deepEqual(ci.on.push.branches, ['main']); assert.deepEqual(Object.keys(ci.jobs).sort(), ['changes', 'docs', 'verification', 'verify']); - assert.deepEqual(ci.jobs.verify.strategy.matrix.os, ['ubuntu-latest', 'macos-latest', 'windows-latest']); + assert.deepEqual(ci.jobs.verify.strategy.matrix.os, ['ubuntu-latest', 'macos-latest']); assert.deepEqual(ci.jobs.verify.strategy.matrix.node, ['24']); assert.deepEqual(ci.jobs.verify.strategy.matrix.include, [{ os: 'ubuntu-latest', node: '24', profile: 'full' }]); assert.equal(ci.jobs.verify.needs, 'changes'); @@ -269,7 +646,7 @@ test('manual source candidates pin every checkout and receipt to the selected re assert.equal(step.env.REVISION, revision); } const validate = workflow.jobs.validate; - assert.deepEqual(validate.strategy.matrix.os, ['ubuntu-latest', 'macos-latest', 'windows-latest']); + assert.deepEqual(validate.strategy.matrix.os, ['ubuntu-latest', 'macos-latest']); assert.ok(validate.steps.some(step => step.run?.includes('--store-dir "$RUNNER_TEMP/candidate-store" --registry https://registry.npmjs.org/'))); const verify = validate.steps.find(step => step.run === 'pnpm verify --profile archive'); assert.equal(verify.env.MCODE_VERIFY_REVISION, revision); @@ -345,7 +722,7 @@ test('source archive rejects traversal, links, Git history and duplicate entries } }); -test('candidate rejects mismatched receipts and requires three successful same-revision reports', t => { +test('candidate rejects mismatched receipts and requires successful same-revision Linux and macOS reports', t => { const f = archiveFixture(t, [{ path: 'minimax-code/README.md', content: 'source' }]); const revision = 'a'.repeat(40); const receipt = { schemaVersion: 1, revision, sha256: createHash('sha256').update(readFileSync(f.archive)).digest('hex'), format: 'source-only-no-git-history', publicationPerformed: false }; @@ -361,20 +738,22 @@ test('candidate rejects mismatched receipts and requires three successful same-r writeFileSync(`${f.archive}.json`, JSON.stringify(receipt)); const reports = path.join(f.directory, 'reports'); mkdirSync(reports); - for (const platform of ['linux', 'darwin', 'win32']) { + for (const platform of ['linux', 'darwin']) { const folder = path.join(reports, platform); mkdirSync(folder); writeFileSync(path.join(folder, 'verification.json'), JSON.stringify({ revision, platform, arch: 'fixture', node: 'v24', profile: 'archive', status: 'PASS', gates: [{ name: 'build', status: 'PASS' }] })); } assert.equal(run('finalize', '--reports', reports).status, 0); - assert.equal(JSON.parse(readFileSync(path.join(f.directory, 'candidate.json'))).sha256, receipt.sha256); - const windows = path.join(reports, 'win32/verification.json'); - const report = JSON.parse(readFileSync(windows)); - for (const change of [{ status: 'FAIL' }, { revision: 'b'.repeat(40) }, { gates: [{ name: 'build', status: 'NOT_RUN' }] }]) { - writeFileSync(windows, JSON.stringify({ ...report, ...change })); + const manifest = JSON.parse(readFileSync(path.join(f.directory, 'candidate.json'))); + assert.equal(manifest.sha256, receipt.sha256); + assert.deepEqual(manifest.validation.map(report => report.platform).sort(), ['darwin', 'linux']); + const macos = path.join(reports, 'darwin/verification.json'); + const report = JSON.parse(readFileSync(macos)); + for (const change of [{ status: 'FAIL' }, { revision: 'b'.repeat(40) }, { gates: [{ name: 'build', status: 'NOT_RUN' }] }, { platform: 'linux' }, { platform: 'win32' }]) { + writeFileSync(macos, JSON.stringify({ ...report, ...change })); assert.notEqual(run('finalize', '--reports', reports).status, 0); } - rmSync(path.dirname(windows), { recursive: true }); + rmSync(path.dirname(macos), { recursive: true }); assert.notEqual(run('finalize', '--reports', reports).status, 0); }); @@ -422,11 +801,13 @@ test('suite runner preserves gate arguments and canonicalizes Windows temporary }); test('public support forms preserve destination URLs and separate Desktop from CLI reports', () => { - const forms = ['01-bug-report.yml', '02-feature-request.yml', '03-question.yml']; + const forms = ['01-bug-report.yml', '02-feature-request.yml', '03-question.yml', 'docs.yml']; for (const name of forms) { const form = parseYaml(readFileSync(new URL(`../.github/ISSUE_TEMPLATE/${name}`, import.meta.url), 'utf8')); const product = form.body.find(field => field.id === 'product'); assert.equal(product.validations.required, name !== '03-question.yml'); + assert.equal(product.attributes.label, 'Product or interface'); + assert.ok(product.attributes.options.includes('CLI - interactive TUI')); assert.ok(product.attributes.options.includes('Desktop app')); assert.ok(product.attributes.options.includes('CLI - ACP')); assert.ok(product.attributes.options.includes('CLI - headless')); @@ -440,6 +821,54 @@ test('public support forms preserve destination URLs and separate Desktop from C assert.equal(classifyChanges(['README_ZH.md', 'README.md']).docsOnly, true); }); +test('issue product labels follow current form answers without replacing unrelated labels', async () => { + const workflow = parseYaml(readFileSync(new URL('../.github/workflows/label-issue-product.yml', import.meta.url), 'utf8')); + assert.deepEqual(workflow.on, { issues: { types: ['opened', 'edited'] } }); + assert.deepEqual(workflow.permissions, { issues: 'write' }); + assert.equal(workflow.concurrency['cancel-in-progress'], false); + const job = workflow.jobs['label-product']; + assert.equal(job.if, "${{ github.repository == 'MiniMax-AI/minimax-code' && github.event.repository.private == false && !github.event.issue.pull_request }}"); + assert.equal(job.steps.length, 1); // No checkout or execution of issue-supplied code. + const run = job.steps[0].run; + assert.doesNotMatch(run, /\$\{\{/); + const script = run.match(/^node <<'EOF'\r?\n([\s\S]*?)\r?\nEOF\s*$/)?.[1]; + assert.ok(script); + async function replay(body, labels, failureStatus) { + const writes = []; + const issuePath = '/repos/MiniMax-AI/minimax-code/issues/7'; + await runInNewContext(script, { + require: name => { + assert.equal(name, 'node:fs'); + return { readFileSync: () => JSON.stringify({ issue: { number: 7, body: '### Product or interface\n\nDesktop app', labels: [] } }) }; + }, + process: { env: { GITHUB_EVENT_PATH: 'fixture.json', GITHUB_REPOSITORY: 'MiniMax-AI/minimax-code', GITHUB_API_URL: 'https://api.github.invalid', GH_TOKEN: 'synthetic' } }, + fetch: async (url, options) => { + assert.equal(options.headers.Authorization, 'Bearer synthetic'); + if (options.method === 'GET') { + assert.equal(url, `https://api.github.invalid${issuePath}`); + return { ok: !failureStatus, status: failureStatus || 200, json: async () => ({ body, labels: labels.map(name => ({ name })) }) }; + } + writes.push({ method: options.method, path: url.replace(`https://api.github.invalid${issuePath}`, ''), body: options.body && JSON.parse(options.body) }); + return { ok: true, status: 204 }; + }, + }); + return writes; + } + const answer = product => `### Product or interface\n\n${product}\n\n### Question\n\nSynthetic question`; + const add = name => ({ method: 'POST', path: '/labels', body: { labels: [name] } }); + const remove = name => ({ method: 'DELETE', path: `/labels/${name}`, body: undefined }); + assert.deepEqual(await replay(answer('CLI - interactive TUI'), ['bug', 'desktop']), [add('tui'), remove('desktop')]); + assert.deepEqual(await replay(answer('Desktop app').replace(/\n/g, '\r\n'), ['question']), [add('desktop')]); + assert.deepEqual(await replay(answer('Desktop app'), ['desktop', 'bug']), []); + for (const product of ['CLI - headless', 'CLI - ACP', 'Source build or repository tooling']) { + assert.deepEqual(await replay(answer(product), ['tui', 'bug']), [remove('tui')]); + } + for (const body of [null, 'Desktop app', answer('_No response_'), answer('$(touch must-not-execute)')]) { + assert.deepEqual(await replay(body, ['desktop', 'bug']), []); + } + await assert.rejects(replay(answer('Desktop app'), [], 403), /GitHub GET failed: 403/); +}); + test('issue forms label incoming reports for triage and retain collaborator-only PR guidance', () => { for (const [name, type] of [ ['01-bug-report.yml', 'bug'], ['02-feature-request.yml', 'enhancement'], diff --git a/test/vitest-suites.json b/test/vitest-suites.json index 5fe5dcf68..e3a14bcad 100644 --- a/test/vitest-suites.json +++ b/test/vitest-suites.json @@ -7,6 +7,7 @@ "packages/agent-tools/src/shared/replace-all-edit.test.ts", "packages/local-runtime-v2/test/unit/agent/agent-import.test.ts", "packages/tui/test/unit/auth-application.test.ts", + "packages/tui/test/unit/tui-assistant-content.test.ts", "packages/tui/test/unit/auth-session.test.ts", "packages/tui/test/unit/cli-auth-command.test.ts", "packages/tui/test/unit/network-proxy.test.ts", @@ -143,8 +144,14 @@ "packages/tui/test/unit/tui/controller/product/feature-flow.test.ts", "packages/tui/test/unit/tui/features/settings/hotkeys-picker.test.ts", "packages/config/test/config-file-permissions.test.ts", + "packages/config/test/config-update-preservation.test.ts", "packages/agent-modules/context-manager/test/compaction-usage.test.ts", "packages/local-runtime/test/unit/session-diff-fallback.test.ts", + "test/history-processing.test.ts", + "packages/tui/test/unit/tui-feature-viewport-contract.test.ts", + "packages/tui/test/unit/tui-session-history-flow.test.ts", + "packages/tui/test/unit/tui-session-mutation-panels.test.ts", + "packages/tui/test/unit/tui-surface-host.test.ts", "packages/tui/test/unit/egress-guard.test.ts" ], "status-contract": [