mirror of
https://github.com/cline/cline.git
synced 2026-09-12 17:19:32 +08:00
Compare commits
39
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
396032cd3b | ||
|
|
f33ab3a872 | ||
|
|
2ca8364ffc | ||
|
|
2ef81be703 | ||
|
|
359445ae0c | ||
|
|
d9e2e9c76b | ||
|
|
d859a86a6f | ||
|
|
0b7b9c1b3d | ||
|
|
557d725690 | ||
|
|
7274d8badc | ||
|
|
d1837366c0 | ||
|
|
c380daf4a3 | ||
|
|
c564045d81 | ||
|
|
9a5e1751b2 | ||
|
|
131e25e1a1 | ||
|
|
a7ff007af9 | ||
|
|
ef27f45080 | ||
|
|
e3c6d51072 | ||
|
|
48bac25548 | ||
|
|
37f5f104f3 | ||
|
|
3577b52404 | ||
|
|
1843bc8ed0 | ||
|
|
fead00ec57 | ||
|
|
238107d21c | ||
|
|
2063a661bd | ||
|
|
ec02d5862e | ||
|
|
8452084842 | ||
|
|
a41129a5db | ||
|
|
1ea34be611 | ||
|
|
e72bc3cd14 | ||
|
|
9c907af826 | ||
|
|
e8d3d82522 | ||
|
|
7f9d2e96d9 | ||
|
|
d618f8073a | ||
|
|
eb21ba583c | ||
|
|
f29c25395c | ||
|
|
84c9b587a6 | ||
|
|
6dca234d8e | ||
|
|
9217eacbbd |
@@ -0,0 +1,158 @@
|
||||
---
|
||||
name: publish-ui
|
||||
description: Prepare, validate, and publish standalone @cline/ui npm releases. Use when bumping the UI package version, publishing latest or next through ui-publish.yml, checking UI release readiness, or completing the one-time npm trusted-publishing bootstrap.
|
||||
---
|
||||
|
||||
# Publish UI
|
||||
|
||||
Release `@cline/ui` independently from the Cline SDK runtime packages.
|
||||
|
||||
## Release contract
|
||||
|
||||
- Version source: `sdk/packages/ui/package.json`.
|
||||
- Workflow: `.github/workflows/ui-publish.yml`.
|
||||
- The package keeps `internal: true` only to stay out of the SDK's shared
|
||||
version/publish scripts. It is still a public npm package because
|
||||
`private: false` and `publishConfig.access: public` control npm publication.
|
||||
- `latest` is the production channel. `next` is an opt-in preview channel.
|
||||
- Use prerelease versions such as `0.2.0-next.0` for `next`; do not publish a
|
||||
version intended for `latest` under the preview tag because npm versions
|
||||
cannot be republished.
|
||||
- There is no UI Git tag, GitHub release, schedule, or Slack announcement.
|
||||
- The workflow runs only by manual dispatch. Every release attempt runs the UI
|
||||
quality checks before publishing and requires `confirm_publish=publish` from
|
||||
`main`.
|
||||
- The publish job and npm trust relationship use the protected `Publish`
|
||||
environment.
|
||||
- Every npm publication needs a new semver version; npm versions are immutable.
|
||||
- Always ask before pushing commits, triggering the publish workflow, changing
|
||||
npm trust settings, or running a local publish command.
|
||||
|
||||
## Normal release
|
||||
|
||||
1. Inspect the branch, current version, npm state, and UI changes.
|
||||
|
||||
```sh
|
||||
git status --short --branch
|
||||
node -p "require('./sdk/packages/ui/package.json').version"
|
||||
npm view @cline/ui dist-tags versions --json
|
||||
git log --oneline --no-merges -- \
|
||||
sdk/packages/ui apps/examples/desktop-app/webview/components/views/chat \
|
||||
.github/workflows/ui-publish.yml
|
||||
```
|
||||
|
||||
2. Ask for the npm channel and version together. For `latest`, ask for patch,
|
||||
minor, major, or an explicit version. For `next`, require an explicit
|
||||
prerelease version such as `0.2.0-next.0`. Do not guess. Update only
|
||||
`sdk/packages/ui/package.json` and its workspace version in `bun.lock`. Do
|
||||
not run the SDK version command.
|
||||
|
||||
3. Validate the release candidate.
|
||||
|
||||
```sh
|
||||
bun install --filter @cline/ui --filter @cline/code --frozen-lockfile
|
||||
bun -F @cline/ui typecheck
|
||||
bun -F @cline/ui test
|
||||
bun -F @cline/ui test:package
|
||||
bun -F @cline/ui build-storybook
|
||||
bun -F @cline/code test:chat-ui
|
||||
```
|
||||
|
||||
The packed-package test installs the tarball with Bun/React 19 and with
|
||||
npm/Node/React 18.
|
||||
Inspect `bun pm pack --dry-run` when the exported file set changed.
|
||||
|
||||
4. Commit the version bump separately from feature work. Ask before pushing.
|
||||
|
||||
```sh
|
||||
git add sdk/packages/ui/package.json bun.lock
|
||||
git commit -m "chore(ui): release vX.Y.Z"
|
||||
git push origin HEAD
|
||||
```
|
||||
|
||||
5. After the release commit reaches `main`, restate the selected npm tag and ask
|
||||
for explicit publish approval. Then trigger and watch the standalone
|
||||
workflow:
|
||||
|
||||
```sh
|
||||
run_url=$(gh workflow run ui-publish.yml --ref main \
|
||||
-f npm_tag=latest \
|
||||
-f confirm_publish=publish)
|
||||
test -n "$run_url"
|
||||
run_id=${run_url##*/}
|
||||
gh run watch "$run_id" --exit-status
|
||||
```
|
||||
|
||||
Use `npm_tag=next` only for a deliberate preview. Do not report success until
|
||||
the workflow succeeds and npm shows the exact version under the selected tag.
|
||||
|
||||
```sh
|
||||
npm view @cline/ui dist-tags versions --json
|
||||
```
|
||||
|
||||
## One-time npm bootstrap
|
||||
|
||||
Use this only while `npm view @cline/ui` returns `E404`. npm requires the
|
||||
package to exist before its GitHub trusted publisher can be configured.
|
||||
|
||||
1. Merge the package and `ui-publish.yml` to `main`. Start from a clean,
|
||||
reviewed `main` checkout. Verify authentication, account 2FA, and write
|
||||
access to the `@cline` npm organization. The `npm trust` command in step 4
|
||||
requires npm CLI 11.15 or newer; the automated trusted-publishing workflow
|
||||
itself enforces npm 11.5.1 or newer.
|
||||
|
||||
```sh
|
||||
npm --version
|
||||
npm whoami
|
||||
npm view @cline/ui version
|
||||
```
|
||||
|
||||
If npm is older than 11.15, ask before upgrading with
|
||||
`npm install -g npm@^11.15.0`.
|
||||
|
||||
2. Run the normal release validation in step 3 above. Then build, pack, test,
|
||||
and inspect the exact initial tarball. Record the absolute archive path
|
||||
printed by the final command.
|
||||
|
||||
```sh
|
||||
bun -F @cline/ui build
|
||||
pack_dir=$(mktemp -d)
|
||||
(cd sdk/packages/ui && bun pm pack --ignore-scripts --destination "$pack_dir" --quiet)
|
||||
tarball=$(find "$pack_dir" -maxdepth 1 -name '*.tgz' -print -quit)
|
||||
test -n "$tarball"
|
||||
bun sdk/packages/ui/scripts/smoke-package.ts "$tarball"
|
||||
tar -tzf "$tarball"
|
||||
printf 'Bootstrap archive: %s\n' "$tarball"
|
||||
```
|
||||
|
||||
3. Ask for explicit approval, then publish the initial version publicly under
|
||||
`latest`:
|
||||
|
||||
```sh
|
||||
npm publish /absolute/path/from-step-2.tgz --access public --tag latest
|
||||
```
|
||||
|
||||
4. Ask separately before configuring the standalone workflow as the trusted
|
||||
publisher:
|
||||
|
||||
```sh
|
||||
npm trust github @cline/ui \
|
||||
--repo cline/cline \
|
||||
--file ui-publish.yml \
|
||||
--env Publish \
|
||||
--allow-publish
|
||||
```
|
||||
|
||||
5. Verify both package state and trust. Every later release uses the workflow;
|
||||
do not add a long-lived npm token.
|
||||
|
||||
```sh
|
||||
npm view @cline/ui dist-tags versions --json
|
||||
npm trust list @cline/ui
|
||||
```
|
||||
|
||||
## Final report
|
||||
|
||||
Report the version and npm tag, release commit, whether anything was pushed,
|
||||
workflow URL or bootstrap result, npm verification, and tests/builds run. If
|
||||
the package still returns `E404`, state that bootstrap remains required.
|
||||
@@ -0,0 +1,4 @@
|
||||
interface:
|
||||
display_name: "Publish UI"
|
||||
short_description: "Prepare and publish the Cline UI package"
|
||||
default_prompt: "Use $publish-ui to prepare and publish a new @cline/ui npm release."
|
||||
@@ -8,8 +8,9 @@ HTTP-controlled debugger for the VSCode extension at `src/dev/debug-harness/serv
|
||||
# Build extension first if needed (protos + esbuild):
|
||||
bun run protos && IS_DEV=true bun esbuild.mjs
|
||||
|
||||
# Launch (skip-build if already built):
|
||||
bun src/dev/debug-harness/server.ts --skip-build --auto-launch
|
||||
# Launch (skip-build if already built). Run with node, NOT bun — Playwright's
|
||||
# Electron launch times out under bun:
|
||||
node src/dev/debug-harness/server.ts --skip-build --auto-launch
|
||||
|
||||
# In another terminal:
|
||||
curl localhost:19229/api -d '{"method":"status"}'
|
||||
|
||||
@@ -16,6 +16,7 @@ This file is the secret sauce for working effectively in this codebase. It captu
|
||||
- The whole repo (including `apps/vscode`) uses **bun** for package management and task running. Emit `bun run X` / `bun install` / `bunx <bin>` / `bun file.ts`, never npm/npx. Node remains the *runtime* (VS Code's extension host and the standalone cline-core are Node), so Node-runtime tokens are legitimate and must not be "fixed" to bun — see @.clinerules/bun-and-node.md for the keep-list vs rewrite-list.
|
||||
- Avoid provider-specific string matching / hardcoded provider branches when fixing provider/config plumbing. Prefer provider metadata, shared catalog/defaults, explicit protocol/client capabilities, or centralized normalization utilities that apply by data shape rather than `providerId === "..."`. If a provider exception seems necessary, stop and explain why instead of adding ad-hoc string matching.
|
||||
- This is a VS Code extension—check `package.json` for available scripts before trying to verify builds (e.g., `bun run compile`, not `bun run build`).
|
||||
- When reading a configuration files that users may edit, use `readFileStrippingUtf8Bom`, `readFileSyncStrippingUtf8Bom`, or `stripUtf8Bom` from `@cline/shared/node`. DON'T strip byte order marks of user files handled by tools/passed to models.
|
||||
- When creating PRs, contributors should not create changelog-entry files. Maintainers handle release versioning and changelog curation during the release process.
|
||||
- When adding new feature flags, see this PR as a reference https://github.com/cline/cline/pull/7566
|
||||
- Additional instructions about making requests: @.clinerules/network.md
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
name: ext-vscode-ab-package
|
||||
|
||||
# Build (and optionally publish) the combined A/B VSIX: a tiny loader plus two
|
||||
# complete extension bundles — `next/` from the SDK-based apps/vscode on main,
|
||||
# `legacy/` from the legacy-extension branch. Cohort selection happens at
|
||||
# runtime via PostHog flags; see apps/vscode-rollout/README.md for the design
|
||||
# and the rollout runbook.
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: "Combined VSIX version — must exceed every previously published version (e.g. 4.1.0)"
|
||||
required: true
|
||||
type: string
|
||||
next-ref:
|
||||
description: "Ref to build the next (SDK) bundle from"
|
||||
required: true
|
||||
default: "main"
|
||||
type: string
|
||||
legacy-ref:
|
||||
description: "Ref to build the legacy bundle from"
|
||||
required: true
|
||||
default: "legacy-extension"
|
||||
type: string
|
||||
publish:
|
||||
description: "Publish to the VS Code Marketplace (unchecked: just build the .vsix artifact)"
|
||||
required: true
|
||||
default: false
|
||||
type: boolean
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ext-vscode-ab-package-${{ github.event.inputs.version }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
package:
|
||||
name: Build combined (legacy + next) VSIX
|
||||
runs-on: ubuntu-latest
|
||||
environment: publish
|
||||
steps:
|
||||
- name: Checkout next (SDK) source
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event.inputs.next-ref }}
|
||||
path: next-src
|
||||
lfs: true
|
||||
|
||||
- name: Checkout legacy source
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event.inputs.legacy-ref }}
|
||||
path: legacy-src
|
||||
lfs: true
|
||||
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: 1.3.14
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
- name: Install next workspace dependencies
|
||||
working-directory: next-src
|
||||
run: bun install
|
||||
|
||||
# @cline/* are local workspace symlinks to source packages; apps/vscode's
|
||||
# `package` script does NOT build them, so without this the esbuild step
|
||||
# fails on a fresh checkout. (The nightly workflow already does this.)
|
||||
- name: Build SDK packages
|
||||
working-directory: next-src
|
||||
run: bun run build:sdk
|
||||
|
||||
# Stamp the combined version into each bundle's package.json AFTER
|
||||
# install and BEFORE its build: the About tab and telemetry
|
||||
# extension_version read the bundle's own manifest, so without this
|
||||
# the VSIX reports three different versions depending on where you
|
||||
# look. (The nightly workflow gets the same alignment via nightlify.mjs.)
|
||||
- name: Align next bundle version
|
||||
working-directory: next-src/apps/vscode-rollout
|
||||
run: node scripts/set-version.mjs --dir "$GITHUB_WORKSPACE/next-src/apps/vscode" --version "${{ github.event.inputs.version }}"
|
||||
|
||||
- name: Build next bundle
|
||||
working-directory: next-src/apps/vscode
|
||||
env:
|
||||
CLINE_ENVIRONMENT: production
|
||||
TELEMETRY_SERVICE_API_KEY: ${{ secrets.TELEMETRY_SERVICE_API_KEY }}
|
||||
ERROR_SERVICE_API_KEY: ${{ secrets.ERROR_SERVICE_API_KEY }}
|
||||
# Inlined by esbuild: attributes every telemetry event with
|
||||
# extension_variant and unlocks the bundle's authoritative
|
||||
# extension.rollout.bundle_activated capture. Rollout builds only.
|
||||
CLINE_ROLLOUT_VARIANT: next
|
||||
# Match the stable publish workflow's OpenTelemetry production defaults.
|
||||
OTEL_TELEMETRY_ENABLED: ${{ secrets.OTEL_TELEMETRY_ENABLED }}
|
||||
OTEL_LOGS_EXPORTER: otlp
|
||||
OTEL_METRICS_EXPORTER: otlp
|
||||
OTEL_EXPORTER_OTLP_PROTOCOL: ${{ secrets.OTEL_EXPORTER_OTLP_PROTOCOL }}
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT: ${{ secrets.OTEL_EXPORTER_OTLP_ENDPOINT }}
|
||||
OTEL_EXPORTER_OTLP_HEADERS: ${{ secrets.OTEL_EXPORTER_OTLP_HEADERS }}
|
||||
run: bun run package
|
||||
|
||||
- name: Install legacy dependencies
|
||||
working-directory: legacy-src
|
||||
run: |
|
||||
npm --prefix apps/vscode install --include=optional
|
||||
npm --prefix apps/vscode/webview-ui install --include=optional
|
||||
|
||||
- name: Align legacy bundle version
|
||||
working-directory: next-src/apps/vscode-rollout
|
||||
run: node scripts/set-version.mjs --dir "$GITHUB_WORKSPACE/legacy-src/apps/vscode" --version "${{ github.event.inputs.version }}"
|
||||
|
||||
- name: Build legacy bundle
|
||||
working-directory: legacy-src/apps/vscode
|
||||
env:
|
||||
CLINE_ENVIRONMENT: production
|
||||
TELEMETRY_SERVICE_API_KEY: ${{ secrets.TELEMETRY_SERVICE_API_KEY }}
|
||||
ERROR_SERVICE_API_KEY: ${{ secrets.ERROR_SERVICE_API_KEY }}
|
||||
CLINE_ROLLOUT_VARIANT: legacy
|
||||
# Match the stable publish workflow's OpenTelemetry production defaults.
|
||||
OTEL_TELEMETRY_ENABLED: ${{ secrets.OTEL_TELEMETRY_ENABLED }}
|
||||
OTEL_LOGS_EXPORTER: otlp
|
||||
OTEL_METRICS_EXPORTER: otlp
|
||||
OTEL_EXPORTER_OTLP_PROTOCOL: ${{ secrets.OTEL_EXPORTER_OTLP_PROTOCOL }}
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT: ${{ secrets.OTEL_EXPORTER_OTLP_ENDPOINT }}
|
||||
OTEL_EXPORTER_OTLP_HEADERS: ${{ secrets.OTEL_EXPORTER_OTLP_HEADERS }}
|
||||
run: npm run package
|
||||
|
||||
- name: Build loader and run rollout tests
|
||||
working-directory: next-src/apps/vscode-rollout
|
||||
env:
|
||||
TELEMETRY_SERVICE_API_KEY: ${{ secrets.TELEMETRY_SERVICE_API_KEY }}
|
||||
run: |
|
||||
bun run typecheck
|
||||
bun run test
|
||||
bun run build:production
|
||||
|
||||
- name: Stitch combined VSIX staging
|
||||
working-directory: next-src/apps/vscode-rollout
|
||||
run: |
|
||||
node scripts/stitch.mjs \
|
||||
--next "$GITHUB_WORKSPACE/next-src/apps/vscode" \
|
||||
--legacy "$GITHUB_WORKSPACE/legacy-src/apps/vscode" \
|
||||
--loader dist/extension.js \
|
||||
--version "${{ github.event.inputs.version }}" \
|
||||
--out "$GITHUB_WORKSPACE/staging"
|
||||
|
||||
- name: Smoke-test loader against staging
|
||||
working-directory: next-src/apps/vscode-rollout
|
||||
run: node scripts/smoke-loader.mjs "$GITHUB_WORKSPACE/staging"
|
||||
|
||||
# This workflow publishes the STABLE identity. If nightlify ever leaks
|
||||
# into this path the union manifest would ship under the wrong name.
|
||||
# The bundle sub-manifest checks guard the set-version.mjs stamping:
|
||||
# the About tab and telemetry extension_version read those files.
|
||||
- name: Assert stable manifest identity
|
||||
working-directory: staging
|
||||
env:
|
||||
EXPECTED_VERSION: ${{ github.event.inputs.version }}
|
||||
run: |
|
||||
node -e '
|
||||
const assert = require("node:assert");
|
||||
const expected = process.env.EXPECTED_VERSION;
|
||||
const pkg = require("./package.json");
|
||||
assert.equal(pkg.name, "claude-dev", `unexpected name ${pkg.name}`);
|
||||
assert.equal(pkg.publisher, "saoudrizwan", `unexpected publisher ${pkg.publisher}`);
|
||||
assert.equal(pkg.version, expected, `unexpected union version ${pkg.version}`);
|
||||
for (const bundle of ["next", "legacy"]) {
|
||||
const sub = require(`./${bundle}/package.json`);
|
||||
assert.equal(sub.version, expected, `unexpected ${bundle} bundle version ${sub.version}`);
|
||||
}
|
||||
console.log(`stable identity ok: ${pkg.publisher}.${pkg.name}@${pkg.version} (bundle versions aligned)`);
|
||||
'
|
||||
|
||||
- name: Package VSIX
|
||||
working-directory: staging
|
||||
run: |
|
||||
npm install -g @vscode/vsce
|
||||
# Preserve the narrowly scoped VSCE `sendgrid` scanner exemption used by
|
||||
# both standalone bundle workflows. No SendGrid credential is intentionally
|
||||
# supplied here; inspect the reported artifact before widening the exemption.
|
||||
vsce package --no-dependencies --allow-package-secrets sendgrid --out "claude-dev-${{ github.event.inputs.version }}.vsix"
|
||||
|
||||
- name: Upload VSIX artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: claude-dev-${{ github.event.inputs.version }}
|
||||
path: staging/claude-dev-${{ github.event.inputs.version }}.vsix
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Publish to Marketplace
|
||||
if: ${{ github.event.inputs.publish == 'true' }}
|
||||
working-directory: staging
|
||||
env:
|
||||
VSCE_PAT: ${{ secrets.VSCE_PAT }}
|
||||
run: |
|
||||
if [[ -z "$VSCE_PAT" ]]; then
|
||||
echo "Error: VSCE_PAT is required to publish."
|
||||
exit 1
|
||||
fi
|
||||
vsce publish --no-dependencies --packagePath "claude-dev-${{ github.event.inputs.version }}.vsix"
|
||||
@@ -1,17 +1,40 @@
|
||||
name: ext-vscode-publish-nightly
|
||||
|
||||
# Publishes saoudrizwan.cline-nightly as the COMBINED A/B VSIX: the rollout
|
||||
# loader plus two complete extension bundles — `next/` from this ref's
|
||||
# apps/vscode (SDK-based) and `legacy/` from the legacy-extension branch.
|
||||
# Cohort selection happens at runtime via PostHog flags; see
|
||||
# apps/vscode-rollout/README.md for the design and rollout runbook.
|
||||
#
|
||||
# The stable-identity equivalent of this pipeline is ext-vscode-ab-package.yml
|
||||
# (manual dispatch, publishes claude-dev). Shared logic lives in
|
||||
# apps/vscode-rollout/scripts (nightlify/gen-manifest/stitch/smoke) so both
|
||||
# workflows stay thin. The single-bundle nightly path this replaced
|
||||
# (apps/vscode/scripts/publish-nightly.mjs) remains for manual feature-branch
|
||||
# pre-release publishes.
|
||||
|
||||
on:
|
||||
schedule:
|
||||
# Every day at 4:00 AM PST (12:00 UTC)
|
||||
- cron: "0 12 * * *"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
legacy-ref:
|
||||
description: "Ref to build the legacy bundle from"
|
||||
required: false
|
||||
default: "legacy-extension"
|
||||
type: string
|
||||
dry-run:
|
||||
description: "Build and upload the .vsix artifact without publishing or tagging"
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
|
||||
run-name: "Publish Nightly from ${{ github.ref_name }} @ ${{ github.sha }}"
|
||||
run-name: "Publish Combined Nightly from ${{ github.ref_name }} @ ${{ github.sha }}"
|
||||
|
||||
# Prevent concurrent publish runs on the same branch. The nightly publish script
|
||||
# generates the extension version from a seconds-resolution timestamp, so parallel
|
||||
# runs on the same ref can collide on the same version and cause publish failures
|
||||
# or inconsistent tagging. Runs on different branches proceed independently.
|
||||
# Prevent concurrent publish runs on the same branch: the version is generated
|
||||
# from a seconds-resolution timestamp, so parallel runs on the same ref can
|
||||
# collide on the same version and cause publish failures or inconsistent tagging.
|
||||
concurrency:
|
||||
group: ext-vscode-publish-nightly-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
@@ -20,7 +43,7 @@ permissions: {}
|
||||
|
||||
jobs:
|
||||
test:
|
||||
if: github.repository == 'cline/cline' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/dpc/sdk-migration-simpler-login')
|
||||
if: github.repository == 'cline/cline'
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
@@ -30,60 +53,79 @@ jobs:
|
||||
needs: test
|
||||
permissions:
|
||||
contents: write
|
||||
name: Publish Cline (Nightly) Extension
|
||||
if: github.repository == 'cline/cline' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/dpc/sdk-migration-simpler-login')
|
||||
name: Publish Cline (Nightly) Combined Extension
|
||||
# Defense in depth: only protected main may enter the publishing environment.
|
||||
# This `if` is advisory because a dispatched branch runs its own copy of this
|
||||
# file; the enforced gate is the PublishNightly environment's deployment-branch
|
||||
# policy, which must also allow only main.
|
||||
if: github.repository == 'cline/cline' && github.ref == 'refs/heads/main'
|
||||
runs-on: ubuntu-latest
|
||||
environment: PublishNightly
|
||||
# The VS Code extension's package.json and lockfiles live under apps/vscode/
|
||||
# (the repo root has no package.json). Mirror ext-vscode-test.yml so install
|
||||
# and publish steps run in the correct workspace.
|
||||
defaults:
|
||||
run:
|
||||
working-directory: apps/vscode
|
||||
|
||||
steps:
|
||||
- name: Checkout selected branch
|
||||
- name: Checkout next (SDK) source
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.sha }}
|
||||
path: next-src
|
||||
lfs: true
|
||||
persist-credentials: false
|
||||
|
||||
- name: Show build source
|
||||
working-directory: ${{ github.workspace }}
|
||||
- name: Checkout legacy source
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
# NOTE: inputs are empty strings on `schedule` events, so the ||
|
||||
# fallback (not the input's declared default) is what the cron uses.
|
||||
ref: ${{ inputs.legacy-ref || 'legacy-extension' }}
|
||||
path: legacy-src
|
||||
lfs: true
|
||||
persist-credentials: false
|
||||
|
||||
- name: Show build sources
|
||||
env:
|
||||
# Routed through env rather than interpolated into the script body so
|
||||
# a crafted dispatch input can't inject shell (hygiene: dispatchers
|
||||
# need write access anyway, but keep the pattern clean).
|
||||
LEGACY_REF: ${{ inputs.legacy-ref || 'legacy-extension' }}
|
||||
run: |
|
||||
echo "Building ref: $GITHUB_REF"
|
||||
echo "Building sha: $GITHUB_SHA"
|
||||
git --no-pager log -1 --oneline
|
||||
echo "next: $(git -C next-src rev-parse HEAD)"
|
||||
echo "legacy: $(git -C legacy-src rev-parse HEAD) ($LEGACY_REF)"
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: 1.3.14
|
||||
|
||||
# Node is still REQUIRED in the publish job (not just for install): the publish
|
||||
# scripts run as `node ./scripts/publish-*.mjs` and shell out to `npx ovsx`.
|
||||
# setup-bun does not provide a Node runtime, so keep setup-node here.
|
||||
# Pinned to Node 22 because newer LTS (Node 24 / npm 11) can make vsce's
|
||||
# `npm list` dependency detection fail with ELSPROBLEMS during packaging.
|
||||
# Node is required beyond install: the rollout scripts run under node and
|
||||
# publishing shells out to vsce/ovsx. Pinned to Node 22 because newer LTS
|
||||
# (Node 24 / npm 11) can make vsce's dependency detection fail.
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
# Single root install resolves the whole bun workspace at once (replaces the
|
||||
# per-package `npm ci` steps for apps/vscode + webview-ui).
|
||||
- name: Install workspace dependencies
|
||||
working-directory: ${{ github.workspace }}
|
||||
# ONE version for the next bundle, the legacy bundle, and the union
|
||||
# manifest: gen-manifest hard-fails if the bundle identities diverge.
|
||||
# Same scheme as the standalone nightly: <major>.<minor>.<unix-seconds>
|
||||
# from next's base version, so it keeps outranking earlier nightlies.
|
||||
- name: Compute nightly version
|
||||
id: version
|
||||
run: |
|
||||
BASE=$(node -p "require('./next-src/apps/vscode/package.json').version")
|
||||
VERSION="$(echo "$BASE" | cut -d. -f1,2).$(date +%s)"
|
||||
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
|
||||
echo "Combined nightly version: $VERSION (base $BASE)"
|
||||
|
||||
- name: Install next workspace dependencies
|
||||
working-directory: next-src
|
||||
run: bun install --frozen-lockfile
|
||||
|
||||
# @cline/* are local workspace symlinks to source packages; build dist/ before
|
||||
# packaging/publishing the extension.
|
||||
- name: Build SDK packages
|
||||
working-directory: ${{ github.workspace }}
|
||||
working-directory: next-src
|
||||
run: bun run build:sdk
|
||||
|
||||
- name: Assert better-sqlite3 native binary present
|
||||
working-directory: next-src/apps/vscode
|
||||
run: |
|
||||
NODE_FILE="node_modules/better-sqlite3/build/Release/better_sqlite3.node"
|
||||
if [ ! -f "$NODE_FILE" ]; then
|
||||
@@ -93,20 +135,24 @@ jobs:
|
||||
fi
|
||||
echo "Found better-sqlite3 native binary: $NODE_FILE"
|
||||
|
||||
# vsce is now a workspace devDependency (on node_modules/.bin), but ovsx is not
|
||||
# vendored and the publish script invokes it via `npx ovsx`, so install ovsx
|
||||
# globally here (npm is available via setup-node). vsce is installed globally
|
||||
# too to preserve the script's existing PATH expectations.
|
||||
- name: Install Publishing Tools
|
||||
run: npm install -g @vscode/vsce ovsx
|
||||
# Rewrite each bundle's package.json to the cline-nightly identity BEFORE
|
||||
# its build (runtime command/config IDs derive from the manifest) and
|
||||
# AFTER dependency install (workspace self-links key off the original
|
||||
# package name).
|
||||
- name: Nightlify next bundle manifest
|
||||
working-directory: next-src/apps/vscode-rollout
|
||||
run: node scripts/nightlify.mjs --dir "$GITHUB_WORKSPACE/next-src/apps/vscode" --version "${{ steps.version.outputs.version }}"
|
||||
|
||||
- name: Publish Nightly Extension
|
||||
- name: Build next bundle
|
||||
working-directory: next-src/apps/vscode
|
||||
env:
|
||||
VSCE_PAT: ${{ secrets.VSCE_PAT }}
|
||||
OVSX_PAT: ${{ secrets.OVSX_PAT }}
|
||||
CLINE_ENVIRONMENT: production
|
||||
TELEMETRY_SERVICE_API_KEY: ${{ secrets.TELEMETRY_SERVICE_API_KEY }}
|
||||
ERROR_SERVICE_API_KEY: ${{ secrets.ERROR_SERVICE_API_KEY }}
|
||||
CLINE_ENVIRONMENT: production
|
||||
# Inlined by esbuild: attributes every telemetry event with
|
||||
# extension_variant and unlocks the bundle's authoritative
|
||||
# extension.rollout.bundle_activated capture. Rollout builds only.
|
||||
CLINE_ROLLOUT_VARIANT: next
|
||||
# OpenTelemetry production defaults (can be overridden at runtime)
|
||||
OTEL_TELEMETRY_ENABLED: ${{ secrets.OTEL_TELEMETRY_ENABLED }}
|
||||
OTEL_LOGS_EXPORTER: otlp
|
||||
@@ -114,12 +160,129 @@ jobs:
|
||||
OTEL_EXPORTER_OTLP_PROTOCOL: ${{ secrets.OTEL_EXPORTER_OTLP_PROTOCOL }}
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT: ${{ secrets.OTEL_EXPORTER_OTLP_ENDPOINT }}
|
||||
OTEL_EXPORTER_OTLP_HEADERS: ${{ secrets.OTEL_EXPORTER_OTLP_HEADERS }}
|
||||
# The script itself runs under `node ./scripts/publish-nightly.mjs`; bun run
|
||||
# just launches it. Node + npm (for `npx ovsx`) are provided by setup-node above.
|
||||
run: bun run publish:marketplace:nightly
|
||||
run: bun run package
|
||||
|
||||
- name: Install legacy dependencies
|
||||
working-directory: legacy-src
|
||||
run: |
|
||||
npm --prefix apps/vscode install --include=optional
|
||||
npm --prefix apps/vscode/webview-ui install --include=optional
|
||||
|
||||
- name: Nightlify legacy bundle manifest
|
||||
working-directory: next-src/apps/vscode-rollout
|
||||
run: node scripts/nightlify.mjs --dir "$GITHUB_WORKSPACE/legacy-src/apps/vscode" --version "${{ steps.version.outputs.version }}"
|
||||
|
||||
- name: Build legacy bundle
|
||||
working-directory: legacy-src/apps/vscode
|
||||
env:
|
||||
CLINE_ENVIRONMENT: production
|
||||
TELEMETRY_SERVICE_API_KEY: ${{ secrets.TELEMETRY_SERVICE_API_KEY }}
|
||||
ERROR_SERVICE_API_KEY: ${{ secrets.ERROR_SERVICE_API_KEY }}
|
||||
CLINE_ROLLOUT_VARIANT: legacy
|
||||
# Legacy's esbuild inlines these too (its own publish workflow passes
|
||||
# them) — omitting them here would ship the legacy bundle with the
|
||||
# OTel pipeline dead, unlike what legacy users get today.
|
||||
OTEL_TELEMETRY_ENABLED: ${{ secrets.OTEL_TELEMETRY_ENABLED }}
|
||||
OTEL_LOGS_EXPORTER: otlp
|
||||
OTEL_METRICS_EXPORTER: otlp
|
||||
OTEL_EXPORTER_OTLP_PROTOCOL: ${{ secrets.OTEL_EXPORTER_OTLP_PROTOCOL }}
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT: ${{ secrets.OTEL_EXPORTER_OTLP_ENDPOINT }}
|
||||
OTEL_EXPORTER_OTLP_HEADERS: ${{ secrets.OTEL_EXPORTER_OTLP_HEADERS }}
|
||||
run: npm run package
|
||||
|
||||
- name: Build loader and run rollout tests
|
||||
working-directory: next-src/apps/vscode-rollout
|
||||
env:
|
||||
TELEMETRY_SERVICE_API_KEY: ${{ secrets.TELEMETRY_SERVICE_API_KEY }}
|
||||
run: |
|
||||
bun run typecheck
|
||||
bun run test
|
||||
bun run build:production
|
||||
|
||||
- name: Stitch combined VSIX staging
|
||||
working-directory: next-src/apps/vscode-rollout
|
||||
run: |
|
||||
node scripts/stitch.mjs \
|
||||
--next "$GITHUB_WORKSPACE/next-src/apps/vscode" \
|
||||
--legacy "$GITHUB_WORKSPACE/legacy-src/apps/vscode" \
|
||||
--loader dist/extension.js \
|
||||
--version "${{ steps.version.outputs.version }}" \
|
||||
--out "$GITHUB_WORKSPACE/staging"
|
||||
|
||||
- name: Smoke-test loader against staging
|
||||
working-directory: next-src/apps/vscode-rollout
|
||||
run: node scripts/smoke-loader.mjs "$GITHUB_WORKSPACE/staging"
|
||||
|
||||
# The nightly identity must have fully propagated (nightlify -> both
|
||||
# bundle manifests -> union manifest) or we'd publish over the stable
|
||||
# extension ID. The bundle sub-manifest checks guard the version
|
||||
# stamping: the About tab and telemetry extension_version read those.
|
||||
- name: Assert nightly manifest identity
|
||||
working-directory: staging
|
||||
env:
|
||||
EXPECTED_VERSION: ${{ steps.version.outputs.version }}
|
||||
run: |
|
||||
node -e '
|
||||
const assert = require("node:assert");
|
||||
const expected = process.env.EXPECTED_VERSION;
|
||||
const pkg = require("./package.json");
|
||||
assert.equal(pkg.name, "cline-nightly", `unexpected name ${pkg.name}`);
|
||||
assert.equal(pkg.publisher, "saoudrizwan", `unexpected publisher ${pkg.publisher}`);
|
||||
assert.equal(pkg.version, expected, `unexpected union version ${pkg.version}`);
|
||||
for (const bundle of ["next", "legacy"]) {
|
||||
const sub = require(`./${bundle}/package.json`);
|
||||
assert.equal(sub.name, "cline-nightly", `unexpected ${bundle} bundle name ${sub.name}`);
|
||||
assert.equal(sub.version, expected, `unexpected ${bundle} bundle version ${sub.version}`);
|
||||
}
|
||||
console.log(`nightly identity ok: ${pkg.publisher}.${pkg.name}@${pkg.version} (bundle identities aligned)`);
|
||||
'
|
||||
|
||||
- name: Install Publishing Tools
|
||||
run: npm install -g @vscode/vsce ovsx
|
||||
|
||||
- name: Package VSIX
|
||||
working-directory: staging
|
||||
# Preserve the narrowly scoped VSCE `sendgrid` scanner exemption used by
|
||||
# both standalone bundle workflows. No SendGrid credential is intentionally
|
||||
# supplied here; inspect the reported artifact before widening the exemption.
|
||||
run: vsce package --no-dependencies --allow-package-secrets sendgrid --out "cline-nightly-${{ steps.version.outputs.version }}.vsix"
|
||||
|
||||
- name: Upload VSIX artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: cline-nightly-${{ steps.version.outputs.version }}
|
||||
path: staging/cline-nightly-${{ steps.version.outputs.version }}.vsix
|
||||
if-no-files-found: error
|
||||
|
||||
# The job is main-only; step-level dry-run gating still permits a build-only
|
||||
# rehearsal without publishing or tagging.
|
||||
- name: Publish to VS Code Marketplace and Open VSX
|
||||
if: github.ref == 'refs/heads/main' && inputs.dry-run != true
|
||||
working-directory: staging
|
||||
env:
|
||||
VSCE_PAT: ${{ secrets.VSCE_PAT }}
|
||||
OVSX_PAT: ${{ secrets.OVSX_PAT }}
|
||||
run: |
|
||||
if [[ -z "$VSCE_PAT" ]]; then
|
||||
echo "Error: VSCE_PAT is required to publish."
|
||||
exit 1
|
||||
fi
|
||||
vsce publish --no-dependencies --packagePath "cline-nightly-${{ steps.version.outputs.version }}.vsix"
|
||||
if [[ -n "$OVSX_PAT" ]]; then
|
||||
npx ovsx publish --packagePath "cline-nightly-${{ steps.version.outputs.version }}.vsix" --pat "$OVSX_PAT"
|
||||
else
|
||||
echo "WARNING: OVSX_PAT not set; skipping Open VSX publish."
|
||||
fi
|
||||
|
||||
- name: Tag published commit
|
||||
working-directory: ${{ github.workspace }}
|
||||
if: github.ref == 'refs/heads/main' && inputs.dry-run != true
|
||||
# Best-effort bookkeeping: the default GITHUB_TOKEN cannot create a ref
|
||||
# whose commit modifies workflow files (no workflows permission exists
|
||||
# for it), so this step fails whenever HEAD touched .github/workflows.
|
||||
# The publish already succeeded by this point — don't mark the run red;
|
||||
# push the tag manually with user credentials when it matters.
|
||||
continue-on-error: true
|
||||
working-directory: next-src
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
@@ -127,10 +290,11 @@ jobs:
|
||||
SHORT_SHA=$(git rev-parse --short=12 HEAD)
|
||||
TIMESTAMP=$(date -u +"%Y%m%d%H%M%S")
|
||||
TAG="nightly-${SAFE_REF}-${TIMESTAMP}-${SHORT_SHA}"
|
||||
LEGACY_SHA=$(git -C "$GITHUB_WORKSPACE/legacy-src" rev-parse HEAD)
|
||||
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git tag -a "$TAG" -m "Cline Nightly published from ${GITHUB_REF_NAME} at ${GITHUB_SHA}"
|
||||
git tag -a "$TAG" -m "Cline Nightly (combined A/B) published from ${GITHUB_REF_NAME} at ${GITHUB_SHA} (legacy bundle: ${LEGACY_SHA})"
|
||||
# Use an explicit HTTPS remote with GH_TOKEN because checkout was run with
|
||||
# persist-credentials: false, so actions/checkout did not persist a git credential helper.
|
||||
git push "https://x-access-token:${GH_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" "refs/tags/${TAG}"
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
name: ui-publish
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
npm_tag:
|
||||
description: "npm distribution tag"
|
||||
required: true
|
||||
type: choice
|
||||
options:
|
||||
- next
|
||||
- latest
|
||||
default: next
|
||||
confirm_publish:
|
||||
description: 'Type "publish" to publish @cline/ui to npm'
|
||||
required: true
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
quality:
|
||||
name: UI quality and package checks
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: "1.3.13"
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "24.x"
|
||||
|
||||
- name: Install dependencies
|
||||
run: bun install --filter @cline/ui --filter @cline/code --frozen-lockfile
|
||||
|
||||
- name: Typecheck UI
|
||||
run: bun -F @cline/ui typecheck
|
||||
|
||||
- name: Test UI
|
||||
run: bun -F @cline/ui test
|
||||
|
||||
- name: Build Storybook
|
||||
run: bun -F @cline/ui build-storybook
|
||||
|
||||
- name: Build UI package
|
||||
run: bun -F @cline/ui build
|
||||
|
||||
- name: Test desktop chat integration
|
||||
run: bun -F @cline/code test:chat-ui
|
||||
|
||||
- name: Pack publish artifact
|
||||
id: pack
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
pack_dir="$RUNNER_TEMP/ui-npm-pack"
|
||||
mkdir -p "$pack_dir"
|
||||
cd sdk/packages/ui
|
||||
bun pm pack --ignore-scripts --destination "$pack_dir" --quiet
|
||||
archive=$(find "$pack_dir" -maxdepth 1 -name '*.tgz' -print -quit)
|
||||
test -n "$archive"
|
||||
echo "archive=$archive" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Test packed package
|
||||
env:
|
||||
UI_PACKAGE_ARCHIVE: ${{ steps.pack.outputs.archive }}
|
||||
run: bun sdk/packages/ui/scripts/smoke-package.ts "$UI_PACKAGE_ARCHIVE"
|
||||
|
||||
- name: Upload publish artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ui-npm-package
|
||||
path: ${{ runner.temp }}/ui-npm-pack/*.tgz
|
||||
if-no-files-found: error
|
||||
retention-days: 7
|
||||
|
||||
publish:
|
||||
name: Publish @cline/ui
|
||||
if: >-
|
||||
github.event_name == 'workflow_dispatch' &&
|
||||
github.repository == 'cline/cline' &&
|
||||
github.ref == 'refs/heads/main' &&
|
||||
inputs.confirm_publish == 'publish' &&
|
||||
!endsWith(github.actor, '[bot]')
|
||||
needs: quality
|
||||
runs-on: ubuntu-latest
|
||||
environment: Publish
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
steps:
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "24.x"
|
||||
registry-url: "https://registry.npmjs.org"
|
||||
|
||||
- name: Download publish artifact
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: ui-npm-package
|
||||
path: ${{ runner.temp }}/ui-npm-pack
|
||||
|
||||
- name: Verify publish tooling
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
npm_version=$(npm --version)
|
||||
echo "npm ${npm_version}"
|
||||
node -e 'const [major, minor, patch] = process.argv[1].split(".").map(Number); if (major < 11 || (major === 11 && (minor < 5 || (minor === 5 && patch < 1)))) { console.error("npm 11.5.1 or newer is required for trusted publishing"); process.exit(1); }' "$npm_version"
|
||||
|
||||
- name: Publish package
|
||||
shell: bash
|
||||
env:
|
||||
NPM_CONFIG_PROVENANCE: "true"
|
||||
NPM_TAG: ${{ inputs.npm_tag }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
archive=$(find "$RUNNER_TEMP/ui-npm-pack" -maxdepth 1 -name '*.tgz' -print -quit)
|
||||
if [ -z "$archive" ]; then
|
||||
echo "UI package archive was not downloaded"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
version=$(tar -xOf "$archive" package/package.json | node -e 'let input=""; process.stdin.on("data", chunk => input += chunk); process.stdin.on("end", () => process.stdout.write(JSON.parse(input).version))')
|
||||
if npm view "@cline/ui@${version}" version >/dev/null 2>&1; then
|
||||
echo "@cline/ui@${version} already exists; bump sdk/packages/ui/package.json before publishing"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
npm publish "$archive" --tag "$NPM_TAG" --access public
|
||||
echo "Published @cline/ui@${version} with npm tag '${NPM_TAG}'"
|
||||
@@ -1,5 +1,37 @@
|
||||
# Cline CLI Changelog
|
||||
|
||||
## 3.0.45
|
||||
|
||||
- Smaller install: the Claude Code and Codex providers are now optional and loaded on demand, cutting `npm i -g cline` from ~640MB to ~285MB (from SDK v0.0.65)
|
||||
- Kimi K3 is now available as a ClinePass model (from SDK v0.0.65)
|
||||
- Runs now retry once after refreshing expired OAuth credentials (from SDK v0.0.65)
|
||||
- Team runs: the spawn tool is no longer exposed to teammates, and errored teammate runs now report as failed instead of completed (from SDK v0.0.65)
|
||||
- Hub status output now includes version numbers
|
||||
- Updated the bundled model catalog (from SDK v0.0.65)
|
||||
|
||||
## 3.0.44
|
||||
|
||||
- Improved max output token handling across providers (gateway routing, OpenAI vendor, and reasoning models) (from SDK v0.0.64)
|
||||
- Frontmatter and configuration files that start with a UTF-8 byte order mark (e.g. saved by Windows editors) now parse correctly (from SDK v0.0.64)
|
||||
|
||||
## 3.0.43
|
||||
|
||||
- The CLI now automatically trusts your operating system's certificate store, so it works behind corporate proxies and TLS-inspecting firewalls without manually setting `NODE_EXTRA_CA_CERTS` (fixes "unable to get local issuer certificate" errors, including Windows intermediate CA stores)
|
||||
|
||||
## 3.0.42
|
||||
|
||||
- Fixed Ollama native API routing so context window and timeout settings work again
|
||||
|
||||
## 3.0.41
|
||||
|
||||
- Compaction now shows progress status in the TUI
|
||||
- Model IDs are now suggested from OpenAI-compatible endpoints when configuring a provider
|
||||
- Workspace git info (branch/remote) is now persisted and refreshed across sessions
|
||||
- Compaction no longer runs during an active turn
|
||||
- Fixed a crash when the terminal title was updated during TUI teardown
|
||||
- The API key fallback hint is now highlighted for better visibility
|
||||
- Benign git states are no longer reported as workspace initialization errors
|
||||
|
||||
## 3.0.40
|
||||
|
||||
- Added a manual API key escape hatch for Cline OAuth providers, so you can enter a key by hand from settings
|
||||
|
||||
@@ -346,9 +346,24 @@ Desktop-integrated approval mode is also supported via env wiring (`CLINE_TOOL_A
|
||||
- `CLINE_LOG_LEVEL` - Runtime log level (`trace|debug|info|warn|error|fatal|silent`, default `info`)
|
||||
- `CLINE_LOG_PATH` - Runtime log file path (default `<CLINE_DATA_DIR>/logs/cline.log`)
|
||||
- `CLINE_LOG_NAME` - Logger name embedded in runtime log records
|
||||
- `CLINE_DEBUG` - Set to `1`/`true` to print wrapper diagnostics (e.g. the CA bundle summary)
|
||||
|
||||
`--key` takes precedence over environment variables.
|
||||
|
||||
## Certificate trust
|
||||
|
||||
The CLI automatically trusts your operating system's certificate store, so it
|
||||
works behind corporate TLS-inspecting proxies and with self-signed/internal
|
||||
endpoints without any setup. On launch the `cline` wrapper harvests the OS trust
|
||||
anchors and writes them to `~/.cline/cli-node-extra-ca-certs.pem`, then points
|
||||
the runtime's `NODE_EXTRA_CA_CERTS` at that bundle. The file is regenerated when
|
||||
it changes and is safe to delete (it is rebuilt on the next run).
|
||||
|
||||
If you set `NODE_EXTRA_CA_CERTS` yourself, your certificates are **merged** into
|
||||
that bundle alongside the system store rather than replacing it. Run with
|
||||
`CLINE_DEBUG=1` to see how many OS and user CAs were loaded and where the bundle
|
||||
was written.
|
||||
|
||||
## Contributing
|
||||
|
||||
See [DEVELOPMENT.md](./DEVELOPMENT.md) for local development setup, monorepo structure, and TUI architecture. See [DISTRIBUTION.md](./DISTRIBUTION.md) for how the CLI is packaged and distributed.
|
||||
|
||||
@@ -0,0 +1,281 @@
|
||||
// Auto-discovery of OS trust anchors for the Cline CLI.
|
||||
//
|
||||
// Bun does not read the OS trust store, so the 3.x CLI cannot see corporate
|
||||
// MITM / self-signed CAs out of the box. This runs in the Node `bin/cline`
|
||||
// wrapper (not Bun), reads the full OS store via tls.getCACertificates("system")
|
||||
// (Node >= 22, no --use-system-ca flag), and hands the certs to the Bun child
|
||||
// via NODE_EXTRA_CA_CERTS, which both runtimes honor. Mirrors the JetBrains
|
||||
// plugin's configureCertificates(), sourcing from the OS instead of the IDE.
|
||||
//
|
||||
// Dependency-free CommonJS with injectable modules so it is unit-testable and
|
||||
// ships verbatim in the published wrapper package.
|
||||
|
||||
const PEM_MARKER = "-----BEGIN CERTIFICATE-----";
|
||||
const CERT_BLOCK =
|
||||
/-----BEGIN CERTIFICATE-----[\s\S]*?-----END CERTIFICATE-----/g;
|
||||
|
||||
/**
|
||||
* Returns only the complete certificate blocks from PEM text, or null when
|
||||
* there are none. User files may also hold private keys (combined cert+key
|
||||
* PEMs) or other sections, which must never be copied into the managed
|
||||
* bundle. Files that contain nothing but certificates pass through verbatim
|
||||
* so unchanged bundles keep hash-skipping the rewrite.
|
||||
*/
|
||||
function sanitizePem(text) {
|
||||
const blocks = text.match(CERT_BLOCK) ?? [];
|
||||
if (blocks.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const rest = text.replace(CERT_BLOCK, "");
|
||||
if (/^\s*$/.test(rest)) {
|
||||
return text;
|
||||
}
|
||||
return `${blocks.join("\n")}\n`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns OS-trusted certificates as PEM strings, or [] when unavailable.
|
||||
* tls.getCACertificates("system") requires Node >= 22.
|
||||
*/
|
||||
function harvestSystemCerts(tlsModule) {
|
||||
try {
|
||||
const tls = tlsModule || require("node:tls");
|
||||
if (typeof tls.getCACertificates !== "function") {
|
||||
return [];
|
||||
}
|
||||
const certs = tls.getCACertificates("system");
|
||||
if (!Array.isArray(certs)) {
|
||||
return [];
|
||||
}
|
||||
return certs.filter(
|
||||
(cert) => typeof cert === "string" && cert.includes(PEM_MARKER),
|
||||
);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the file's certificate blocks as PEM text, or null when missing,
|
||||
* unreadable, or holding no complete certificate block.
|
||||
*/
|
||||
function readUserBundle(fsModule, userPath) {
|
||||
if (!userPath) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const fs = fsModule || require("node:fs");
|
||||
const stat = fs.statSync(userPath, { throwIfNoEntry: false });
|
||||
if (!stat || !stat.isFile()) {
|
||||
return null;
|
||||
}
|
||||
// Binary DER would not have loaded in the runtime either; require PEM.
|
||||
return sanitizePem(fs.readFileSync(userPath, "utf8"));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the user's NODE_EXTRA_CA_CERTS value into PEM strings. Node treats the
|
||||
* value as a single file, but some users set an OS-path-delimited list; the
|
||||
* whole value is tried as one file first, then split.
|
||||
* The managed bundle is excluded so reading it back never re-appends its certs.
|
||||
*/
|
||||
function readUserCerts(fsModule, pathModule, value, managedPath) {
|
||||
if (!value) {
|
||||
return [];
|
||||
}
|
||||
const fs = fsModule || require("node:fs");
|
||||
const path = pathModule || require("node:path");
|
||||
const candidates = [];
|
||||
const whole = readUserBundle(fs, value);
|
||||
if (whole) {
|
||||
candidates.push({ filePath: value, pem: whole });
|
||||
} else if (value.includes(path.delimiter)) {
|
||||
for (const segment of value.split(path.delimiter)) {
|
||||
const trimmed = segment.trim();
|
||||
if (!trimmed) {
|
||||
continue;
|
||||
}
|
||||
const pem = readUserBundle(fs, trimmed);
|
||||
if (pem) {
|
||||
candidates.push({ filePath: trimmed, pem });
|
||||
}
|
||||
}
|
||||
}
|
||||
const pems = [];
|
||||
for (const candidate of candidates) {
|
||||
const isManaged =
|
||||
managedPath &&
|
||||
path.resolve(candidate.filePath) === path.resolve(managedPath);
|
||||
if (!isManaged) {
|
||||
pems.push(candidate.pem);
|
||||
}
|
||||
}
|
||||
return pems;
|
||||
}
|
||||
|
||||
/**
|
||||
* Concatenates the user PEMs (if any) and the system certificates into one
|
||||
* bundle. A separating newline is inserted between parts so adjacent END/BEGIN
|
||||
* markers cannot fuse into one invalid line.
|
||||
*/
|
||||
function buildBundle({ systemCerts, userPems }) {
|
||||
const parts = [...(userPems ?? []), ...systemCerts];
|
||||
return parts
|
||||
.map((part) => (part.endsWith("\n") ? part : `${part}\n`))
|
||||
.join("");
|
||||
}
|
||||
|
||||
/** Counts individual PEM certificates across the given bundle strings. */
|
||||
function countCerts(pems) {
|
||||
let count = 0;
|
||||
for (const pem of pems) {
|
||||
count += pem.split(PEM_MARKER).length - 1;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
function readFileIfExists(fs, filePath) {
|
||||
try {
|
||||
return fs.readFileSync(filePath, "utf8");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function resolveClineDir(env, os, path) {
|
||||
return env.CLINE_DIR?.trim() || path.join(os.homedir(), ".cline");
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the api-unavailable warning should print. Stamped per Node version
|
||||
* in the cline dir so the nudge shows once rather than on every command; a
|
||||
* version change (upgrade that still falls short, or downgrade) re-arms it.
|
||||
* When the stamp cannot be read or written, warn — bookkeeping failures must
|
||||
* never suppress a real diagnostic.
|
||||
*/
|
||||
function shouldWarnApiUnavailable(env, deps = {}) {
|
||||
const fs = deps.fs || require("node:fs");
|
||||
const os = deps.os || require("node:os");
|
||||
const path = deps.path || require("node:path");
|
||||
const version = deps.nodeVersion || process.versions.node;
|
||||
const dir = resolveClineDir(env, os, path);
|
||||
const stamp = path.join(dir, `.ca-api-warned-${version}`);
|
||||
try {
|
||||
if (fs.existsSync(stamp)) {
|
||||
return false;
|
||||
}
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
fs.writeFileSync(stamp, "", { mode: 0o600 });
|
||||
return true;
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/** Atomically writes [content] to [target]; returns true on success. */
|
||||
function writeBundle(fs, dir, target, content) {
|
||||
const tmp = `${target}.${process.pid}.${Date.now()}.tmp`;
|
||||
try {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
// Owner read/write: the bundle holds public CA material, not secrets,
|
||||
// but there is no reason to make it world-writable.
|
||||
fs.writeFileSync(tmp, content, { mode: 0o600 });
|
||||
try {
|
||||
fs.renameSync(tmp, target);
|
||||
} catch {
|
||||
// Windows can reject rename over a file a concurrent child holds open.
|
||||
fs.rmSync(target, { force: true });
|
||||
fs.renameSync(tmp, target);
|
||||
}
|
||||
return true;
|
||||
} catch {
|
||||
// Never leave a partial temp file behind (e.g. ENOSPC mid-write).
|
||||
try {
|
||||
fs.rmSync(tmp, { force: true });
|
||||
} catch {
|
||||
// Ignore: best-effort cleanup.
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Harvests OS trust anchors, merges them with any user NODE_EXTRA_CA_CERTS, and
|
||||
* points env.NODE_EXTRA_CA_CERTS at a single managed PEM bundle. Mutates `env`
|
||||
* in place. Returns an outcome the caller can log; `action` is one of
|
||||
* "unchanged" | "written" | "write-failed-reused" | "write-failed" |
|
||||
* "no-system-certs" | "api-unavailable".
|
||||
*/
|
||||
function configureNodeExtraCaCerts(env, deps = {}) {
|
||||
const fs = deps.fs || require("node:fs");
|
||||
const os = deps.os || require("node:os");
|
||||
const path = deps.path || require("node:path");
|
||||
const tls = deps.tls || require("node:tls");
|
||||
|
||||
// tls.getCACertificates("system") needs Node >= 22.15; on older Nodes the
|
||||
// harvest cannot run at all, which the caller should surface to the user.
|
||||
if (typeof tls.getCACertificates !== "function") {
|
||||
return {
|
||||
action: "api-unavailable",
|
||||
path: null,
|
||||
systemCertCount: 0,
|
||||
userCertCount: 0,
|
||||
};
|
||||
}
|
||||
|
||||
const systemCerts = harvestSystemCerts(tls);
|
||||
if (systemCerts.length === 0) {
|
||||
// Nothing to add: leave any user-provided NODE_EXTRA_CA_CERTS untouched
|
||||
// and let the runtime fall back to its bundled CAs.
|
||||
return {
|
||||
action: "no-system-certs",
|
||||
path: null,
|
||||
systemCertCount: 0,
|
||||
userCertCount: 0,
|
||||
};
|
||||
}
|
||||
|
||||
const managedDir = resolveClineDir(env, os, path);
|
||||
const managedPath = path.join(managedDir, "cli-node-extra-ca-certs.pem");
|
||||
const userValue = (env.NODE_EXTRA_CA_CERTS || "").trim() || null;
|
||||
const userPems = readUserCerts(fs, path, userValue, managedPath);
|
||||
const bundle = buildBundle({ systemCerts, userPems });
|
||||
const base = {
|
||||
path: managedPath,
|
||||
systemCertCount: systemCerts.length,
|
||||
userCertCount: countCerts(userPems),
|
||||
};
|
||||
|
||||
// Skip the rewrite when the bundle is already current. Avoids per-launch I/O
|
||||
// and the concurrent-rename race in the steady state.
|
||||
if (readFileIfExists(fs, managedPath) === bundle) {
|
||||
env.NODE_EXTRA_CA_CERTS = managedPath;
|
||||
return { ...base, action: "unchanged" };
|
||||
}
|
||||
|
||||
if (writeBundle(fs, managedDir, managedPath, bundle)) {
|
||||
env.NODE_EXTRA_CA_CERTS = managedPath;
|
||||
return { ...base, action: "written" };
|
||||
}
|
||||
|
||||
// Write failed: fall back to a previously-written bundle if one exists.
|
||||
if (readFileIfExists(fs, managedPath)) {
|
||||
env.NODE_EXTRA_CA_CERTS = managedPath;
|
||||
return { ...base, action: "write-failed-reused" };
|
||||
}
|
||||
return { ...base, path: null, action: "write-failed" };
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
harvestSystemCerts,
|
||||
sanitizePem,
|
||||
readUserBundle,
|
||||
readUserCerts,
|
||||
buildBundle,
|
||||
countCerts,
|
||||
configureNodeExtraCaCerts,
|
||||
shouldWarnApiUnavailable,
|
||||
};
|
||||
@@ -23,6 +23,48 @@ const childEnv = {
|
||||
CLINE_WRAPPER_PATH: scriptPath,
|
||||
};
|
||||
|
||||
// Auto-discover OS trust anchors and pass them to the Bun child via
|
||||
// NODE_EXTRA_CA_CERTS. The Bun runtime does not read the OS store on its own,
|
||||
// so corporate/self-signed CAs would otherwise fail. This wrapper runs on
|
||||
// Node, which can read the full store here.
|
||||
try {
|
||||
const caCerts = require("./ca-certs.cjs");
|
||||
const outcome = caCerts.configureNodeExtraCaCerts(childEnv);
|
||||
const debug =
|
||||
process.env.CLINE_DEBUG === "1" || process.env.CLINE_DEBUG === "true";
|
||||
// Not debug-gated: on old Nodes the harvest silently doing nothing is
|
||||
// indistinguishable from a broken corporate proxy. Stamped per Node
|
||||
// version so the nudge shows once, not on every command.
|
||||
if (
|
||||
outcome &&
|
||||
outcome.action === "api-unavailable" &&
|
||||
!childEnv.NODE_EXTRA_CA_CERTS &&
|
||||
caCerts.shouldWarnApiUnavailable(childEnv)
|
||||
) {
|
||||
console.warn(
|
||||
`[cline] Node ${process.versions.node} cannot read the OS trust store (needs >= 22.15); ` +
|
||||
"corporate or self-signed CAs may fail TLS. Upgrade Node or set NODE_EXTRA_CA_CERTS.",
|
||||
);
|
||||
}
|
||||
if (debug && outcome) {
|
||||
if (outcome.action === "no-system-certs") {
|
||||
console.warn(
|
||||
"[cline] No OS trust anchors found; relying on the runtime's bundled CAs.",
|
||||
);
|
||||
} else if (outcome.action === "write-failed") {
|
||||
console.warn(
|
||||
"[cline] Could not write the managed CA bundle; relying on the runtime's bundled CAs.",
|
||||
);
|
||||
} else {
|
||||
console.warn(
|
||||
`[cline] Trust: ${outcome.systemCertCount} OS + ${outcome.userCertCount} user CAs (${outcome.action}) -> ${outcome.path}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Best effort: fall back to the runtime's default trust on any failure.
|
||||
}
|
||||
|
||||
function run(target) {
|
||||
const result = childProcess.spawnSync(target, process.argv.slice(2), {
|
||||
stdio: "inherit",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@cline/cli",
|
||||
"displayName": "cline",
|
||||
"version": "3.0.40",
|
||||
"version": "3.0.45",
|
||||
"description": "Autonomous coding agent CLI - capable of creating/editing files, running commands, using the browser, and more",
|
||||
"type": "module",
|
||||
"publishConfig": {
|
||||
|
||||
@@ -0,0 +1,367 @@
|
||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { delimiter, join } from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
// The helper ships as CommonJS in the published wrapper package, so it is
|
||||
// loaded via require rather than an ESM import.
|
||||
const caCerts = require("../../bin/ca-certs.cjs") as {
|
||||
harvestSystemCerts: (tls?: unknown) => string[];
|
||||
readUserBundle: (fs: unknown, p: string | null) => string | null;
|
||||
readUserCerts: (
|
||||
fs: unknown,
|
||||
path: unknown,
|
||||
value: string | null,
|
||||
managedPath: string | null,
|
||||
) => string[];
|
||||
buildBundle: (input: {
|
||||
systemCerts: string[];
|
||||
userPems?: string[];
|
||||
}) => string;
|
||||
countCerts: (pems: string[]) => number;
|
||||
configureNodeExtraCaCerts: (
|
||||
env: Record<string, string>,
|
||||
deps?: { tls?: unknown; fs?: unknown },
|
||||
) => {
|
||||
action: string;
|
||||
path: string | null;
|
||||
systemCertCount: number;
|
||||
userCertCount: number;
|
||||
};
|
||||
shouldWarnApiUnavailable: (
|
||||
env: Record<string, string>,
|
||||
deps?: { fs?: unknown; nodeVersion?: string },
|
||||
) => boolean;
|
||||
};
|
||||
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
|
||||
const certSystem =
|
||||
"-----BEGIN CERTIFICATE-----\nSYSTEM\n-----END CERTIFICATE-----\n";
|
||||
const certUser = "-----BEGIN CERTIFICATE-----\nUSER\n-----END CERTIFICATE-----";
|
||||
|
||||
function fakeTls(certs: unknown) {
|
||||
return { getCACertificates: () => certs };
|
||||
}
|
||||
|
||||
describe("ca-certs", () => {
|
||||
let dir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
dir = mkdtempSync(join(tmpdir(), "cline-ca-"));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("harvestSystemCerts", () => {
|
||||
it("returns only PEM strings from the system store", () => {
|
||||
expect(
|
||||
caCerts.harvestSystemCerts(fakeTls([certSystem, "not-a-cert", 42])),
|
||||
).toEqual([certSystem]);
|
||||
});
|
||||
|
||||
it("returns [] when getCACertificates is unavailable", () => {
|
||||
expect(caCerts.harvestSystemCerts({})).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns [] when getCACertificates throws", () => {
|
||||
expect(
|
||||
caCerts.harvestSystemCerts({
|
||||
getCACertificates: () => {
|
||||
throw new Error("nope");
|
||||
},
|
||||
}),
|
||||
).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("readUserBundle", () => {
|
||||
it("returns PEM contents for a PEM file", () => {
|
||||
const p = join(dir, "user.pem");
|
||||
writeFileSync(p, certUser);
|
||||
expect(caCerts.readUserBundle(fs, p)).toBe(certUser);
|
||||
});
|
||||
|
||||
it("returns null for a non-PEM (DER) file", () => {
|
||||
const p = join(dir, "user.der");
|
||||
writeFileSync(p, Buffer.from([0x30, 0x82, 0x01, 0x02]));
|
||||
expect(caCerts.readUserBundle(fs, p)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for a missing file and for null path", () => {
|
||||
expect(caCerts.readUserBundle(fs, join(dir, "nope.pem"))).toBeNull();
|
||||
expect(caCerts.readUserBundle(fs, null)).toBeNull();
|
||||
});
|
||||
|
||||
it("strips non-certificate sections such as private keys", () => {
|
||||
// Combined cert+key files (nginx/haproxy style) are common; the key
|
||||
// must never reach the managed bundle.
|
||||
const p = join(dir, "combined.pem");
|
||||
writeFileSync(
|
||||
p,
|
||||
`${certUser}\n-----BEGIN PRIVATE KEY-----\nSECRET\n-----END PRIVATE KEY-----\n`,
|
||||
);
|
||||
const out = caCerts.readUserBundle(fs, p);
|
||||
expect(out).toContain("USER");
|
||||
expect(out).not.toContain("PRIVATE KEY");
|
||||
expect(out).not.toContain("SECRET");
|
||||
});
|
||||
|
||||
it("keeps certificates-only files verbatim", () => {
|
||||
// Byte-identical passthrough keeps the unchanged-skip hash stable.
|
||||
const p = join(dir, "clean.pem");
|
||||
writeFileSync(p, `${certUser}\n${certSystem}`);
|
||||
expect(caCerts.readUserBundle(fs, p)).toBe(`${certUser}\n${certSystem}`);
|
||||
});
|
||||
|
||||
it("returns null for a BEGIN marker without a complete block", () => {
|
||||
const p = join(dir, "truncated.pem");
|
||||
writeFileSync(p, "-----BEGIN CERTIFICATE-----\ntruncated");
|
||||
expect(caCerts.readUserBundle(fs, p)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("readUserCerts", () => {
|
||||
it("reads a single PEM file path", () => {
|
||||
const p = join(dir, "corp.pem");
|
||||
writeFileSync(p, certUser);
|
||||
expect(caCerts.readUserCerts(fs, path, p, null)).toEqual([certUser]);
|
||||
});
|
||||
|
||||
it("splits a legacy OS-path-delimited value and reads each PEM", () => {
|
||||
// Legacy footgun: NODE_EXTRA_CA_CERTS="a.pem;b.pem".
|
||||
const a = join(dir, "a.pem");
|
||||
const b = join(dir, "b.pem");
|
||||
writeFileSync(a, certUser);
|
||||
writeFileSync(b, certSystem);
|
||||
expect(
|
||||
caCerts.readUserCerts(fs, path, [a, b].join(delimiter), null),
|
||||
).toEqual([certUser, certSystem]);
|
||||
});
|
||||
|
||||
it("skips missing segments in a delimited value", () => {
|
||||
const a = join(dir, "a.pem");
|
||||
writeFileSync(a, certUser);
|
||||
const value = [a, join(dir, "missing.pem")].join(delimiter);
|
||||
expect(caCerts.readUserCerts(fs, path, value, null)).toEqual([certUser]);
|
||||
});
|
||||
|
||||
it("excludes the managed bundle from user certs", () => {
|
||||
const managed = join(dir, "cli-node-extra-ca-certs.pem");
|
||||
writeFileSync(managed, certUser);
|
||||
expect(caCerts.readUserCerts(fs, path, managed, managed)).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns [] for empty value", () => {
|
||||
expect(caCerts.readUserCerts(fs, path, null, null)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildBundle", () => {
|
||||
it("merges user PEMs before system certs", () => {
|
||||
expect(
|
||||
caCerts.buildBundle({
|
||||
systemCerts: [certSystem],
|
||||
userPems: [certUser],
|
||||
}),
|
||||
).toBe(`${certUser}\n${certSystem}`);
|
||||
});
|
||||
|
||||
it("inserts a separating newline so END/BEGIN markers do not fuse", () => {
|
||||
// certUser has no trailing newline, so this proves the boundary fix.
|
||||
const merged = caCerts.buildBundle({
|
||||
systemCerts: [certSystem],
|
||||
userPems: [certUser],
|
||||
});
|
||||
expect(merged).not.toContain(
|
||||
"-----END CERTIFICATE----------BEGIN CERTIFICATE-----",
|
||||
);
|
||||
});
|
||||
|
||||
it("handles no user PEMs", () => {
|
||||
expect(caCerts.buildBundle({ systemCerts: [certSystem] })).toBe(
|
||||
certSystem,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("configureNodeExtraCaCerts", () => {
|
||||
it("writes a managed bundle and points the env var at it", () => {
|
||||
const env: Record<string, string> = { CLINE_DIR: dir };
|
||||
const out = caCerts.configureNodeExtraCaCerts(env, {
|
||||
tls: fakeTls([certSystem]),
|
||||
});
|
||||
expect(out.action).toBe("written");
|
||||
expect(out.path).toBe(join(dir, "cli-node-extra-ca-certs.pem"));
|
||||
expect(env.NODE_EXTRA_CA_CERTS).toBe(out.path);
|
||||
expect(readFileSync(out.path as string, "utf8")).toContain("SYSTEM");
|
||||
});
|
||||
|
||||
it("merges a user-supplied NODE_EXTRA_CA_CERTS with system certs", () => {
|
||||
const userPath = join(dir, "corp.pem");
|
||||
writeFileSync(userPath, certUser);
|
||||
const env: Record<string, string> = {
|
||||
CLINE_DIR: dir,
|
||||
NODE_EXTRA_CA_CERTS: userPath,
|
||||
};
|
||||
const out = caCerts.configureNodeExtraCaCerts(env, {
|
||||
tls: fakeTls([certSystem]),
|
||||
});
|
||||
expect(out.userCertCount).toBe(1);
|
||||
const written = readFileSync(env.NODE_EXTRA_CA_CERTS, "utf8");
|
||||
expect(written).toContain("USER");
|
||||
expect(written).toContain("SYSTEM");
|
||||
});
|
||||
|
||||
it("reports unchanged and skips rewrite on the second run", () => {
|
||||
const env: Record<string, string> = { CLINE_DIR: dir };
|
||||
expect(
|
||||
caCerts.configureNodeExtraCaCerts(env, { tls: fakeTls([certSystem]) })
|
||||
.action,
|
||||
).toBe("written");
|
||||
expect(
|
||||
caCerts.configureNodeExtraCaCerts(env, { tls: fakeTls([certSystem]) })
|
||||
.action,
|
||||
).toBe("unchanged");
|
||||
});
|
||||
|
||||
it("does not re-append when the user already points at the managed bundle", () => {
|
||||
const env: Record<string, string> = { CLINE_DIR: dir };
|
||||
const first = caCerts.configureNodeExtraCaCerts(env, {
|
||||
tls: fakeTls([certSystem]),
|
||||
}).path as string;
|
||||
const env2: Record<string, string> = {
|
||||
CLINE_DIR: dir,
|
||||
NODE_EXTRA_CA_CERTS: first,
|
||||
};
|
||||
caCerts.configureNodeExtraCaCerts(env2, { tls: fakeTls([certSystem]) });
|
||||
const written = readFileSync(env2.NODE_EXTRA_CA_CERTS, "utf8");
|
||||
expect(written.match(/SYSTEM/g)?.length).toBe(1);
|
||||
});
|
||||
|
||||
it("no-ops when no system certs are available", () => {
|
||||
const env: Record<string, string> = {
|
||||
CLINE_DIR: dir,
|
||||
NODE_EXTRA_CA_CERTS: "/user/corp.pem",
|
||||
};
|
||||
const out = caCerts.configureNodeExtraCaCerts(env, { tls: fakeTls([]) });
|
||||
expect(out.action).toBe("no-system-certs");
|
||||
expect(out.path).toBeNull();
|
||||
expect(env.NODE_EXTRA_CA_CERTS).toBe("/user/corp.pem");
|
||||
});
|
||||
|
||||
it("reports api-unavailable on Nodes without getCACertificates", () => {
|
||||
const env: Record<string, string> = {
|
||||
CLINE_DIR: dir,
|
||||
NODE_EXTRA_CA_CERTS: "/user/corp.pem",
|
||||
};
|
||||
const out = caCerts.configureNodeExtraCaCerts(env, { tls: {} });
|
||||
expect(out.action).toBe("api-unavailable");
|
||||
expect(out.path).toBeNull();
|
||||
expect(env.NODE_EXTRA_CA_CERTS).toBe("/user/corp.pem");
|
||||
});
|
||||
|
||||
it("reports write-failed when the bundle cannot be written", () => {
|
||||
const realFs = require("node:fs");
|
||||
const failingFs = {
|
||||
...realFs,
|
||||
mkdirSync: () => {
|
||||
throw new Error("EACCES");
|
||||
},
|
||||
writeFileSync: () => {
|
||||
throw new Error("EACCES");
|
||||
},
|
||||
};
|
||||
const env: Record<string, string> = { CLINE_DIR: dir };
|
||||
const out = caCerts.configureNodeExtraCaCerts(env, {
|
||||
tls: fakeTls([certSystem]),
|
||||
fs: failingFs,
|
||||
});
|
||||
expect(out.action).toBe("write-failed");
|
||||
expect(out.path).toBeNull();
|
||||
expect(env.NODE_EXTRA_CA_CERTS).toBeUndefined();
|
||||
});
|
||||
|
||||
it("reuses a stale bundle when the rewrite fails", () => {
|
||||
// First run writes the bundle normally.
|
||||
const env: Record<string, string> = { CLINE_DIR: dir };
|
||||
const managedPath = caCerts.configureNodeExtraCaCerts(env, {
|
||||
tls: fakeTls([certSystem]),
|
||||
}).path as string;
|
||||
|
||||
// Second run: writes fail, but the stale bundle is still readable.
|
||||
const realFs = require("node:fs");
|
||||
const failingFs = {
|
||||
...realFs,
|
||||
mkdirSync: () => {
|
||||
throw new Error("EACCES");
|
||||
},
|
||||
writeFileSync: () => {
|
||||
throw new Error("EACCES");
|
||||
},
|
||||
};
|
||||
const env2: Record<string, string> = { CLINE_DIR: dir };
|
||||
const out = caCerts.configureNodeExtraCaCerts(env2, {
|
||||
// A different system cert forces a rewrite attempt (not "unchanged").
|
||||
tls: fakeTls([certUser]),
|
||||
fs: failingFs,
|
||||
});
|
||||
|
||||
expect(out.action).toBe("write-failed-reused");
|
||||
expect(env2.NODE_EXTRA_CA_CERTS).toBe(managedPath);
|
||||
});
|
||||
});
|
||||
|
||||
describe("countCerts", () => {
|
||||
it("counts individual certificates, not files", () => {
|
||||
// One file holding two certs must report 2, not 1.
|
||||
const twoInOne = `${certUser}\n${certSystem}`;
|
||||
expect(caCerts.countCerts([twoInOne])).toBe(2);
|
||||
expect(caCerts.countCerts([certUser, certSystem])).toBe(2);
|
||||
expect(caCerts.countCerts([])).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("shouldWarnApiUnavailable", () => {
|
||||
it("warns once per Node version, then stays quiet", () => {
|
||||
const env = { CLINE_DIR: dir };
|
||||
const deps = { nodeVersion: "22.1.0" };
|
||||
expect(caCerts.shouldWarnApiUnavailable(env, deps)).toBe(true);
|
||||
expect(caCerts.shouldWarnApiUnavailable(env, deps)).toBe(false);
|
||||
});
|
||||
|
||||
it("re-arms when the Node version changes", () => {
|
||||
const env = { CLINE_DIR: dir };
|
||||
expect(
|
||||
caCerts.shouldWarnApiUnavailable(env, { nodeVersion: "22.1.0" }),
|
||||
).toBe(true);
|
||||
expect(
|
||||
caCerts.shouldWarnApiUnavailable(env, { nodeVersion: "22.14.0" }),
|
||||
).toBe(true);
|
||||
expect(
|
||||
caCerts.shouldWarnApiUnavailable(env, { nodeVersion: "22.1.0" }),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("still warns when the stamp cannot be written", () => {
|
||||
const realFs = require("node:fs");
|
||||
const failingFs = {
|
||||
...realFs,
|
||||
mkdirSync: () => {
|
||||
throw new Error("EACCES");
|
||||
},
|
||||
writeFileSync: () => {
|
||||
throw new Error("EACCES");
|
||||
},
|
||||
};
|
||||
const env = { CLINE_DIR: dir };
|
||||
const deps = { fs: failingFs, nodeVersion: "22.1.0" };
|
||||
// Bookkeeping failure must never suppress the diagnostic.
|
||||
expect(caCerts.shouldWarnApiUnavailable(env, deps)).toBe(true);
|
||||
expect(caCerts.shouldWarnApiUnavailable(env, deps)).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
||||
import { existsSync, readdirSync } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import { basename, extname, join } from "node:path";
|
||||
import {
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
type SkillConfig,
|
||||
type WorkflowConfig,
|
||||
} from "@cline/core";
|
||||
import { readFileSyncStrippingUtf8Bom } from "@cline/shared/node";
|
||||
import { Command } from "commander";
|
||||
import { getToolCatalog } from "../runtime/tools";
|
||||
import { loadInteractiveConfigData } from "../tui/interactive-config";
|
||||
@@ -209,7 +210,7 @@ async function runAgentsConfigCommand(
|
||||
continue;
|
||||
}
|
||||
const filePath = join(directory, entry.name);
|
||||
const raw = readFileSync(filePath, "utf8");
|
||||
const raw = readFileSyncStrippingUtf8Bom(filePath);
|
||||
const frontmatterMatch = raw.match(/^---\r?\n([\s\S]*?)\r?\n---/);
|
||||
const frontmatter = frontmatterMatch?.[1] ?? "";
|
||||
const nameMatch = frontmatter.match(/^\s*name:\s*(.+?)\s*$/m);
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { version as cliVersion } from "../../package.json";
|
||||
import { getCliBuildInfo } from "../utils/common";
|
||||
|
||||
const {
|
||||
@@ -174,6 +175,40 @@ describe("runDoctorCommand", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("reports CLI and running hub Core versions", async () => {
|
||||
const cwd = "/workspace";
|
||||
mockReadHubDiscovery.mockResolvedValue({
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
port: 25463,
|
||||
pid: 50174,
|
||||
coreVersion: "0.0.63",
|
||||
});
|
||||
mockProbeHubServer.mockResolvedValue({
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
port: 25463,
|
||||
pid: 50174,
|
||||
coreVersion: "0.0.64",
|
||||
});
|
||||
mockSpawnSync.mockReturnValue({ status: 1, stdout: "" });
|
||||
|
||||
const output: string[] = [];
|
||||
const code = await runDoctorCommand(
|
||||
{ cwd, json: true },
|
||||
{
|
||||
writeln: (text) => {
|
||||
output.push(text ?? "");
|
||||
},
|
||||
writeErr: () => {},
|
||||
},
|
||||
);
|
||||
|
||||
expect(code).toBe(0);
|
||||
expect(JSON.parse(output[0] || "")).toMatchObject({
|
||||
cliVersion,
|
||||
coreVersion: "0.0.64",
|
||||
});
|
||||
});
|
||||
|
||||
it("doctor --fix clears wedged hub startup artifacts when no server is actually running", async () => {
|
||||
const cwd = mkdtempSync(path.join(os.tmpdir(), "doctor-hub-fix-"));
|
||||
tempDirs.push(cwd);
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
import { formatUptime, resolveClineBuildEnv } from "@cline/shared";
|
||||
import { Command } from "commander";
|
||||
import open from "open";
|
||||
import { version as cliVersion } from "../../package.json";
|
||||
import { isProcessRunning } from "../connectors/common";
|
||||
import {
|
||||
type ActiveConnectorRecord,
|
||||
@@ -49,6 +50,8 @@ type SpawnedProcessRecord = {
|
||||
|
||||
type DoctorStatus = {
|
||||
cwd: string;
|
||||
cliVersion: string;
|
||||
coreVersion?: string;
|
||||
hubUrl?: string;
|
||||
hubHealthy: boolean;
|
||||
hubPid?: number;
|
||||
@@ -337,6 +340,8 @@ async function collectDoctorStatus(cwd: string): Promise<DoctorStatus> {
|
||||
];
|
||||
return {
|
||||
cwd,
|
||||
cliVersion,
|
||||
coreVersion: health?.coreVersion ?? discovery?.coreVersion,
|
||||
hubUrl: current?.url,
|
||||
hubHealthy: !!health?.url,
|
||||
hubPid: current?.pid,
|
||||
@@ -419,6 +424,8 @@ export async function runDoctorCommand(
|
||||
io.writeln(JSON.stringify(before));
|
||||
return 0;
|
||||
}
|
||||
writeln(`cli version ${c.dim}${before.cliVersion}${c.reset}`);
|
||||
writeln(`core version ${c.dim}${before.coreVersion ?? "n/a"}${c.reset}`);
|
||||
writeln(`hub url ${c.dim}${before.hubUrl ?? "none"}${c.reset}`);
|
||||
writeln(
|
||||
`hub healthy ${c.dim}${before.hubHealthy ? "yes" : "no"}${before.hubPid ? ` (pid=${before.hubPid})` : ""}${c.reset}`,
|
||||
|
||||
@@ -34,6 +34,7 @@ vi.mock("@cline/core", () => ({
|
||||
stopLocalHubServerGracefully: mockStopLocalHubServerGracefully,
|
||||
}));
|
||||
|
||||
import { version as cliVersion } from "../../package.json";
|
||||
import { createHubCommand } from "./hub";
|
||||
|
||||
const originalBuildEnv = process.env.CLINE_BUILD_ENV;
|
||||
@@ -63,6 +64,7 @@ describe("createHubCommand", () => {
|
||||
port: 25463,
|
||||
pid: 50174,
|
||||
startedAt: "2026-01-01T00:00:00.000Z",
|
||||
coreVersion: "0.0.62",
|
||||
});
|
||||
|
||||
const output: string[] = [];
|
||||
@@ -88,6 +90,8 @@ describe("createHubCommand", () => {
|
||||
pid: 50174,
|
||||
startedAt: "2026-01-01T00:00:00.000Z",
|
||||
uptime: "1m 5s",
|
||||
cliVersion,
|
||||
coreVersion: "0.0.62",
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
} from "@cline/core";
|
||||
import { formatUptime, resolveClineBuildEnv } from "@cline/shared";
|
||||
import { Command } from "commander";
|
||||
import { version as cliVersion } from "../../package.json";
|
||||
|
||||
interface HubCommandIo {
|
||||
writeln: (text?: string) => void;
|
||||
@@ -134,6 +135,8 @@ export function createHubCommand(
|
||||
pid: health?.pid,
|
||||
startedAt: health?.startedAt,
|
||||
uptime,
|
||||
cliVersion,
|
||||
coreVersion: health?.coreVersion ?? discovery?.coreVersion,
|
||||
}),
|
||||
);
|
||||
}),
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
type UserInstructionConfigService,
|
||||
type WorkflowConfig,
|
||||
} from "@cline/core";
|
||||
import { readFileSyncStrippingUtf8Bom } from "@cline/shared/node";
|
||||
import { getToolCatalog } from "../runtime/tools";
|
||||
import {
|
||||
type InteractiveSlashCommand,
|
||||
@@ -195,7 +196,7 @@ function loadAgentConfigItems(workspaceRoot: string): InteractiveConfigItem[] {
|
||||
continue;
|
||||
}
|
||||
const filePath = join(directory, entry.name);
|
||||
const raw = readFileSync(filePath, "utf8");
|
||||
const raw = readFileSyncStrippingUtf8Bom(filePath);
|
||||
const frontmatterMatch = raw.match(/^---\r?\n([\s\S]*?)\r?\n---/);
|
||||
const frontmatter = frontmatterMatch?.[1] ?? "";
|
||||
const nameMatch = frontmatter.match(/^\s*name:\s*(.+?)\s*$/m);
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
resolvePluginConfigSearchPaths,
|
||||
resolveAgentConfigSearchPaths as resolveSharedAgentConfigSearchPaths,
|
||||
} from "@cline/core";
|
||||
import { readFileSyncStrippingUtf8Bom } from "@cline/shared/node";
|
||||
import { readMcpServersResponse } from "./mcp";
|
||||
import type { JsonRecord } from "./types";
|
||||
|
||||
@@ -114,7 +115,7 @@ export async function listUserInstructionConfigs(
|
||||
const ext = extname(entry.name).toLowerCase();
|
||||
if (ext !== ".yml" && ext !== ".yaml") continue;
|
||||
const filePath = join(directory, entry.name);
|
||||
const raw = readFileSync(filePath, "utf8");
|
||||
const raw = readFileSyncStrippingUtf8Bom(filePath);
|
||||
const fmMatch = raw.match(/^---\r?\n([\s\S]*?)\r?\n---/);
|
||||
const fm = fmMatch?.[1] ?? "";
|
||||
const nameMatch = fm.match(/^\s*name:\s*(.+?)\s*$/m);
|
||||
|
||||
@@ -3,9 +3,12 @@
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build:ui": "bun -F @cline/ui build",
|
||||
"predev:web": "bun run build:ui",
|
||||
"dev:web": "next dev webview -p 3125 --turbo",
|
||||
"dev:sidecar": "bun run sidecar/index.ts",
|
||||
"dev": "tauri dev",
|
||||
"prebuild": "bun run build:ui",
|
||||
"build": "bun run bun.mts",
|
||||
"build:sidecar": "mkdir -p dist/sidecar && bun build ./sidecar/index.ts --outfile ./dist/sidecar/index.js --target bun",
|
||||
"build:sidecar:bin": "bun run scripts/build-sidecar-bin.ts",
|
||||
@@ -16,7 +19,10 @@
|
||||
"package:desktop:windows": "bun run scripts/package-desktop.ts --platform windows",
|
||||
"package:desktop:linux": "bun run scripts/package-desktop.ts --platform linux",
|
||||
"start": "next start webview",
|
||||
"pretypecheck": "bun run build:ui",
|
||||
"typecheck": "tsc -p tsconfig.dev.json --noEmit",
|
||||
"pretest:chat-ui": "bun run build:ui",
|
||||
"test:chat-ui": "vitest run webview/components/views/chat/chat-messages.test.tsx --config vitest.config.ts",
|
||||
"clean": "rm -rf webview/.next webview/out node_modules dist && (cd src-tauri && rm -rf target node_modules dist)"
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
@@ -50,6 +50,7 @@ import {
|
||||
updateMcpSettingsFileSync,
|
||||
} from "@cline/core";
|
||||
import { getClineEnvironmentConfig } from "@cline/shared";
|
||||
import { readFileSyncStrippingUtf8Bom } from "@cline/shared/node";
|
||||
import {
|
||||
connectorChannelsPayload,
|
||||
startConnectorChannel,
|
||||
@@ -558,7 +559,7 @@ async function listUserInstructionConfigs(
|
||||
const ext = extname(entry.name).toLowerCase();
|
||||
if (ext !== ".yml" && ext !== ".yaml") continue;
|
||||
const filePath = join(directory, entry.name);
|
||||
const raw = readFileSync(filePath, "utf8");
|
||||
const raw = readFileSyncStrippingUtf8Bom(filePath);
|
||||
const fmMatch = raw.match(/^---\r?\n([\s\S]*?)\r?\n---/);
|
||||
const fm = fmMatch?.[1] ?? "";
|
||||
const nameMatch = fm.match(/^\s*name:\s*(.+?)\s*$/m);
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
@import "@cline/ui/theme/index.css";
|
||||
@import "@cline/ui/components/agent-chat.css";
|
||||
|
||||
@source "../../node_modules/streamdown/dist";
|
||||
|
||||
|
||||
@@ -1,12 +1,27 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Message as AgentMessage,
|
||||
Conversation,
|
||||
ConversationContent,
|
||||
ConversationScrollButton,
|
||||
ConversationViewport,
|
||||
MessageAction,
|
||||
MessageActions,
|
||||
MessageContent,
|
||||
Reasoning,
|
||||
ReasoningContent,
|
||||
ReasoningTrigger,
|
||||
ToolActivity,
|
||||
ToolActivityCode,
|
||||
ToolActivityContent,
|
||||
ToolActivityDetails,
|
||||
ToolActivityTrigger,
|
||||
} from "@cline/ui/components/agent-chat";
|
||||
import {
|
||||
AlertCircle,
|
||||
Bot,
|
||||
BrainIcon,
|
||||
Check,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Clock3,
|
||||
Copy,
|
||||
FileEdit,
|
||||
@@ -20,15 +35,7 @@ import {
|
||||
SquareTerminalIcon,
|
||||
UndoIcon,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
memo,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useId,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { memo, useCallback, useEffect, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { toast } from "@/hooks/use-toast";
|
||||
import type { ChatMessage, ChatSessionStatus } from "@/lib/chat-schema";
|
||||
@@ -86,11 +93,9 @@ type AskQuestionRequestItem = {
|
||||
};
|
||||
|
||||
const IS_DEBUG = process.env.NODE_ENV === "test";
|
||||
const STICKY_BOTTOM_THRESHOLD_PX = 24;
|
||||
const SCROLL_TO_BOTTOM_BUTTON_THRESHOLD_PX = 120;
|
||||
|
||||
function ChatMessagesImpl({
|
||||
sessionId: _sessionId,
|
||||
sessionId,
|
||||
status,
|
||||
chatTransportState = "connecting",
|
||||
isSessionSwitching = false,
|
||||
@@ -105,9 +110,6 @@ function ChatMessagesImpl({
|
||||
onRestoreCheckpoint,
|
||||
onForkSession,
|
||||
}: ChatMessagesProps) {
|
||||
const scrollAreaRef = useRef<HTMLDivElement | null>(null);
|
||||
const scrollContentRef = useRef<HTMLDivElement | null>(null);
|
||||
const shouldStickToBottomRef = useRef(true);
|
||||
const hasMessages = messages.length > 0;
|
||||
const lastErrorMessage = [...messages]
|
||||
.reverse()
|
||||
@@ -115,7 +117,6 @@ function ChatMessagesImpl({
|
||||
const shouldShowErrorBanner =
|
||||
Boolean(error) && (!lastErrorMessage || lastErrorMessage.content !== error);
|
||||
const [showSwitchTransition, setShowSwitchTransition] = useState(false);
|
||||
const [showScrollToBottom, setShowScrollToBottom] = useState(false);
|
||||
const [toolApprovalActions, setToolApprovalActions] = useState<
|
||||
Record<string, "approving" | "rejecting">
|
||||
>({});
|
||||
@@ -140,23 +141,6 @@ function ChatMessagesImpl({
|
||||
const showIdleDetails =
|
||||
!hasMessages && !isSessionSwitching && !showSwitchTransition;
|
||||
|
||||
const getViewport = useCallback(() => {
|
||||
return scrollAreaRef.current;
|
||||
}, []);
|
||||
|
||||
const scrollToBottom = useCallback(
|
||||
(behavior: ScrollBehavior = "smooth") => {
|
||||
const viewport = getViewport();
|
||||
if (!viewport) {
|
||||
return;
|
||||
}
|
||||
shouldStickToBottomRef.current = true;
|
||||
viewport.scrollTo({ top: viewport.scrollHeight, behavior });
|
||||
setShowScrollToBottom((prev) => (prev ? false : prev));
|
||||
},
|
||||
[getViewport],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isSessionSwitching) {
|
||||
setShowSwitchTransition((prev) => (prev ? false : prev));
|
||||
@@ -170,50 +154,6 @@ function ChatMessagesImpl({
|
||||
};
|
||||
}, [isSessionSwitching]);
|
||||
|
||||
useEffect(() => {
|
||||
const viewport = getViewport();
|
||||
if (!viewport) {
|
||||
return;
|
||||
}
|
||||
|
||||
const updateScrollToBottomVisibility = () => {
|
||||
const distanceFromBottom =
|
||||
viewport.scrollHeight - viewport.scrollTop - viewport.clientHeight;
|
||||
shouldStickToBottomRef.current =
|
||||
distanceFromBottom <= STICKY_BOTTOM_THRESHOLD_PX;
|
||||
const shouldShow =
|
||||
distanceFromBottom > SCROLL_TO_BOTTOM_BUTTON_THRESHOLD_PX;
|
||||
setShowScrollToBottom((prev) =>
|
||||
prev === shouldShow ? prev : shouldShow,
|
||||
);
|
||||
};
|
||||
|
||||
updateScrollToBottomVisibility();
|
||||
viewport.addEventListener("scroll", updateScrollToBottomVisibility);
|
||||
|
||||
return () => {
|
||||
viewport.removeEventListener("scroll", updateScrollToBottomVisibility);
|
||||
};
|
||||
}, [getViewport]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!shouldStickToBottomRef.current) {
|
||||
return;
|
||||
}
|
||||
scrollToBottom("auto");
|
||||
}, [scrollToBottom]);
|
||||
|
||||
useEffect(() => {
|
||||
const content = scrollContentRef.current;
|
||||
if (!content || typeof ResizeObserver === "undefined") return;
|
||||
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
if (shouldStickToBottomRef.current) scrollToBottom("auto");
|
||||
});
|
||||
resizeObserver.observe(content);
|
||||
return () => resizeObserver.disconnect();
|
||||
}, [scrollToBottom]);
|
||||
|
||||
useEffect(() => {
|
||||
const activeRequestIds = new Set(
|
||||
pendingToolApprovals.map((item) => item.requestId),
|
||||
@@ -377,17 +317,19 @@ function ChatMessagesImpl({
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="relative h-full min-h-0 min-w-0">
|
||||
<div
|
||||
className="h-full min-h-0 min-w-0 overflow-x-hidden overflow-y-auto"
|
||||
ref={scrollAreaRef}
|
||||
<Conversation
|
||||
className="h-full min-h-0 min-w-0"
|
||||
key={sessionId ?? "new-chat"}
|
||||
>
|
||||
<ConversationViewport
|
||||
aria-label="Agent conversation"
|
||||
className="h-full min-h-0 min-w-0"
|
||||
>
|
||||
<div
|
||||
<ConversationContent
|
||||
className={cn(
|
||||
"relative mx-auto min-h-full w-full min-w-0 max-w-full overflow-x-hidden",
|
||||
showIdleDetails ? "p-0" : "px-6 py-6",
|
||||
)}
|
||||
ref={scrollContentRef}
|
||||
>
|
||||
{showIdleDetails ? null : (
|
||||
<div className="flex min-h-full w-full min-w-0 flex-col gap-2 overflow-x-hidden">
|
||||
@@ -497,21 +439,10 @@ function ChatMessagesImpl({
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
{showScrollToBottom ? (
|
||||
<Button
|
||||
className="absolute bottom-4 right-4 z-20 size-9 rounded-full shadow-sm"
|
||||
onClick={() => scrollToBottom("smooth")}
|
||||
size="icon"
|
||||
type="button"
|
||||
variant="secondary"
|
||||
>
|
||||
<ChevronDown className="size-4" />
|
||||
<span className="sr-only">Scroll to bottom</span>
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</ConversationContent>
|
||||
</ConversationViewport>
|
||||
<ConversationScrollButton />
|
||||
</Conversation>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -746,8 +677,6 @@ function MessageBubble({
|
||||
isUser && Boolean(onCopyRawText || checkpoint);
|
||||
const keepUserActionsVisible = restorePending || Boolean(restoreError);
|
||||
const keepAssistantActionsVisible = forkPending || Boolean(forkError);
|
||||
const hiddenActionButtonsClassName =
|
||||
"pointer-events-none opacity-0 transition-opacity group-hover:pointer-events-auto group-hover:opacity-100 group-focus-within:pointer-events-auto group-focus-within:opacity-100";
|
||||
|
||||
if (message.role === "tool") {
|
||||
return <ToolMessageBlock message={message} />;
|
||||
@@ -760,159 +689,102 @@ function MessageBubble({
|
||||
const reasoningContent = message.reasoning?.trim() || "";
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex min-w-0",
|
||||
isUser ? "justify-end" : "w-full justify-start",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"group max-w-full min-w-0 wrap-break-word text-sm",
|
||||
isUser && "flex max-w-[85%] flex-col items-end gap-1 md:max-w-[50%]",
|
||||
!isUser && "flex flex-col items-start gap-2 overflow-hidden",
|
||||
!isUser && !isError && "text-foreground",
|
||||
isError &&
|
||||
"bg-destructive/10 border border-destructive/40 text-destructive",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"max-w-full min-w-0 space-y-2 overflow-hidden wrap-break-word",
|
||||
isUser && "rounded-sm bg-card p-2 text-foreground/80",
|
||||
)}
|
||||
>
|
||||
{reasoningContent || message.reasoningRedacted ? (
|
||||
<ReasoningBlock
|
||||
content={reasoningContent}
|
||||
redacted={message.reasoningRedacted === true}
|
||||
streaming={isStreaming}
|
||||
/>
|
||||
) : null}
|
||||
<AgentMessage from={message.role}>
|
||||
<MessageContent className="space-y-2 wrap-break-word">
|
||||
{reasoningContent || message.reasoningRedacted ? (
|
||||
<ReasoningBlock
|
||||
content={reasoningContent}
|
||||
redacted={message.reasoningRedacted === true}
|
||||
streaming={isStreaming}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<div className="my-1 min-w-0 max-w-full wrap-break-word">
|
||||
<MemoizedMarkdown
|
||||
content={displayContent || " "}
|
||||
streaming={isStreaming && message.role === "assistant"}
|
||||
/>
|
||||
</div>
|
||||
<div className="my-1 min-w-0 max-w-full wrap-break-word">
|
||||
<MemoizedMarkdown
|
||||
content={displayContent || " "}
|
||||
streaming={isStreaming && message.role === "assistant"}
|
||||
/>
|
||||
</div>
|
||||
{shouldRenderUserActions ? (
|
||||
<div className="space-y-1">
|
||||
<div className="flex h-6 items-center justify-end">
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center justify-end gap-2",
|
||||
keepUserActionsVisible
|
||||
? "pointer-events-auto opacity-100"
|
||||
: hiddenActionButtonsClassName,
|
||||
)}
|
||||
</MessageContent>
|
||||
|
||||
{shouldRenderUserActions ? (
|
||||
<>
|
||||
<MessageActions visible={keepUserActionsVisible}>
|
||||
{onCopyRawText ? (
|
||||
<MessageAction
|
||||
label={wasCopied ? "Copied user message" : "Copy user message"}
|
||||
onClick={onCopyRawText}
|
||||
title={wasCopied ? "Copied" : "Copy message"}
|
||||
>
|
||||
{onCopyRawText ? (
|
||||
<Button
|
||||
className="h-6 px-2 text-xs text-muted-foreground hover:text-foreground"
|
||||
aria-label={
|
||||
wasCopied ? "Copied user message" : "Copy user message"
|
||||
}
|
||||
onClick={onCopyRawText}
|
||||
size="sm"
|
||||
title={wasCopied ? "Copied" : "Copy message"}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
{wasCopied ? (
|
||||
<Check className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</Button>
|
||||
) : null}
|
||||
{checkpoint ? (
|
||||
<Button
|
||||
className="h-6 px-2 text-xs text-muted-foreground hover:text-foreground"
|
||||
aria-label="Restore checkpoint"
|
||||
disabled={restoreDisabled || restorePending}
|
||||
onClick={() => onRestoreCheckpoint?.(checkpoint.runCount)}
|
||||
size="sm"
|
||||
title="Restore checkpoint"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
{restorePending ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<UndoIcon className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
{restoreError ? (
|
||||
<div className="text-right text-xs text-destructive">
|
||||
{restoreError}
|
||||
</div>
|
||||
{wasCopied ? (
|
||||
<Check className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</MessageAction>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
{shouldRenderAssistantActions ? (
|
||||
<div className="flex h-6 items-center hidden">
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-0",
|
||||
keepAssistantActionsVisible
|
||||
? "pointer-events-auto opacity-100"
|
||||
: hiddenActionButtonsClassName,
|
||||
)}
|
||||
>
|
||||
{onCopyRawText ? (
|
||||
<Button
|
||||
className="h-6 gap-1.5 px-2 text-[11px] text-muted-foreground hover:text-foreground"
|
||||
aria-label={
|
||||
wasCopied
|
||||
? "Copied assistant message"
|
||||
: "Copy assistant message"
|
||||
}
|
||||
onClick={onCopyRawText}
|
||||
size="sm"
|
||||
title={wasCopied ? "Copied" : "Copy raw assistant output"}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
{wasCopied ? (
|
||||
<Check className="h-3 w-3" />
|
||||
) : (
|
||||
<Copy className="h-3 w-3" />
|
||||
)}
|
||||
</Button>
|
||||
) : null}
|
||||
{onForkSession ? (
|
||||
<Button
|
||||
className="h-6 gap-1.5 px-2 text-[11px] text-muted-foreground hover:text-foreground"
|
||||
aria-label="Fork session"
|
||||
disabled={forkPending}
|
||||
onClick={onForkSession}
|
||||
size="sm"
|
||||
title="Fork session - copy full message history into a new session"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
{forkPending ? (
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
) : (
|
||||
<SplitIcon className="h-3 w-3" />
|
||||
)}
|
||||
</Button>
|
||||
) : null}
|
||||
{forkError ? (
|
||||
<span className="text-[11px] text-destructive">
|
||||
{forkError}
|
||||
</span>
|
||||
) : null}
|
||||
{checkpoint ? (
|
||||
<MessageAction
|
||||
disabled={restoreDisabled || restorePending}
|
||||
label="Restore checkpoint"
|
||||
onClick={() => onRestoreCheckpoint?.(checkpoint.runCount)}
|
||||
title="Restore checkpoint"
|
||||
>
|
||||
{restorePending ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<UndoIcon className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</MessageAction>
|
||||
) : null}
|
||||
</MessageActions>
|
||||
{restoreError ? (
|
||||
<div className="text-right text-xs text-destructive">
|
||||
{restoreError}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{shouldRenderAssistantActions ? (
|
||||
<MessageActions visible={keepAssistantActionsVisible}>
|
||||
{onCopyRawText ? (
|
||||
<MessageAction
|
||||
label={
|
||||
wasCopied
|
||||
? "Copied assistant message"
|
||||
: "Copy assistant message"
|
||||
}
|
||||
onClick={onCopyRawText}
|
||||
title={wasCopied ? "Copied" : "Copy raw assistant output"}
|
||||
>
|
||||
{wasCopied ? (
|
||||
<Check className="h-3 w-3" />
|
||||
) : (
|
||||
<Copy className="h-3 w-3" />
|
||||
)}
|
||||
</MessageAction>
|
||||
) : null}
|
||||
{onForkSession ? (
|
||||
<MessageAction
|
||||
disabled={forkPending}
|
||||
label="Fork session"
|
||||
onClick={onForkSession}
|
||||
title="Fork session - copy full message history into a new session"
|
||||
>
|
||||
{forkPending ? (
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
) : (
|
||||
<SplitIcon className="h-3 w-3" />
|
||||
)}
|
||||
</MessageAction>
|
||||
) : null}
|
||||
{forkError ? (
|
||||
<span className="text-[11px] text-destructive">{forkError}</span>
|
||||
) : null}
|
||||
</MessageActions>
|
||||
) : null}
|
||||
</AgentMessage>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -925,48 +797,18 @@ function ReasoningBlock({
|
||||
redacted: boolean;
|
||||
streaming?: boolean;
|
||||
}) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const panelId = useId();
|
||||
const displayContent = content || (redacted ? "[redacted]" : "");
|
||||
if (!displayContent) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="my-2">
|
||||
<Button
|
||||
aria-controls={panelId}
|
||||
aria-expanded={expanded}
|
||||
className="h-auto min-h-0 max-w-full justify-start gap-2 whitespace-normal px-0 py-1 text-left text-sm font-medium text-foreground/70 hover:bg-transparent hover:text-foreground has-[>svg]:px-0 dark:hover:bg-transparent dark:hover:text-foreground"
|
||||
onClick={() => setExpanded((current) => !current)}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<BrainIcon aria-hidden="true" className="size-4" />
|
||||
<span>{streaming ? "Thinking" : "Thought process"}</span>
|
||||
<span
|
||||
aria-live="polite"
|
||||
className="text-xs font-normal text-muted-foreground"
|
||||
>
|
||||
{streaming ? "In progress" : "Complete"}
|
||||
</span>
|
||||
<span aria-hidden="true" className="shrink-0 text-muted-foreground">
|
||||
{expanded ? (
|
||||
<ChevronDown className="size-4" />
|
||||
) : (
|
||||
<ChevronRight className="size-4" />
|
||||
)}
|
||||
</span>
|
||||
</Button>
|
||||
{expanded ? (
|
||||
<div
|
||||
className="mt-1.5 min-w-0 max-w-full rounded-lg border border-border/70 bg-muted/30 p-3 text-sm leading-relaxed text-muted-foreground"
|
||||
id={panelId}
|
||||
>
|
||||
<MemoizedMarkdown content={displayContent} streaming={streaming} />
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<Reasoning isStreaming={streaming}>
|
||||
<ReasoningTrigger />
|
||||
<ReasoningContent>
|
||||
<MemoizedMarkdown content={displayContent} streaming={streaming} />
|
||||
</ReasoningContent>
|
||||
</Reasoning>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1347,8 +1189,6 @@ function buildToolSummaryFromMeta(
|
||||
}
|
||||
|
||||
function ToolMessageBlock({ message }: { message: ChatMessage }) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const panelId = useId();
|
||||
const payload = parseToolPayload(message.content);
|
||||
const toolName = message.meta?.toolName || payload?.toolName || "tool";
|
||||
const hookEventName = message.meta?.hookEventName;
|
||||
@@ -1380,94 +1220,50 @@ function ToolMessageBlock({ message }: { message: ChatMessage }) {
|
||||
const resultPreview = payload?.isError ? formatToolValue(payload.result) : "";
|
||||
const hasExpandedSections =
|
||||
details.length > 0 || Boolean(inputPreview || resultPreview);
|
||||
const summaryContent = (
|
||||
<>
|
||||
{payload?.isError ? (
|
||||
<AlertCircle className="size-4 text-destructive/80" />
|
||||
) : (
|
||||
<Icon className="size-4" />
|
||||
)}
|
||||
<span className="min-w-0 wrap-break-word">{summary.label}</span>
|
||||
{summary.diff ? (
|
||||
<span className="shrink-0 font-mono text-xs">
|
||||
<span className="text-chart-2">+{summary.diff.additions}</span>{" "}
|
||||
<span className="text-destructive">-{summary.diff.deletions}</span>
|
||||
</span>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="my-2 flex w-full min-w-0 justify-start">
|
||||
<div
|
||||
className={cn("min-w-0 max-w-full overflow-hidden rounded-xl text-sm")}
|
||||
>
|
||||
{hasExpandedSections ? (
|
||||
<Button
|
||||
aria-controls={panelId}
|
||||
aria-expanded={expanded}
|
||||
className="h-auto min-h-0 max-w-full justify-start gap-2 whitespace-normal px-0 py-1 text-left text-sm font-medium text-primary hover:bg-transparent hover:text-primary/80 has-[>svg]:px-0 dark:hover:bg-transparent dark:hover:text-primary/80"
|
||||
onClick={() => setExpanded((current) => !current)}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
{summaryContent}
|
||||
<span className="shrink-0 text-muted-foreground">
|
||||
{expanded ? (
|
||||
<ChevronDown className="size-4" />
|
||||
) : (
|
||||
<ChevronRight className="size-4" />
|
||||
)}
|
||||
</span>
|
||||
</Button>
|
||||
) : (
|
||||
<div className="flex max-w-full items-center justify-start gap-2 py-1 text-left text-sm font-medium text-primary">
|
||||
{summaryContent}
|
||||
</div>
|
||||
)}
|
||||
{expanded ? (
|
||||
<div
|
||||
className="mt-1.5 min-w-0 max-w-full overflow-x-hidden pl-8 text-sm text-muted-foreground"
|
||||
id={panelId}
|
||||
>
|
||||
{hasExpandedSections ? (
|
||||
<div className="space-y-1">
|
||||
{details.map((detail) => (
|
||||
<div
|
||||
className="wrap-break-word"
|
||||
key={`${message.id}_${detail}`}
|
||||
>
|
||||
{detail}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
{inputPreview ? (
|
||||
<div className="space-y-1">
|
||||
<div className="text-[11px] uppercase tracking-wide text-muted-foreground/80">
|
||||
Input
|
||||
</div>
|
||||
<pre className="max-h-52 max-w-full overflow-x-hidden overflow-y-auto whitespace-pre-wrap wrap-break-word rounded-md border border-border/70 bg-background/60 p-2 text-sm leading-relaxed text-foreground">
|
||||
{inputPreview}
|
||||
</pre>
|
||||
</div>
|
||||
) : null}
|
||||
{resultPreview ? (
|
||||
payload?.isError ? (
|
||||
<div className="mt-1">
|
||||
<span className="text-destructive">{resultPreview}</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
<pre className="max-h-64 max-w-full overflow-x-hidden overflow-y-auto whitespace-pre-wrap wrap-break-word rounded-md border border-border/70 bg-background/60 p-2 text-sm leading-relaxed text-foreground">
|
||||
{resultPreview}
|
||||
</pre>
|
||||
</div>
|
||||
)
|
||||
) : null}
|
||||
<ToolActivity expandable={hasExpandedSections}>
|
||||
<ToolActivityTrigger
|
||||
additions={summary.diff?.additions}
|
||||
deletions={summary.diff?.deletions}
|
||||
icon={
|
||||
payload?.isError ? (
|
||||
<AlertCircle className="size-4 text-destructive/80" />
|
||||
) : (
|
||||
<Icon className="size-4" />
|
||||
)
|
||||
}
|
||||
label={summary.label}
|
||||
status={payload?.isError ? "error" : inProgress ? "running" : "success"}
|
||||
/>
|
||||
<ToolActivityContent>
|
||||
{details.length > 0 ? (
|
||||
<ToolActivityDetails>
|
||||
{details.map((detail) => (
|
||||
<div key={`${message.id}_${detail}`}>{detail}</div>
|
||||
))}
|
||||
</ToolActivityDetails>
|
||||
) : null}
|
||||
{inputPreview ? (
|
||||
<div className="space-y-1">
|
||||
<div className="text-[11px] uppercase tracking-wide text-muted-foreground/80">
|
||||
Input
|
||||
</div>
|
||||
<ToolActivityCode className="text-sm">
|
||||
{inputPreview}
|
||||
</ToolActivityCode>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
{resultPreview ? (
|
||||
payload?.isError ? (
|
||||
<div className="mt-1 text-destructive">{resultPreview}</div>
|
||||
) : (
|
||||
<ToolActivityCode className="max-h-64 text-sm">
|
||||
{resultPreview}
|
||||
</ToolActivityCode>
|
||||
)
|
||||
) : null}
|
||||
</ToolActivityContent>
|
||||
</ToolActivity>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
# vscode-rollout — A/B loader for the SDK extension rollout
|
||||
|
||||
The VS Code Marketplace has no staged rollouts: publishing a version updates
|
||||
every user. This package lets us ship the **SDK-based extension** (main's
|
||||
`apps/vscode`, "next") to a percentage of users while everyone else keeps
|
||||
running the **legacy extension** (the `legacy-extension` branch), inside a
|
||||
single published VSIX.
|
||||
|
||||
## How it works
|
||||
|
||||
The published VSIX contains a ~40 KB loader as its entrypoint and two complete,
|
||||
independently built extension bundles:
|
||||
|
||||
```
|
||||
extension.js ← loader (this package)
|
||||
package.json ← UNION of both bundles' manifests (generated, see below)
|
||||
assets/, walkthrough/ ← manifest-referenced resources (VSIX-root-relative)
|
||||
next/ ← SDK extension (dist/, webview-ui/build/, assets/)
|
||||
legacy/ ← legacy extension (dist/, webview-ui/build/, assets/, codicons)
|
||||
```
|
||||
|
||||
Per window, the loader:
|
||||
|
||||
1. Reads the cached cohort assignment from its own `globalState` keys —
|
||||
synchronously, never from the network.
|
||||
2. Sets the `cline.sdkBundle` context key (gates cohort-specific menu items /
|
||||
palette entries in the union manifest).
|
||||
3. `require()`s exactly one bundle and calls its `activate()` with a
|
||||
Proxy-wrapped `ExtensionContext` whose `extensionUri` / `extensionPath` /
|
||||
`asAbsolutePath` point into that bundle's subdirectory — so each bundle
|
||||
resolves its own webview build and assets without knowing it was relocated.
|
||||
Storage properties pass through untouched: both bundles share the same
|
||||
`~/.cline/data` + VS Code storage they used as standalone extensions.
|
||||
4. After the selected bundle activates, evaluates the PostHog flags in the
|
||||
background and caches the assignment **for the next window**. Flag changes
|
||||
never flip a live window. A crash fallback skips this refresh so it cannot
|
||||
overwrite the legacy pin.
|
||||
|
||||
If the next bundle throws during activation, the loader disposes whatever it
|
||||
half-registered, pins this VSIX version back to legacy
|
||||
(`cline.rollout.nextActivationFailedVersion`), reports a `fallback` telemetry
|
||||
event, and activates legacy — a crashed rollout self-heals without a
|
||||
marketplace re-publish. A new version gets to try next again.
|
||||
|
||||
## Cohort rules
|
||||
|
||||
- **Two-way, one knob.** `ext-sdk-bundle-rollout` (percentage flag) is the
|
||||
entire remote control surface: each background refresh caches exactly what
|
||||
the flag says for the machine's next window. Dialing the percentage up
|
||||
promotes; dialing it down demotes on the next reload — the emergency lever
|
||||
is simply "set the rollout to 0%". Known demotion costs (accepted): tasks
|
||||
created on the SDK bundle are stored as SDK sessions the legacy bundle
|
||||
doesn't list (they reappear on re-promotion — nothing is deleted), and
|
||||
credentials rotated on next may require a re-login on legacy.
|
||||
- **The flag must stay a boolean flag.** The loader only promotes on a
|
||||
literal `true` from `/decide` — a multivariate variant, number, or anything
|
||||
else fails safe to legacy (see `parseRolloutAssignment` + tests). Don't
|
||||
convert it to multivariate.
|
||||
- The flag is evaluated against the same PostHog distinct id the extension's
|
||||
telemetry uses (machine id, mirroring `src/services/logging/distinctId.ts`),
|
||||
so cohort membership is correlatable with telemetry. Flag evaluation is
|
||||
always on (matching `FeatureFlagsService`); the loader's own
|
||||
`extension.rollout.loader_decision` event respects the user's telemetry
|
||||
opt-out and VS Code's global telemetry switch.
|
||||
- **Manual overrides, in either direction.** The `cline.rollout.bundleOverride`
|
||||
user setting (`"auto" | "next" | "legacy"`, editable straight from
|
||||
settings.json) forces a bundle for anyone — users in a pinch, or us
|
||||
debugging — beating the remote assignment both ways. Applies on window
|
||||
reload. `CLINE_BUNDLE_OVERRIDE=next|legacy` (env var) does the same for
|
||||
local dev and e2e and beats even the setting. Both are reported as
|
||||
`override` on the loader event so overridden machines don't pollute
|
||||
cohort comparisons.
|
||||
- **Crash pinning is local, not remote.** If the next bundle throws during
|
||||
activation, the loader falls back to legacy in the same window and pins
|
||||
that VSIX version on this machine (`cline.rollout.nextActivationFailedVersion`);
|
||||
a new release gets to try next again. This safety net is independent of the
|
||||
flag.
|
||||
|
||||
## The union manifest
|
||||
|
||||
`package.json` contributions are static — VS Code reads them before any code
|
||||
runs — so the shipped manifest must serve both cohorts. `scripts/gen-manifest.mjs`
|
||||
regenerates it at stitch time from both branches' real manifests:
|
||||
|
||||
- Contributions declared by both bundles pass through untouched.
|
||||
- Menu entries / keybindings declared by only one get `when` AND-ed with
|
||||
`cline.sdkBundle` / `!cline.sdkBundle`, so a cohort never sees a button its
|
||||
bundle didn't register (and shared buttons that moved position don't render
|
||||
twice).
|
||||
- Commands exclusive to one bundle are hidden from the other cohort's command
|
||||
palette.
|
||||
- `views` / `viewsContainers` / `configuration` / `walkthroughs`
|
||||
**must be identical** in both manifests — they can't be safely gated at
|
||||
runtime, so divergence fails the build. Keep these static contributions in
|
||||
sync between the branches. `engines` may diverge: the union takes the newer
|
||||
requirement (which necessarily satisfies the older one).
|
||||
|
||||
Because the manifest is regenerated from both branches on every build,
|
||||
contribution drift between the branches can't ship silently — it either merges
|
||||
cleanly or the stitch fails.
|
||||
|
||||
## Building locally
|
||||
|
||||
```bash
|
||||
# 1. build both bundles (their own toolchains)
|
||||
cd apps/vscode && bun run package # next
|
||||
cd <legacy worktree>/apps/vscode && npm run package # legacy (npm ci first)
|
||||
|
||||
# 2. build the loader + stitch + package
|
||||
cd apps/vscode-rollout
|
||||
bun run build # dev build; CI uses build:production with the PostHog key
|
||||
node scripts/stitch.mjs \
|
||||
--next ../vscode --legacy <legacy worktree>/apps/vscode \
|
||||
--loader dist/extension.js --version 4.1.0 --out /tmp/cline-ab-staging
|
||||
node scripts/smoke-loader.mjs /tmp/cline-ab-staging # loader behavior smoke
|
||||
cd /tmp/cline-ab-staging && vsce package --no-dependencies --allow-package-secrets sendgrid
|
||||
```
|
||||
|
||||
The narrowly scoped `sendgrid` scanner exemption mirrors the existing next and
|
||||
legacy packaging workflows. This workflow supplies only the existing PostHog
|
||||
project-key inputs; it does not declare a SendGrid credential. Identify the
|
||||
exact matching string in production staging output before changing or
|
||||
broadening the exemption.
|
||||
|
||||
Local builds have no `TELEMETRY_SERVICE_API_KEY`, so the loader skips PostHog
|
||||
entirely and everyone stays on legacy unless `CLINE_BUNDLE_OVERRIDE` is set.
|
||||
|
||||
CI: the `ext-vscode-ab-package` workflow (manual dispatch) builds both refs,
|
||||
stitches, smoke-tests, uploads the `.vsix` artifact, and optionally publishes.
|
||||
|
||||
## Nightly channel
|
||||
|
||||
The daily `ext-vscode-publish-nightly` workflow (cron + manual dispatch)
|
||||
publishes this same combined package as **`saoudrizwan.cline-nightly`**. Before
|
||||
each bundle builds, `scripts/nightlify.mjs` rewrites its manifest to the
|
||||
nightly identity — the same mutation the standalone nightly always applied
|
||||
(`apps/vscode/scripts/publish-nightly.mjs` on both branches is the source of
|
||||
truth), so nightly can be installed alongside stable:
|
||||
|
||||
| | stable | nightly |
|
||||
|---|---|---|
|
||||
| manifest `name` | `claude-dev` | `cline-nightly` |
|
||||
| contribution IDs / context key / settings | `cline.*` | `cline-nightly.*` |
|
||||
| version | operator-supplied (4.1.0+) | `<major>.<minor>.<unix-seconds>` |
|
||||
|
||||
The loader derives the namespace from its own `packageJSON.name` at runtime
|
||||
(`idPrefix` in `src/cohort.ts`), and gen-manifest derives it from the next
|
||||
manifest's name — no build flags involved. Nightly builds also show a
|
||||
status-bar indicator (`Cline: Next` / `Cline: Legacy`); stable builds never do.
|
||||
|
||||
Dispatching the nightly workflow from `main` with `dry-run` builds and uploads
|
||||
the installable `.vsix` without publishing or tagging. The publish job is
|
||||
intentionally restricted to `main` by both the workflow and the
|
||||
`PublishNightly` environment's deployment-branch policy.
|
||||
|
||||
### Telemetry events
|
||||
|
||||
- **`extension.rollout.bundle_activated`** (authoritative, captured by the
|
||||
activated bundle's own telemetry via its `reportRolloutActivation` export;
|
||||
requires the bundle to be built with `CLINE_ROLLOUT_VARIANT`): attempted vs
|
||||
actual bundle, fallback flag, error details on fallback. Every other event
|
||||
from a rollout build carries `extension_variant` as a common property.
|
||||
- **`extension.rollout.loader_decision`** (loader-owned, direct capture): the
|
||||
loader-side metadata the bundle event can't know — override source, launch
|
||||
cadence, loader version, `extension_name` (nightly vs stable) — and the only
|
||||
signal when BOTH bundles fail (`double_failure: true`).
|
||||
|
||||
## Rollout runbook
|
||||
|
||||
Until the stable combined VSIX ships, the flag governs **nightly installs
|
||||
only** — dialing it is safe for production users and is the lever for moving
|
||||
nightly dogfooders onto next.
|
||||
|
||||
1. Create `ext-sdk-bundle-rollout` in PostHog **before** the first publish: a
|
||||
plain boolean release flag with a percentage rollout, starting at **0%**.
|
||||
(There is deliberately no kill-switch flag — the assignment is two-way, so
|
||||
0% *is* the kill switch.)
|
||||
2. Publish the combined VSIX (version above every previously published one).
|
||||
With the rollout at 0% this release is behaviorally identical to legacy for
|
||||
everyone — it only validates the loader plumbing in the wild. Watch
|
||||
`extension.rollout.bundle_activated` and `extension.rollout.loader_decision`.
|
||||
3. Dial `ext-sdk-bundle-rollout` up: 1% → 5% → 25% → 100%. Assignments apply on
|
||||
each machine's next window reload after its flag refresh, so propagation
|
||||
speed is bounded by how often people reload windows — watch the
|
||||
`ms_since_last_activation` distribution on loader events to see real
|
||||
uptake lag before deciding the next step, and compare cohorts by the
|
||||
`bundle` property.
|
||||
4. Emergencies: dial the percentage **down** (0% pulls everyone back to legacy
|
||||
on their next reload). Demoted machines keep settings and creds; tasks
|
||||
created on the SDK bundle reappear when re-promoted. Ship the fix as a
|
||||
higher version, then dial back up. Machines whose next bundle *crashed*
|
||||
are additionally version-pinned to legacy locally, independent of the flag.
|
||||
5. When next reaches 100% and soaks, retire the loader: publish a plain SDK
|
||||
extension build and delete this package.
|
||||
|
||||
## Version numbering
|
||||
|
||||
The combined VSIX owns the marketplace version line and must always exceed the
|
||||
last version published from either branch (legacy stable was 4.0.x → start at
|
||||
4.1.0). The bundles' own `package.json` versions ride along inside their
|
||||
subdirectories for provenance; the loader reports the combined version as
|
||||
`loader_version`.
|
||||
@@ -0,0 +1,27 @@
|
||||
import esbuild from "esbuild";
|
||||
|
||||
const production = process.argv.includes("--production");
|
||||
|
||||
// Same build-time secret injection scheme as apps/vscode/esbuild.mjs: CI
|
||||
// provides TELEMETRY_SERVICE_API_KEY; local builds leave it undefined and the
|
||||
// loader skips all PostHog calls (everyone stays on legacy).
|
||||
const define = {};
|
||||
if (process.env.TELEMETRY_SERVICE_API_KEY) {
|
||||
define["process.env.TELEMETRY_SERVICE_API_KEY"] = JSON.stringify(
|
||||
process.env.TELEMETRY_SERVICE_API_KEY,
|
||||
);
|
||||
}
|
||||
|
||||
await esbuild.build({
|
||||
entryPoints: ["src/extension.ts"],
|
||||
bundle: true,
|
||||
outfile: "dist/extension.js",
|
||||
platform: "node",
|
||||
format: "cjs",
|
||||
target: "node18",
|
||||
external: ["vscode"],
|
||||
minify: production,
|
||||
sourcemap: !production,
|
||||
define,
|
||||
logLevel: "info",
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "@cline/vscode-rollout",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"description": "Loader + packaging tooling for the staged (A/B) rollout of the SDK-based VS Code extension alongside the legacy extension",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "node esbuild.mjs",
|
||||
"build:production": "node esbuild.mjs --production",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "bun test src scripts",
|
||||
"stitch": "node scripts/stitch.mjs"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "20.x",
|
||||
"@types/vscode": "1.84.0",
|
||||
"esbuild": "^0.25.0",
|
||||
"node-machine-id": "^1.1.12",
|
||||
"typescript": "^5.4.5"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,437 @@
|
||||
/**
|
||||
* Generate the combined VSIX's package.json as the UNION of the two bundles'
|
||||
* manifests, regenerated from both branches' actual package.json files at
|
||||
* stitch time so contribution drift between branches can't ship silently.
|
||||
*
|
||||
* Rules:
|
||||
* - Identity/top-level fields come from the next (main) manifest.
|
||||
* - `main` points at the loader; `version` comes from the release input.
|
||||
* - commands / menus / keybindings / activationEvents / icons are unioned.
|
||||
* Menu entries and keybindings present in only ONE manifest get their
|
||||
* `when` clause AND-ed with the `<prefix>.sdkBundle` context key (set by the
|
||||
* loader before activation; prefix follows the manifest identity — see
|
||||
* src/cohort.ts idPrefix), so a cohort never sees a button whose handler
|
||||
* its bundle doesn't register — and shared buttons that moved position
|
||||
* don't show up twice. Commands exclusive to one bundle are likewise hidden
|
||||
* from the other cohort's command palette.
|
||||
* - views / viewsContainers / configuration / engines MUST be
|
||||
* identical in both manifests — they can't be safely gated at runtime, so
|
||||
* divergence is a hard error.
|
||||
*
|
||||
* Usage: node gen-manifest.mjs --next <pkg.json> --legacy <pkg.json> --version <x.y.z> [--out <file>]
|
||||
*/
|
||||
|
||||
import { deepStrictEqual } from "node:assert";
|
||||
import { readFileSync, writeFileSync } from "node:fs";
|
||||
|
||||
export function generateManifest(nextPkg, legacyPkg, version) {
|
||||
for (const field of ["name", "publisher", "main"]) {
|
||||
if (nextPkg[field] !== legacyPkg[field]) {
|
||||
throw new Error(
|
||||
`manifest field "${field}" differs: ${nextPkg[field]} vs ${legacyPkg[field]}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
for (const field of ["views", "viewsContainers", "configuration"]) {
|
||||
try {
|
||||
deepStrictEqual(
|
||||
nextPkg.contributes?.[field],
|
||||
legacyPkg.contributes?.[field],
|
||||
);
|
||||
} catch {
|
||||
throw new Error(
|
||||
`contributes.${field} diverged between bundles — it cannot be gated at runtime; reconcile the branches`,
|
||||
);
|
||||
}
|
||||
}
|
||||
const engines = unionEngines(nextPkg.engines, legacyPkg.engines);
|
||||
|
||||
assertWalkthroughsCompatible(
|
||||
nextPkg.contributes?.walkthroughs,
|
||||
legacyPkg.contributes?.walkthroughs,
|
||||
);
|
||||
|
||||
// The nightly packaging rewrites the whole `cline.*` ID namespace to
|
||||
// `cline-nightly.*` (scripts/nightlify.mjs), so the context key and the
|
||||
// injected setting must follow the manifest's identity. Keep in sync with
|
||||
// idPrefix/bundleContextKey/settingSection in src/cohort.ts.
|
||||
const prefix = nextPkg.name === "cline-nightly" ? "cline-nightly" : "cline";
|
||||
const nextGate = `${prefix}.sdkBundle`;
|
||||
const legacyGate = `!${nextGate}`;
|
||||
|
||||
const nc = nextPkg.contributes ?? {};
|
||||
const lc = legacyPkg.contributes ?? {};
|
||||
const menus = unionMenus(nc.menus, lc.menus, nextGate, legacyGate);
|
||||
hideExclusiveCommandsFromPalette(
|
||||
menus,
|
||||
nc.commands,
|
||||
lc.commands,
|
||||
nextGate,
|
||||
legacyGate,
|
||||
);
|
||||
|
||||
const manifest = {
|
||||
name: nextPkg.name,
|
||||
displayName: nextPkg.displayName,
|
||||
description: nextPkg.description,
|
||||
version,
|
||||
icon: nextPkg.icon,
|
||||
engines,
|
||||
author: nextPkg.author,
|
||||
license: nextPkg.license,
|
||||
publisher: nextPkg.publisher,
|
||||
repository: nextPkg.repository,
|
||||
homepage: nextPkg.homepage,
|
||||
categories: nextPkg.categories,
|
||||
keywords: nextPkg.keywords,
|
||||
activationEvents: unionPrimitive(
|
||||
nextPkg.activationEvents,
|
||||
legacyPkg.activationEvents,
|
||||
),
|
||||
main: "./extension.js",
|
||||
contributes: {
|
||||
viewsContainers: nc.viewsContainers,
|
||||
views: nc.views,
|
||||
commands: unionBy(
|
||||
[...(nc.commands ?? []), ...(lc.commands ?? [])],
|
||||
(c) => c.command,
|
||||
),
|
||||
keybindings: unionGated(
|
||||
nc.keybindings,
|
||||
lc.keybindings,
|
||||
nextGate,
|
||||
legacyGate,
|
||||
),
|
||||
menus,
|
||||
icons: unionIcons(nc.icons, lc.icons),
|
||||
configuration: injectLoaderConfiguration(nc.configuration, prefix),
|
||||
walkthroughs: nc.walkthroughs,
|
||||
},
|
||||
scripts: {},
|
||||
};
|
||||
|
||||
assertSuperset(manifest, nextPkg, "next", nextGate);
|
||||
assertSuperset(manifest, legacyPkg, "legacy", legacyGate);
|
||||
return manifest;
|
||||
}
|
||||
|
||||
/**
|
||||
* The loader's own user-visible escape hatch, keyed by the manifest identity.
|
||||
* Neither bundle knows about it; only the loader reads it (src/cohort.ts
|
||||
* settingSection/SETTING_BUNDLE_OVERRIDE — keep the key and values in sync).
|
||||
* Injected after the configuration-equality invariant so it can't mask real
|
||||
* drift between the bundles.
|
||||
*/
|
||||
function loaderSettings(prefix) {
|
||||
return {
|
||||
[`${prefix}.rollout.bundleOverride`]: {
|
||||
type: "string",
|
||||
enum: ["auto", "next", "legacy"],
|
||||
enumDescriptions: [
|
||||
"Follow the remote rollout assignment.",
|
||||
"Force the new (SDK-based) extension.",
|
||||
"Force the previous (legacy) extension.",
|
||||
],
|
||||
default: "auto",
|
||||
scope: "application",
|
||||
markdownDescription:
|
||||
"Manual override for Cline's staged extension rollout. `next` forces the new (SDK-based) extension, `legacy` forces the previous one, `auto` follows the remote rollout assignment. Takes effect on window reload.",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function injectLoaderConfiguration(configuration, prefix) {
|
||||
const properties = { ...(configuration?.properties ?? {}) };
|
||||
for (const [key, schema] of Object.entries(loaderSettings(prefix))) {
|
||||
if (properties[key]) {
|
||||
throw new Error(
|
||||
`bundle manifests must not declare loader-owned setting ${key}`,
|
||||
);
|
||||
}
|
||||
properties[key] = schema;
|
||||
}
|
||||
return { title: "Cline", ...(configuration ?? {}), properties };
|
||||
}
|
||||
|
||||
/**
|
||||
* Walkthroughs can't be gated per cohort, and their markdown at the VSIX root
|
||||
* always comes from the next checkout — so requiring byte-identical manifests
|
||||
* here would brick releases over copy tweaks while protecting nothing. Only
|
||||
* STRUCTURE must match (walkthrough/step ids, media paths, completion events —
|
||||
* the parts code and the manifest reference); when titles/descriptions
|
||||
* diverge, next's copy ships for everyone and the build says so.
|
||||
*/
|
||||
function assertWalkthroughsCompatible(next = [], legacy = []) {
|
||||
const structure = (walkthroughs) =>
|
||||
walkthroughs.map((walkthrough) => ({
|
||||
id: walkthrough.id,
|
||||
steps: (walkthrough.steps ?? []).map((step) => ({
|
||||
id: step.id,
|
||||
media: step.media,
|
||||
completionEvents: step.completionEvents,
|
||||
when: step.when,
|
||||
})),
|
||||
}));
|
||||
try {
|
||||
deepStrictEqual(structure(next), structure(legacy));
|
||||
} catch {
|
||||
throw new Error(
|
||||
"contributes.walkthroughs diverged structurally (ids/media/completionEvents) — reconcile the branches",
|
||||
);
|
||||
}
|
||||
try {
|
||||
deepStrictEqual(next, legacy);
|
||||
} catch {
|
||||
console.warn(
|
||||
"warning: walkthrough titles/descriptions differ between bundles; shipping next's copy for both cohorts",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Engines can safely diverge in ONE direction: the union requires whichever
|
||||
* bundle needs the NEWER host, which necessarily satisfies the other bundle's
|
||||
* older requirement too. (main routinely bumps the VS Code engine ahead of the
|
||||
* legacy branch — an equality assertion here would brick every combined build
|
||||
* over that.) Non-caret/complex ranges we can't compare fail hard rather than
|
||||
* guessing.
|
||||
*/
|
||||
function unionEngines(nextEngines = {}, legacyEngines = {}) {
|
||||
const union = {};
|
||||
for (const key of new Set([
|
||||
...Object.keys(nextEngines),
|
||||
...Object.keys(legacyEngines),
|
||||
])) {
|
||||
const a = nextEngines[key];
|
||||
const b = legacyEngines[key];
|
||||
if (a === undefined || b === undefined || a === b) {
|
||||
union[key] = a ?? b;
|
||||
continue;
|
||||
}
|
||||
const minimum = (range) => {
|
||||
const match = /^\^(\d+(?:\.\d+)*)$/.exec(range);
|
||||
return match?.[1];
|
||||
};
|
||||
const [minA, minB] = [minimum(a), minimum(b)];
|
||||
if (!minA || !minB) {
|
||||
throw new Error(
|
||||
`engines.${key} diverged with uncomparable ranges: ${a} vs ${b}`,
|
||||
);
|
||||
}
|
||||
union[key] = compareDotted(minA, minB) >= 0 ? a : b;
|
||||
console.warn(
|
||||
`warning: engines.${key} differs between bundles (next ${a}, legacy ${b}); union requires ${union[key]}`,
|
||||
);
|
||||
}
|
||||
return union;
|
||||
}
|
||||
|
||||
/** Compare dotted numeric versions. */
|
||||
function compareDotted(a, b) {
|
||||
const pa = a.split(".").map((part) => Number.parseInt(part, 10) || 0);
|
||||
const pb = b.split(".").map((part) => Number.parseInt(part, 10) || 0);
|
||||
for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
|
||||
const diff = (pa[i] ?? 0) - (pb[i] ?? 0);
|
||||
if (diff !== 0) {
|
||||
return diff;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
function unionPrimitive(a = [], b = []) {
|
||||
return [...new Set([...a, ...b])];
|
||||
}
|
||||
|
||||
/** Union keeping first occurrence per key (next wins on shared ids). */
|
||||
function unionBy(items, keyFn) {
|
||||
const seen = new Map();
|
||||
for (const item of items) {
|
||||
const key = keyFn(item);
|
||||
if (!seen.has(key)) {
|
||||
seen.set(key, item);
|
||||
}
|
||||
}
|
||||
return [...seen.values()];
|
||||
}
|
||||
|
||||
function sortKeysDeep(value) {
|
||||
if (Array.isArray(value)) {
|
||||
return value.map(sortKeysDeep);
|
||||
}
|
||||
if (value && typeof value === "object") {
|
||||
return Object.fromEntries(
|
||||
Object.keys(value)
|
||||
.sort()
|
||||
.map((key) => [key, sortKeysDeep(value[key])]),
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function stableJson(value) {
|
||||
return JSON.stringify(sortKeysDeep(value));
|
||||
}
|
||||
|
||||
function gateWhen(entry, gate) {
|
||||
return { ...entry, when: entry.when ? `(${entry.when}) && ${gate}` : gate };
|
||||
}
|
||||
|
||||
/**
|
||||
* Union two entry lists (menu entries or keybindings): entries declared by
|
||||
* both bundles pass through untouched; entries declared by only one get their
|
||||
* `when` AND-ed with that bundle's cohort gate.
|
||||
*/
|
||||
function unionGated(
|
||||
nextEntries = [],
|
||||
legacyEntries = [],
|
||||
nextGate,
|
||||
legacyGate,
|
||||
) {
|
||||
const nextSet = new Set(nextEntries.map(stableJson));
|
||||
const legacySet = new Set(legacyEntries.map(stableJson));
|
||||
const entries = [];
|
||||
for (const entry of nextEntries) {
|
||||
entries.push(
|
||||
legacySet.has(stableJson(entry)) ? entry : gateWhen(entry, nextGate),
|
||||
);
|
||||
}
|
||||
for (const entry of legacyEntries) {
|
||||
if (!nextSet.has(stableJson(entry))) {
|
||||
entries.push(gateWhen(entry, legacyGate));
|
||||
}
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
function unionMenus(a = {}, b = {}, nextGate, legacyGate) {
|
||||
const menus = {};
|
||||
for (const location of new Set([...Object.keys(a), ...Object.keys(b)])) {
|
||||
menus[location] = unionGated(
|
||||
a[location],
|
||||
b[location],
|
||||
nextGate,
|
||||
legacyGate,
|
||||
);
|
||||
}
|
||||
return menus;
|
||||
}
|
||||
|
||||
/**
|
||||
* A command declared by only one bundle would surface in the other cohort's
|
||||
* command palette with no registered handler ("command not found" on run).
|
||||
* Hide it there unless that bundle's own manifest already constrains it.
|
||||
*/
|
||||
function hideExclusiveCommandsFromPalette(
|
||||
menus,
|
||||
nextCommands = [],
|
||||
legacyCommands = [],
|
||||
nextGate,
|
||||
legacyGate,
|
||||
) {
|
||||
const nextIds = new Set(nextCommands.map((c) => c.command));
|
||||
const legacyIds = new Set(legacyCommands.map((c) => c.command));
|
||||
const palette = menus.commandPalette ?? (menus.commandPalette = []);
|
||||
const alreadyListed = new Set(palette.map((e) => e.command));
|
||||
for (const id of nextIds) {
|
||||
if (!legacyIds.has(id) && !alreadyListed.has(id)) {
|
||||
palette.push({ command: id, when: nextGate });
|
||||
}
|
||||
}
|
||||
for (const id of legacyIds) {
|
||||
if (!nextIds.has(id) && !alreadyListed.has(id)) {
|
||||
palette.push({ command: id, when: legacyGate });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function unionIcons(a = {}, b = {}) {
|
||||
const icons = { ...b, ...a };
|
||||
for (const id of Object.keys(icons)) {
|
||||
if (a[id] && b[id] && JSON.stringify(a[id]) !== JSON.stringify(b[id])) {
|
||||
throw new Error(
|
||||
`contributes.icons["${id}"] diverged between bundles — icon fonts resolve from the VSIX root`,
|
||||
);
|
||||
}
|
||||
}
|
||||
return icons;
|
||||
}
|
||||
|
||||
/**
|
||||
* Every command/keybinding/menu entry/activation event a bundle declares must
|
||||
* survive the union, either verbatim or with its `when` AND-ed with that
|
||||
* bundle's cohort gate.
|
||||
*/
|
||||
function assertSuperset(manifest, sourcePkg, label, gate) {
|
||||
const missing = [];
|
||||
const commandIds = new Set(
|
||||
manifest.contributes.commands.map((c) => c.command),
|
||||
);
|
||||
for (const cmd of sourcePkg.contributes?.commands ?? []) {
|
||||
if (!commandIds.has(cmd.command)) {
|
||||
missing.push(`command ${cmd.command}`);
|
||||
}
|
||||
}
|
||||
for (const event of sourcePkg.activationEvents ?? []) {
|
||||
if (!manifest.activationEvents.includes(event)) {
|
||||
missing.push(`activationEvent ${event}`);
|
||||
}
|
||||
}
|
||||
const presentOrGated = (unionEntries, entry) => {
|
||||
const set = new Set((unionEntries ?? []).map(stableJson));
|
||||
return (
|
||||
set.has(stableJson(entry)) || set.has(stableJson(gateWhen(entry, gate)))
|
||||
);
|
||||
};
|
||||
for (const kb of sourcePkg.contributes?.keybindings ?? []) {
|
||||
if (!presentOrGated(manifest.contributes.keybindings, kb)) {
|
||||
missing.push(`keybinding ${kb.command}`);
|
||||
}
|
||||
}
|
||||
for (const [location, entries] of Object.entries(
|
||||
sourcePkg.contributes?.menus ?? {},
|
||||
)) {
|
||||
for (const entry of entries) {
|
||||
if (!presentOrGated(manifest.contributes.menus[location], entry)) {
|
||||
missing.push(
|
||||
`menu ${location}: ${entry.command ?? JSON.stringify(entry)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (missing.length > 0) {
|
||||
throw new Error(
|
||||
`union manifest is missing ${label} contributions:\n ${missing.join("\n ")}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
const args = {};
|
||||
for (let i = 2; i < argv.length; i += 2) {
|
||||
args[argv[i].replace(/^--/, "")] = argv[i + 1];
|
||||
}
|
||||
return args;
|
||||
}
|
||||
|
||||
if (import.meta.url === `file://${process.argv[1]}`) {
|
||||
const { next, legacy, version, out } = parseArgs(process.argv);
|
||||
if (!next || !legacy || !version) {
|
||||
console.error(
|
||||
"usage: gen-manifest.mjs --next <pkg.json> --legacy <pkg.json> --version <x.y.z> [--out <file>]",
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
const manifest = generateManifest(
|
||||
JSON.parse(readFileSync(next, "utf8")),
|
||||
JSON.parse(readFileSync(legacy, "utf8")),
|
||||
version,
|
||||
);
|
||||
const json = `${JSON.stringify(manifest, null, "\t")}\n`;
|
||||
if (out) {
|
||||
writeFileSync(out, json);
|
||||
console.log(`wrote ${out}`);
|
||||
} else {
|
||||
process.stdout.write(json);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,382 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import { generateManifest } from "./gen-manifest.mjs";
|
||||
|
||||
const shared = {
|
||||
name: "claude-dev",
|
||||
publisher: "saoudrizwan",
|
||||
main: "./dist/extension.js",
|
||||
engines: { vscode: "^1.84.0" },
|
||||
displayName: "Cline",
|
||||
};
|
||||
|
||||
function pkg(overrides) {
|
||||
return {
|
||||
...shared,
|
||||
activationEvents: ["onStartupFinished"],
|
||||
contributes: {
|
||||
viewsContainers: {
|
||||
activitybar: [{ id: "c", title: "Cline", icon: "assets/icon.svg" }],
|
||||
},
|
||||
views: { c: [{ type: "webview", id: "claude-dev.SidebarProvider" }] },
|
||||
commands: [],
|
||||
keybindings: [],
|
||||
menus: {},
|
||||
icons: {},
|
||||
...overrides.contributes,
|
||||
},
|
||||
...Object.fromEntries(
|
||||
Object.entries(overrides).filter(([k]) => k !== "contributes"),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
describe("generateManifest", () => {
|
||||
it("unions commands, menus, keybindings and activation events", () => {
|
||||
const next = pkg({
|
||||
contributes: {
|
||||
commands: [
|
||||
{ command: "cline.a", title: "A" },
|
||||
{ command: "cline.shared", title: "S" },
|
||||
],
|
||||
menus: { "view/title": [{ command: "cline.a", when: "x" }] },
|
||||
},
|
||||
});
|
||||
const legacy = pkg({
|
||||
activationEvents: ["onStartupFinished", "workspaceContains:evals.env"],
|
||||
contributes: {
|
||||
commands: [
|
||||
{ command: "cline.b", title: "B" },
|
||||
{ command: "cline.shared", title: "S" },
|
||||
],
|
||||
menus: {
|
||||
"view/title": [{ command: "cline.b", when: "y" }],
|
||||
"comments/commentThread/title": [{ command: "cline.b" }],
|
||||
},
|
||||
keybindings: [{ command: "cline.b", key: "ctrl+k" }],
|
||||
},
|
||||
});
|
||||
const manifest = generateManifest(next, legacy, "4.1.0");
|
||||
expect(manifest.version).toBe("4.1.0");
|
||||
expect(manifest.main).toBe("./extension.js");
|
||||
expect(manifest.contributes.commands.map((c) => c.command).sort()).toEqual([
|
||||
"cline.a",
|
||||
"cline.b",
|
||||
"cline.shared",
|
||||
]);
|
||||
expect(manifest.contributes.menus["view/title"]).toHaveLength(2);
|
||||
expect(
|
||||
manifest.contributes.menus["comments/commentThread/title"],
|
||||
).toHaveLength(1);
|
||||
expect(manifest.contributes.keybindings).toHaveLength(1);
|
||||
expect(manifest.activationEvents).toContain("workspaceContains:evals.env");
|
||||
});
|
||||
|
||||
it("gates cohort-exclusive menu entries and keybindings on the context key", () => {
|
||||
const next = pkg({
|
||||
contributes: {
|
||||
commands: [
|
||||
{ command: "cline.a", title: "A" },
|
||||
{ command: "cline.shared", title: "S" },
|
||||
],
|
||||
menus: {
|
||||
"view/title": [
|
||||
{ command: "cline.a", when: "x" },
|
||||
{ command: "cline.shared", when: "v" },
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
const legacy = pkg({
|
||||
contributes: {
|
||||
commands: [
|
||||
{ command: "cline.b", title: "B" },
|
||||
{ command: "cline.shared", title: "S" },
|
||||
],
|
||||
menus: {
|
||||
"view/title": [
|
||||
{ command: "cline.b", when: "y" },
|
||||
{ command: "cline.shared", when: "v" },
|
||||
],
|
||||
},
|
||||
keybindings: [{ command: "cline.b", key: "ctrl+k", when: "focus" }],
|
||||
},
|
||||
});
|
||||
const manifest = generateManifest(next, legacy, "4.1.0");
|
||||
const viewTitle = manifest.contributes.menus["view/title"];
|
||||
expect(viewTitle.find((e) => e.command === "cline.a").when).toBe(
|
||||
"(x) && cline.sdkBundle",
|
||||
);
|
||||
expect(viewTitle.find((e) => e.command === "cline.b").when).toBe(
|
||||
"(y) && !cline.sdkBundle",
|
||||
);
|
||||
expect(viewTitle.find((e) => e.command === "cline.shared").when).toBe("v");
|
||||
expect(manifest.contributes.keybindings[0].when).toBe(
|
||||
"(focus) && !cline.sdkBundle",
|
||||
);
|
||||
});
|
||||
|
||||
it("hides cohort-exclusive commands from the other cohort's palette", () => {
|
||||
const next = pkg({
|
||||
contributes: {
|
||||
commands: [
|
||||
{ command: "cline.nextOnly", title: "N" },
|
||||
{ command: "cline.shared", title: "S" },
|
||||
],
|
||||
},
|
||||
});
|
||||
const legacy = pkg({
|
||||
contributes: {
|
||||
commands: [
|
||||
{ command: "cline.legacyOnly", title: "L" },
|
||||
{ command: "cline.shared", title: "S" },
|
||||
],
|
||||
},
|
||||
});
|
||||
const palette = generateManifest(next, legacy, "4.1.0").contributes.menus
|
||||
.commandPalette;
|
||||
expect(palette).toContainEqual({
|
||||
command: "cline.nextOnly",
|
||||
when: "cline.sdkBundle",
|
||||
});
|
||||
expect(palette).toContainEqual({
|
||||
command: "cline.legacyOnly",
|
||||
when: "!cline.sdkBundle",
|
||||
});
|
||||
expect(palette.find((e) => e.command === "cline.shared")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("leaves commands alone when a bundle already declares a palette entry for them", () => {
|
||||
const next = pkg({
|
||||
contributes: { commands: [{ command: "cline.shared", title: "S" }] },
|
||||
});
|
||||
const legacy = pkg({
|
||||
contributes: {
|
||||
commands: [
|
||||
{ command: "cline.hidden", title: "H" },
|
||||
{ command: "cline.shared", title: "S" },
|
||||
],
|
||||
menus: { commandPalette: [{ command: "cline.hidden", when: "false" }] },
|
||||
},
|
||||
});
|
||||
const palette = generateManifest(next, legacy, "4.1.0").contributes.menus
|
||||
.commandPalette;
|
||||
expect(palette.filter((e) => e.command === "cline.hidden")).toEqual([
|
||||
{ command: "cline.hidden", when: "(false) && !cline.sdkBundle" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("dedupes structurally identical menu entries", () => {
|
||||
const entry = { command: "cline.a", when: "view == cline" };
|
||||
const next = pkg({
|
||||
contributes: {
|
||||
commands: [{ command: "cline.a", title: "A" }],
|
||||
menus: { "view/title": [entry] },
|
||||
},
|
||||
});
|
||||
const legacy = pkg({
|
||||
contributes: {
|
||||
commands: [{ command: "cline.a", title: "A" }],
|
||||
menus: { "view/title": [{ ...entry }] },
|
||||
},
|
||||
});
|
||||
expect(
|
||||
generateManifest(next, legacy, "1.0.0").contributes.menus["view/title"],
|
||||
).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("rejects diverged views/viewsContainers", () => {
|
||||
const next = pkg({});
|
||||
const legacy = pkg({
|
||||
contributes: { views: { c: [{ type: "webview", id: "other" }] } },
|
||||
});
|
||||
expect(() => generateManifest(next, legacy, "1.0.0")).toThrow(/views/);
|
||||
});
|
||||
|
||||
it("rejects structurally diverged walkthroughs", () => {
|
||||
const walkthrough = (stepId, media) => ({
|
||||
contributes: {
|
||||
walkthroughs: [
|
||||
{
|
||||
id: "ClineWalkthrough",
|
||||
title: "Meet Cline",
|
||||
steps: [{ id: stepId, title: "Start here", media }],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
expect(() =>
|
||||
generateManifest(
|
||||
pkg(walkthrough("welcome", { markdown: "walkthrough/step1.md" })),
|
||||
pkg(walkthrough("hello", { markdown: "walkthrough/step1.md" })),
|
||||
"1.0.0",
|
||||
),
|
||||
).toThrow(/walkthroughs diverged structurally/);
|
||||
expect(() =>
|
||||
generateManifest(
|
||||
pkg(walkthrough("welcome", { markdown: "walkthrough/step1.md" })),
|
||||
pkg(walkthrough("welcome", { markdown: "walkthrough/other.md" })),
|
||||
"1.0.0",
|
||||
),
|
||||
).toThrow(/walkthroughs diverged structurally/);
|
||||
});
|
||||
|
||||
it("tolerates copy-only walkthrough divergence, shipping next's text", () => {
|
||||
const walkthrough = (description) => ({
|
||||
contributes: {
|
||||
walkthroughs: [
|
||||
{
|
||||
id: "ClineWalkthrough",
|
||||
title: "Meet Cline",
|
||||
steps: [
|
||||
{
|
||||
id: "welcome",
|
||||
title: "Start here",
|
||||
description,
|
||||
media: { markdown: "walkthrough/step1.md" },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
const manifest = generateManifest(
|
||||
pkg(walkthrough("Connect via MCP.")),
|
||||
pkg(walkthrough("Discover the MCP Marketplace.")),
|
||||
"1.0.0",
|
||||
);
|
||||
expect(manifest.contributes.walkthroughs[0].steps[0].description).toBe(
|
||||
"Connect via MCP.",
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects diverged configuration", () => {
|
||||
const next = pkg({
|
||||
contributes: {
|
||||
configuration: {
|
||||
title: "Cline",
|
||||
properties: {
|
||||
"cline.enabled": { type: "boolean", default: false },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
const legacy = pkg({
|
||||
contributes: {
|
||||
configuration: {
|
||||
title: "Cline",
|
||||
properties: {
|
||||
"cline.enabled": { type: "boolean", default: 0 },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(() => generateManifest(next, legacy, "1.0.0")).toThrow(
|
||||
/contributes\.configuration diverged/,
|
||||
);
|
||||
});
|
||||
|
||||
it("injects the loader-owned bundleOverride setting into the union", () => {
|
||||
const manifest = generateManifest(pkg({}), pkg({}), "4.1.0");
|
||||
const prop =
|
||||
manifest.contributes.configuration.properties[
|
||||
"cline.rollout.bundleOverride"
|
||||
];
|
||||
expect(prop).toBeDefined();
|
||||
expect(prop.enum).toEqual(["auto", "next", "legacy"]);
|
||||
expect(prop.default).toBe("auto");
|
||||
expect(prop.scope).toBe("application");
|
||||
});
|
||||
|
||||
it("rejects bundles that declare the loader-owned setting themselves", () => {
|
||||
const withClash = {
|
||||
contributes: {
|
||||
configuration: {
|
||||
title: "Cline",
|
||||
properties: { "cline.rollout.bundleOverride": { type: "string" } },
|
||||
},
|
||||
},
|
||||
};
|
||||
expect(() =>
|
||||
generateManifest(pkg(withClash), pkg(withClash), "4.1.0"),
|
||||
).toThrow(/loader-owned setting/);
|
||||
});
|
||||
|
||||
it("derives gates and the injected setting from the nightly identity", () => {
|
||||
const nightly = (overrides) => ({
|
||||
...pkg(overrides),
|
||||
name: "cline-nightly",
|
||||
displayName: "Cline (Nightly)",
|
||||
});
|
||||
const next = nightly({
|
||||
contributes: {
|
||||
commands: [{ command: "cline-nightly.nextOnly", title: "N" }],
|
||||
menus: {
|
||||
"view/title": [{ command: "cline-nightly.nextOnly", when: "x" }],
|
||||
},
|
||||
},
|
||||
});
|
||||
const legacy = nightly({
|
||||
contributes: {
|
||||
commands: [{ command: "cline-nightly.legacyOnly", title: "L" }],
|
||||
keybindings: [{ command: "cline-nightly.legacyOnly", key: "ctrl+k" }],
|
||||
},
|
||||
});
|
||||
const manifest = generateManifest(next, legacy, "4.0.1752600000");
|
||||
expect(manifest.name).toBe("cline-nightly");
|
||||
expect(manifest.contributes.menus["view/title"][0].when).toBe(
|
||||
"(x) && cline-nightly.sdkBundle",
|
||||
);
|
||||
expect(manifest.contributes.keybindings[0].when).toBe(
|
||||
"!cline-nightly.sdkBundle",
|
||||
);
|
||||
expect(manifest.contributes.menus.commandPalette).toContainEqual({
|
||||
command: "cline-nightly.legacyOnly",
|
||||
when: "!cline-nightly.sdkBundle",
|
||||
});
|
||||
const properties = manifest.contributes.configuration.properties;
|
||||
expect(properties["cline-nightly.rollout.bundleOverride"]).toBeDefined();
|
||||
expect(properties["cline.rollout.bundleOverride"]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("unions diverged engines to the newer requirement (either direction)", () => {
|
||||
const olderLegacy = { ...pkg({}), engines: { vscode: "^1.74.0" } };
|
||||
expect(generateManifest(pkg({}), olderLegacy, "1.0.0").engines).toEqual({
|
||||
vscode: "^1.84.0",
|
||||
});
|
||||
const newerLegacy = { ...pkg({}), engines: { vscode: "^1.101.0" } };
|
||||
expect(generateManifest(pkg({}), newerLegacy, "1.0.0").engines).toEqual({
|
||||
vscode: "^1.101.0",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects diverged engines it cannot compare", () => {
|
||||
const legacy = { ...pkg({}), engines: { vscode: ">=1.84.0 <2.0.0" } };
|
||||
expect(() => generateManifest(pkg({}), legacy, "1.0.0")).toThrow(
|
||||
/uncomparable/,
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects conflicting icon definitions", () => {
|
||||
const next = pkg({
|
||||
contributes: {
|
||||
icons: {
|
||||
"cline-logo": {
|
||||
description: "d",
|
||||
default: { fontPath: "a.woff", fontCharacter: "\\E900" },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
const legacy = pkg({
|
||||
contributes: {
|
||||
icons: {
|
||||
"cline-logo": {
|
||||
description: "d",
|
||||
default: { fontPath: "b.woff", fontCharacter: "\\E900" },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(() => generateManifest(next, legacy, "1.0.0")).toThrow(/icons/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* Rewrite an apps/vscode package.json to the nightly identity, in place.
|
||||
*
|
||||
* Reproduces updatePackageJson() from apps/vscode/scripts/publish-nightly.mjs
|
||||
* (the same script exists on BOTH main and legacy-extension — those copies are
|
||||
* the source of truth for the mutation; if they change, change this too):
|
||||
* - textual rewrites: "claude-dev" -> "cline-nightly" everywhere, and every
|
||||
* `"cline.` ID prefix -> `"cline-nightly.` (commands, settings, view IDs,
|
||||
* when-clauses that START with the key — mid-string references like
|
||||
* `config.cline.x` are NOT rewritten, same as the standalone nightly)
|
||||
* - name / displayName / activity bar title / version
|
||||
*
|
||||
* Differences from publish-nightly.mjs, on purpose:
|
||||
* - the version is an explicit ARGUMENT, not computed here: the combined
|
||||
* VSIX applies ONE version to the next bundle, the legacy bundle, and the
|
||||
* union manifest, so gen-manifest's identity-equality assertions hold.
|
||||
* - no backup/restore, README swapping, or workspace-self-link reconciling:
|
||||
* this runs against a disposable CI checkout, BEFORE the bundle build and
|
||||
* never followed by vsce in that checkout (vsce only runs in the stitched
|
||||
* staging dir with --no-dependencies).
|
||||
*
|
||||
* Run it AFTER dependency install (the workspace self-link resolution keys off
|
||||
* the original package name) and BEFORE the bundle's package build.
|
||||
*
|
||||
* Usage: node nightlify.mjs --dir <apps/vscode checkout> --version <x.y.ts>
|
||||
*/
|
||||
import { readFileSync, writeFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
export const NIGHTLY_NAME = "cline-nightly";
|
||||
export const NIGHTLY_DISPLAY_NAME = "Cline (Nightly)";
|
||||
|
||||
export function nightlifyPackageJson(rawContent, version) {
|
||||
if (!version) {
|
||||
throw new Error("version is required");
|
||||
}
|
||||
const content = rawContent
|
||||
.replaceAll("claude-dev", NIGHTLY_NAME)
|
||||
.replaceAll('"cline.', `"${NIGHTLY_NAME}.`);
|
||||
const pkg = JSON.parse(content);
|
||||
|
||||
pkg.name = NIGHTLY_NAME;
|
||||
pkg.displayName = NIGHTLY_DISPLAY_NAME;
|
||||
pkg.version = version;
|
||||
// publish-nightly.mjs assigns `.title` on the activitybar value directly,
|
||||
// which is a silent no-op on the real manifest (activitybar is an ARRAY —
|
||||
// JSON.stringify drops non-index properties). Retitle the actual entries.
|
||||
const activitybar = pkg.contributes?.viewsContainers?.activitybar;
|
||||
for (const container of Array.isArray(activitybar) ? activitybar : []) {
|
||||
container.title = NIGHTLY_DISPLAY_NAME;
|
||||
}
|
||||
|
||||
return `${JSON.stringify(pkg, null, "\t")}\n`;
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
const args = {};
|
||||
for (let i = 2; i < argv.length; i += 2) {
|
||||
args[argv[i].replace(/^--/, "")] = argv[i + 1];
|
||||
}
|
||||
return args;
|
||||
}
|
||||
|
||||
if (
|
||||
process.argv[1] &&
|
||||
import.meta.url === `file://${path.resolve(process.argv[1])}`
|
||||
) {
|
||||
const { dir, version } = parseArgs(process.argv);
|
||||
if (!dir || !version) {
|
||||
console.error(
|
||||
"usage: node nightlify.mjs --dir <apps/vscode checkout> --version <x.y.ts>",
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
const packageJsonPath = path.join(dir, "package.json");
|
||||
const before = readFileSync(packageJsonPath, "utf8");
|
||||
const beforeName = JSON.parse(before).name;
|
||||
writeFileSync(packageJsonPath, nightlifyPackageJson(before, version));
|
||||
console.log(
|
||||
`nightlified ${packageJsonPath}: ${beforeName} -> ${NIGHTLY_NAME}@${version}`,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import { nightlifyPackageJson } from "./nightlify.mjs";
|
||||
|
||||
const fixture = {
|
||||
name: "claude-dev",
|
||||
displayName: "Cline",
|
||||
publisher: "saoudrizwan",
|
||||
version: "4.0.0",
|
||||
main: "./dist/extension.js",
|
||||
contributes: {
|
||||
viewsContainers: {
|
||||
activitybar: [
|
||||
{
|
||||
id: "claude-dev-ActivityBar",
|
||||
title: "Cline",
|
||||
icon: "assets/icon.svg",
|
||||
},
|
||||
],
|
||||
},
|
||||
views: {
|
||||
"claude-dev-ActivityBar": [
|
||||
{ type: "webview", id: "claude-dev.SidebarProvider" },
|
||||
],
|
||||
},
|
||||
commands: [{ command: "cline.plusButtonClicked", title: "New Task" }],
|
||||
keybindings: [{ command: "cline.addToChat", key: "ctrl+'" }],
|
||||
menus: {
|
||||
"view/title": [
|
||||
{
|
||||
command: "cline.plusButtonClicked",
|
||||
when: "view == claude-dev.SidebarProvider",
|
||||
},
|
||||
// Mid-string references are NOT rewritten — a known limitation
|
||||
// shared with the standalone nightly's publish-nightly.mjs.
|
||||
{ command: "cline.addToChat", when: "config.cline.enableExtras" },
|
||||
],
|
||||
},
|
||||
configuration: {
|
||||
title: "Cline",
|
||||
properties: { "cline.enableExtras": { type: "boolean" } },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
describe("nightlifyPackageJson", () => {
|
||||
const pkg = JSON.parse(
|
||||
nightlifyPackageJson(JSON.stringify(fixture, null, "\t"), "4.0.1752600000"),
|
||||
);
|
||||
|
||||
it("sets the nightly identity and the supplied version", () => {
|
||||
expect(pkg.name).toBe("cline-nightly");
|
||||
expect(pkg.displayName).toBe("Cline (Nightly)");
|
||||
expect(pkg.version).toBe("4.0.1752600000");
|
||||
expect(pkg.publisher).toBe("saoudrizwan");
|
||||
});
|
||||
|
||||
it("rewrites claude-dev IDs and the cline.* namespace", () => {
|
||||
expect(pkg.contributes.viewsContainers.activitybar[0].id).toBe(
|
||||
"cline-nightly-ActivityBar",
|
||||
);
|
||||
expect(pkg.contributes.viewsContainers.activitybar[0].title).toBe(
|
||||
"Cline (Nightly)",
|
||||
);
|
||||
expect(Object.keys(pkg.contributes.views)).toEqual([
|
||||
"cline-nightly-ActivityBar",
|
||||
]);
|
||||
expect(pkg.contributes.views["cline-nightly-ActivityBar"][0].id).toBe(
|
||||
"cline-nightly.SidebarProvider",
|
||||
);
|
||||
expect(pkg.contributes.commands[0].command).toBe(
|
||||
"cline-nightly.plusButtonClicked",
|
||||
);
|
||||
expect(pkg.contributes.keybindings[0].command).toBe(
|
||||
"cline-nightly.addToChat",
|
||||
);
|
||||
expect(Object.keys(pkg.contributes.configuration.properties)).toEqual([
|
||||
"cline-nightly.enableExtras",
|
||||
]);
|
||||
});
|
||||
|
||||
it("rewrites when-clauses that start with a rewritten ID, but not mid-string references", () => {
|
||||
const [gated, midString] = pkg.contributes.menus["view/title"];
|
||||
expect(gated.when).toBe("view == cline-nightly.SidebarProvider");
|
||||
// Documented limitation: `config.cline.` does not match the `"cline.`
|
||||
// pattern, so it survives unrewritten (matches publish-nightly.mjs).
|
||||
expect(midString.when).toBe("config.cline.enableExtras");
|
||||
});
|
||||
|
||||
it("requires a version", () => {
|
||||
expect(() => nightlifyPackageJson("{}", undefined)).toThrow(/version/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* Stamp the combined VSIX's version into a bundle checkout's package.json,
|
||||
* in place, BEFORE that bundle builds.
|
||||
*
|
||||
* Why: the union manifest's version (what the Marketplace and auto-update
|
||||
* see) is supplied at stitch time, but each bundle's runtime reads its OWN
|
||||
* package.json — the About tab and every telemetry event's extension_version
|
||||
* come from there. Without this stamp the stable combined VSIX would report
|
||||
* three different versions (union input, main's base version, legacy's base
|
||||
* version) depending on where you look, which turns user bug reports into
|
||||
* archaeology. The nightly path gets the same alignment via nightlify.mjs
|
||||
* (which also rewrites identity); this script is the identity-preserving
|
||||
* version-only equivalent for the stable channel.
|
||||
*
|
||||
* Usage: node set-version.mjs --dir <apps/vscode checkout> --version <x.y.z>
|
||||
*/
|
||||
import { readFileSync, writeFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
export function setPackageVersion(rawContent, version) {
|
||||
if (!version) {
|
||||
throw new Error("version is required");
|
||||
}
|
||||
const pkg = JSON.parse(rawContent);
|
||||
pkg.version = version;
|
||||
return `${JSON.stringify(pkg, null, "\t")}\n`;
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
const args = {};
|
||||
for (let i = 2; i < argv.length; i += 2) {
|
||||
args[argv[i].replace(/^--/, "")] = argv[i + 1];
|
||||
}
|
||||
return args;
|
||||
}
|
||||
|
||||
if (
|
||||
process.argv[1] &&
|
||||
import.meta.url === `file://${path.resolve(process.argv[1])}`
|
||||
) {
|
||||
const { dir, version } = parseArgs(process.argv);
|
||||
if (!dir || !version) {
|
||||
console.error(
|
||||
"usage: node set-version.mjs --dir <apps/vscode checkout> --version <x.y.z>",
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
const packageJsonPath = path.join(dir, "package.json");
|
||||
const before = JSON.parse(readFileSync(packageJsonPath, "utf8")).version;
|
||||
writeFileSync(
|
||||
packageJsonPath,
|
||||
setPackageVersion(readFileSync(packageJsonPath, "utf8"), version),
|
||||
);
|
||||
console.log(`set ${packageJsonPath} version: ${before} -> ${version}`);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import { setPackageVersion } from "./set-version.mjs";
|
||||
|
||||
const fixture = JSON.stringify(
|
||||
{
|
||||
name: "claude-dev",
|
||||
displayName: "Cline",
|
||||
publisher: "saoudrizwan",
|
||||
version: "4.0.0",
|
||||
contributes: {
|
||||
commands: [{ command: "cline.plusButtonClicked", title: "New Task" }],
|
||||
},
|
||||
},
|
||||
null,
|
||||
"\t",
|
||||
);
|
||||
|
||||
describe("setPackageVersion", () => {
|
||||
it("stamps the version and touches nothing else", () => {
|
||||
const pkg = JSON.parse(setPackageVersion(fixture, "4.1.0"));
|
||||
expect(pkg.version).toBe("4.1.0");
|
||||
expect(pkg.name).toBe("claude-dev");
|
||||
expect(pkg.displayName).toBe("Cline");
|
||||
expect(pkg.publisher).toBe("saoudrizwan");
|
||||
expect(pkg.contributes.commands[0].command).toBe(
|
||||
"cline.plusButtonClicked",
|
||||
);
|
||||
});
|
||||
|
||||
it("requires a version", () => {
|
||||
expect(() => setPackageVersion(fixture, undefined)).toThrow(/version/);
|
||||
expect(() => setPackageVersion(fixture, "")).toThrow(/version/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,671 @@
|
||||
/**
|
||||
* Node-level smoke test for the built loader against a staged VSIX directory.
|
||||
* No real VS Code: `vscode` is stubbed just enough for the loader itself, and
|
||||
* the staging dir's next/legacy bundles are swapped for tiny recorders. Verifies
|
||||
* the loader's end-to-end behavior in a real require() environment:
|
||||
* 1. default (no cached cohort) -> activates legacy
|
||||
* 2. cached cohort "next" -> activates next, scoped context paths
|
||||
* 3. the flag refresh caches a TWO-WAY assignment for the next window
|
||||
* (rollout on promotes, rollout off demotes a cached "next")
|
||||
* 4. CLINE_BUNDLE_OVERRIDE / the cline.rollout.bundleOverride setting
|
||||
* force a bundle in either direction
|
||||
* 5. next activation throws -> disposes partial registrations, falls
|
||||
* back to legacy, pins version, and
|
||||
* skips the cohort refresh
|
||||
* 6. the activated bundle's reportRolloutActivation export receives the
|
||||
* authoritative attempted/actual/fallback record (and its absence is
|
||||
* tolerated); the loader's own loader_decision capture fires exactly
|
||||
* once per window
|
||||
* 7. the nightly identity (manifest name cline-nightly) switches the
|
||||
* setting section + context key namespace and shows the status bar
|
||||
* bundle indicator
|
||||
* 8. both bundles throwing surfaces the failure and captures a
|
||||
* double_failure loader event
|
||||
*
|
||||
* Usage: node smoke-loader.mjs <staging-dir>
|
||||
* Copies the staging dir to a temp sandbox; the input is never modified.
|
||||
*/
|
||||
import assert from "node:assert";
|
||||
import { cpSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import Module from "node:module";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
const staging = process.argv[2];
|
||||
if (!staging) {
|
||||
console.error("usage: node smoke-loader.mjs <staging-dir>");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// ---- vscode API stub (only what the loader touches) -------------------------
|
||||
const executedCommands = [];
|
||||
const statusBarItems = [];
|
||||
function makeVscodeStub(
|
||||
sandbox,
|
||||
settings = {},
|
||||
{ telemetryEnabled = false } = {},
|
||||
) {
|
||||
return {
|
||||
Uri: {
|
||||
file: (fsPath) => ({ fsPath, path: fsPath, scheme: "file" }),
|
||||
joinPath: (base, ...segments) => {
|
||||
const fsPath = path.join(base.fsPath, ...segments);
|
||||
return { fsPath, path: fsPath, scheme: "file" };
|
||||
},
|
||||
},
|
||||
commands: {
|
||||
executeCommand: async (command, ...args) => {
|
||||
executedCommands.push([command, ...args]);
|
||||
},
|
||||
},
|
||||
workspace: {
|
||||
getConfiguration: (section) => ({
|
||||
get: (key) => settings[`${section}.${key}`],
|
||||
}),
|
||||
},
|
||||
window: {
|
||||
createStatusBarItem: () => {
|
||||
const item = {
|
||||
text: "",
|
||||
tooltip: "",
|
||||
shown: false,
|
||||
show() {
|
||||
this.shown = true;
|
||||
},
|
||||
dispose() {},
|
||||
};
|
||||
statusBarItems.push(item);
|
||||
return item;
|
||||
},
|
||||
},
|
||||
StatusBarAlignment: { Left: 1, Right: 2 },
|
||||
env: { machineId: "smoke-machine", isTelemetryEnabled: telemetryEnabled },
|
||||
version: "0.0.0-smoke",
|
||||
_sandbox: sandbox,
|
||||
};
|
||||
}
|
||||
|
||||
function makeContext(sandbox, globalStateSeed = {}, packageJSON = {}) {
|
||||
const state = new Map(Object.entries(globalStateSeed));
|
||||
return {
|
||||
extensionUri: { fsPath: sandbox, path: sandbox, scheme: "file" },
|
||||
extensionPath: sandbox,
|
||||
extension: { packageJSON: { version: "4.1.0-smoke", ...packageJSON } },
|
||||
subscriptions: [],
|
||||
globalState: {
|
||||
get: (key) => state.get(key),
|
||||
update: async (key, value) => void state.set(key, value),
|
||||
_dump: () => Object.fromEntries(state),
|
||||
},
|
||||
asAbsolutePath: (rel) => path.join(sandbox, rel),
|
||||
};
|
||||
}
|
||||
|
||||
/** PostHog /capture/ POSTs recorded by a scenario's fetch stub, parsed. */
|
||||
function captureCalls(fetchCalls) {
|
||||
return fetchCalls
|
||||
.filter(([url]) => String(url).includes("/capture/"))
|
||||
.map(([, init]) => JSON.parse(init.body));
|
||||
}
|
||||
|
||||
function captureEvents(fetchCalls, event) {
|
||||
return captureCalls(fetchCalls).filter((capture) => capture.event === event);
|
||||
}
|
||||
|
||||
function loaderDecisionCaptures(fetchCalls) {
|
||||
return captureEvents(fetchCalls, "extension.rollout.loader_decision");
|
||||
}
|
||||
|
||||
function featureFlagCalledCaptures(fetchCalls) {
|
||||
return captureEvents(fetchCalls, "$feature_flag_called");
|
||||
}
|
||||
|
||||
function decideCalls(fetchCalls) {
|
||||
return fetchCalls.filter(([url]) => String(url).includes("/decide"));
|
||||
}
|
||||
|
||||
function flagResponse(flags = { rollout: false }) {
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
featureFlags: {
|
||||
"ext-sdk-bundle-rollout": flags.rollout,
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function makeFlagFetch(flags) {
|
||||
const calls = [];
|
||||
const fetch = async (...args) => {
|
||||
calls.push(args);
|
||||
return flagResponse(flags);
|
||||
};
|
||||
return { calls, fetch };
|
||||
}
|
||||
|
||||
function makeDeferredFlagFetch(flags) {
|
||||
const calls = [];
|
||||
let resolveResponse;
|
||||
let markStarted;
|
||||
const started = new Promise((resolve) => {
|
||||
markStarted = resolve;
|
||||
});
|
||||
const fetch = (...args) => {
|
||||
calls.push(args);
|
||||
markStarted();
|
||||
return new Promise((resolve) => {
|
||||
resolveResponse = () => resolve(flagResponse(flags));
|
||||
});
|
||||
};
|
||||
return {
|
||||
calls,
|
||||
fetch,
|
||||
started,
|
||||
resolve: () => resolveResponse?.(),
|
||||
};
|
||||
}
|
||||
|
||||
async function waitFor(predicate, message, timeoutMs = 500) {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (!predicate()) {
|
||||
if (Date.now() >= deadline) {
|
||||
assert.fail(message);
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||
}
|
||||
}
|
||||
|
||||
async function flushAsyncWork() {
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
}
|
||||
|
||||
// ---- sandbox setup -----------------------------------------------------------
|
||||
function makeSandbox({
|
||||
nextThrows = false,
|
||||
legacyThrows = false,
|
||||
omitReportExport = false,
|
||||
} = {}) {
|
||||
const sandbox = mkdtempSync(path.join(tmpdir(), "cline-ab-smoke-"));
|
||||
cpSync(
|
||||
path.join(staging, "extension.js"),
|
||||
path.join(sandbox, "extension.js"),
|
||||
);
|
||||
for (const bundle of ["next", "legacy"]) {
|
||||
mkdirSync(path.join(sandbox, bundle, "dist"), { recursive: true });
|
||||
const throws =
|
||||
(bundle === "next" && nextThrows) ||
|
||||
(bundle === "legacy" && legacyThrows);
|
||||
const throwLine = throws
|
||||
? `await global.__smoke.beforeNextFailure?.();\n\t\tctx.subscriptions.push({ dispose() { global.__smoke.disposed.push("${bundle}") } });\n\t\tthrow new Error("smoke: ${bundle} activation exploded");`
|
||||
: "";
|
||||
// Mirrors the reportRolloutActivation export both real bundles gained in
|
||||
// their rollout-telemetry PRs; recorded so scenarios can assert the
|
||||
// authoritative attempted/actual/fallback record.
|
||||
const reportExport = omitReportExport
|
||||
? ""
|
||||
: `exports.reportRolloutActivation = async (input) => { global.__smoke.reports.push({ reporter: "${bundle}", attemptedBundle: input.attemptedBundle, actualBundle: input.actualBundle, fallback: input.fallback, hasError: input.error !== undefined }); };`;
|
||||
writeFileSync(
|
||||
path.join(sandbox, bundle, "dist", "extension.js"),
|
||||
`exports.activate = async (ctx) => {
|
||||
${throwLine}
|
||||
global.__smoke.activated.push({ bundle: "${bundle}", extensionPath: ctx.extensionPath, asAbs: ctx.asAbsolutePath("webview-ui/build") });
|
||||
return { bundle: "${bundle}" };
|
||||
};
|
||||
exports.deactivate = () => { global.__smoke.deactivated.push("${bundle}"); };
|
||||
${reportExport}`,
|
||||
);
|
||||
}
|
||||
mkdirSync(path.join(sandbox, "data"), { recursive: true });
|
||||
writeFileSync(
|
||||
path.join(sandbox, "data", "globalState.json"),
|
||||
JSON.stringify({ "cline.generatedMachineId": "smoke-machine" }),
|
||||
);
|
||||
return sandbox;
|
||||
}
|
||||
|
||||
async function runScenario(
|
||||
name,
|
||||
{
|
||||
seed = {},
|
||||
env = {},
|
||||
settings = {},
|
||||
nextThrows = false,
|
||||
legacyThrows = false,
|
||||
omitReportExport = false,
|
||||
telemetryEnabled = false,
|
||||
contextPackageJSON = {},
|
||||
expectFailure = false,
|
||||
fetchController = makeFlagFetch(),
|
||||
beforeNextFailure,
|
||||
expectRefresh = true,
|
||||
},
|
||||
checks,
|
||||
afterDeactivateChecks = async () => {},
|
||||
) {
|
||||
const sandbox = makeSandbox({ nextThrows, legacyThrows, omitReportExport });
|
||||
global.__smoke = {
|
||||
activated: [],
|
||||
deactivated: [],
|
||||
disposed: [],
|
||||
reports: [],
|
||||
beforeNextFailure,
|
||||
};
|
||||
executedCommands.length = 0;
|
||||
statusBarItems.length = 0;
|
||||
|
||||
const previousEnv = {};
|
||||
const scenarioEnv = {
|
||||
CLINE_DIR: sandbox,
|
||||
// A dev build leaves this lookup dynamic; production builds inline the
|
||||
// real PostHog key. Either way, the smoke must exercise refreshCohort.
|
||||
TELEMETRY_SERVICE_API_KEY: "smoke-posthog-project-key",
|
||||
...env,
|
||||
};
|
||||
for (const [key, value] of Object.entries(scenarioEnv)) {
|
||||
previousEnv[key] = process.env[key];
|
||||
process.env[key] = value;
|
||||
}
|
||||
const originalFetch = global.fetch;
|
||||
global.fetch = fetchController.fetch;
|
||||
const originalResolve = Module._resolveFilename;
|
||||
Module._resolveFilename = function (request, ...rest) {
|
||||
if (request === "vscode") {
|
||||
return "vscode";
|
||||
}
|
||||
return originalResolve.call(this, request, ...rest);
|
||||
};
|
||||
require.cache.vscode = {
|
||||
id: "vscode",
|
||||
filename: "vscode",
|
||||
loaded: true,
|
||||
exports: makeVscodeStub(sandbox, settings, { telemetryEnabled }),
|
||||
};
|
||||
|
||||
try {
|
||||
const loaderPath = path.join(sandbox, "extension.js");
|
||||
delete require.cache[loaderPath];
|
||||
const loader = require(loaderPath);
|
||||
const context = makeContext(sandbox, seed, contextPackageJSON);
|
||||
let api;
|
||||
let activationError;
|
||||
try {
|
||||
api = await loader.activate(context);
|
||||
} catch (error) {
|
||||
activationError = error;
|
||||
}
|
||||
if (expectFailure) {
|
||||
assert.ok(activationError, `${name} should have failed to activate`);
|
||||
} else if (activationError) {
|
||||
throw activationError;
|
||||
}
|
||||
if (expectRefresh) {
|
||||
await waitFor(
|
||||
() => decideCalls(fetchController.calls).length > 0,
|
||||
`${name} did not refresh its cohort after activation`,
|
||||
);
|
||||
await flushAsyncWork();
|
||||
assert.equal(
|
||||
decideCalls(fetchController.calls).length,
|
||||
1,
|
||||
`${name} should refresh its cohort exactly once`,
|
||||
);
|
||||
}
|
||||
await checks({
|
||||
context,
|
||||
api,
|
||||
activationError,
|
||||
sandbox,
|
||||
fetchCalls: fetchController.calls,
|
||||
});
|
||||
await loader.deactivate();
|
||||
await afterDeactivateChecks({ context, api, sandbox });
|
||||
console.log(`PASS ${name}`);
|
||||
} finally {
|
||||
Module._resolveFilename = originalResolve;
|
||||
delete require.cache.vscode;
|
||||
if (originalFetch === undefined) {
|
||||
delete global.fetch;
|
||||
} else {
|
||||
global.fetch = originalFetch;
|
||||
}
|
||||
for (const [key, value] of Object.entries(previousEnv)) {
|
||||
if (value === undefined) {
|
||||
delete process.env[key];
|
||||
} else {
|
||||
process.env[key] = value;
|
||||
}
|
||||
}
|
||||
rmSync(sandbox, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
const require = Module.createRequire(import.meta.url);
|
||||
|
||||
await runScenario("default cohort -> legacy", {}, async ({ api, sandbox }) => {
|
||||
assert.deepEqual(api, { bundle: "legacy" });
|
||||
assert.equal(
|
||||
global.__smoke.activated[0].extensionPath,
|
||||
path.join(sandbox, "legacy"),
|
||||
);
|
||||
assert.deepEqual(executedCommands[0], [
|
||||
"setContext",
|
||||
"cline.sdkBundle",
|
||||
false,
|
||||
]);
|
||||
assert.deepEqual(global.__smoke.deactivated, []);
|
||||
// The activated bundle received the authoritative activation record.
|
||||
assert.deepEqual(global.__smoke.reports, [
|
||||
{
|
||||
reporter: "legacy",
|
||||
attemptedBundle: "legacy",
|
||||
actualBundle: "legacy",
|
||||
fallback: false,
|
||||
hasError: false,
|
||||
},
|
||||
]);
|
||||
// Stable identity: no nightly status bar indicator.
|
||||
assert.equal(statusBarItems.length, 0);
|
||||
});
|
||||
|
||||
await runScenario(
|
||||
"cached next -> next with scoped paths",
|
||||
{ seed: { "cline.rollout.bundle": "next" } },
|
||||
async ({ api, sandbox }) => {
|
||||
assert.deepEqual(api, { bundle: "next" });
|
||||
const activation = global.__smoke.activated[0];
|
||||
assert.equal(activation.extensionPath, path.join(sandbox, "next"));
|
||||
assert.equal(
|
||||
activation.asAbs,
|
||||
path.join(sandbox, "next", "webview-ui", "build"),
|
||||
);
|
||||
assert.deepEqual(executedCommands[0], [
|
||||
"setContext",
|
||||
"cline.sdkBundle",
|
||||
true,
|
||||
]);
|
||||
assert.deepEqual(global.__smoke.reports, [
|
||||
{
|
||||
reporter: "next",
|
||||
attemptedBundle: "next",
|
||||
actualBundle: "next",
|
||||
fallback: false,
|
||||
hasError: false,
|
||||
},
|
||||
]);
|
||||
},
|
||||
);
|
||||
|
||||
await runScenario(
|
||||
"rollout flag on promotes for the NEXT window only",
|
||||
{
|
||||
fetchController: makeFlagFetch({ rollout: true }),
|
||||
},
|
||||
async ({ context, api, fetchCalls }) => {
|
||||
// This window already decided legacy from the (empty) cache; the refresh
|
||||
// promotes the NEXT window.
|
||||
assert.deepEqual(api, { bundle: "legacy" });
|
||||
assert.equal(context.globalState._dump()["cline.rollout.bundle"], "next");
|
||||
const [featureFlagCalled] = featureFlagCalledCaptures(fetchCalls);
|
||||
assert.ok(featureFlagCalled, "rollout refresh must emit the PostHog feature-flag exposure event");
|
||||
assert.equal(featureFlagCalled.properties.$feature_flag, "ext-sdk-bundle-rollout");
|
||||
assert.equal(featureFlagCalled.properties.$feature_flag_response, true);
|
||||
},
|
||||
);
|
||||
|
||||
await runScenario(
|
||||
"rollout flag off demotes a cached next for the NEXT window (two-way)",
|
||||
{
|
||||
seed: { "cline.rollout.bundle": "next" },
|
||||
fetchController: makeFlagFetch({ rollout: false }),
|
||||
},
|
||||
async ({ context, api, fetchCalls }) => {
|
||||
// This window already ran next; dialing the flag down moves the machine
|
||||
// back to legacy on its next reload.
|
||||
assert.deepEqual(api, { bundle: "next" });
|
||||
assert.equal(context.globalState._dump()["cline.rollout.bundle"], "legacy");
|
||||
const [featureFlagCalled] = featureFlagCalledCaptures(fetchCalls);
|
||||
assert.ok(featureFlagCalled, "rollout refresh must emit the PostHog feature-flag exposure event");
|
||||
assert.equal(featureFlagCalled.event, "$feature_flag_called");
|
||||
assert.equal(featureFlagCalled.properties.$feature_flag, "ext-sdk-bundle-rollout");
|
||||
assert.equal(featureFlagCalled.properties.$feature_flag_response, false);
|
||||
},
|
||||
);
|
||||
|
||||
await runScenario(
|
||||
"env override forces next",
|
||||
{ env: { CLINE_BUNDLE_OVERRIDE: "next" } },
|
||||
async ({ api }) => {
|
||||
assert.deepEqual(api, { bundle: "next" });
|
||||
},
|
||||
);
|
||||
|
||||
await runScenario(
|
||||
"user setting overrides to legacy despite cached next",
|
||||
{
|
||||
seed: { "cline.rollout.bundle": "next" },
|
||||
settings: { "cline.rollout.bundleOverride": "legacy" },
|
||||
},
|
||||
async ({ api }) => {
|
||||
assert.deepEqual(api, { bundle: "legacy" });
|
||||
},
|
||||
);
|
||||
|
||||
await runScenario(
|
||||
"user setting overrides to next despite a cached legacy assignment",
|
||||
{
|
||||
seed: { "cline.rollout.bundle": "legacy" },
|
||||
settings: { "cline.rollout.bundleOverride": "next" },
|
||||
},
|
||||
async ({ api }) => {
|
||||
assert.deepEqual(api, { bundle: "next" });
|
||||
},
|
||||
);
|
||||
|
||||
const failedNextRefresh = makeDeferredFlagFetch({ rollout: true });
|
||||
await runScenario(
|
||||
"next activation failure falls back to legacy",
|
||||
{
|
||||
seed: { "cline.rollout.bundle": "next" },
|
||||
nextThrows: true,
|
||||
fetchController: failedNextRefresh,
|
||||
expectRefresh: false,
|
||||
beforeNextFailure: () =>
|
||||
Promise.race([
|
||||
failedNextRefresh.started,
|
||||
new Promise((resolve) => setTimeout(resolve, 100)),
|
||||
]),
|
||||
},
|
||||
async ({ context, api, fetchCalls }) => {
|
||||
assert.deepEqual(api, { bundle: "legacy" });
|
||||
assert.deepEqual(
|
||||
global.__smoke.disposed,
|
||||
["next"],
|
||||
"partial registrations disposed",
|
||||
);
|
||||
const state = context.globalState._dump();
|
||||
assert.equal(state["cline.rollout.bundle"], "legacy");
|
||||
assert.equal(
|
||||
state["cline.rollout.nextActivationFailedVersion"],
|
||||
"4.1.0-smoke",
|
||||
);
|
||||
assert.equal(
|
||||
context.subscriptions.length,
|
||||
0,
|
||||
"failed bundle's subscriptions removed",
|
||||
);
|
||||
// setContext flipped back for the legacy UI
|
||||
assert.deepEqual(executedCommands.at(-1), [
|
||||
"setContext",
|
||||
"cline.sdkBundle",
|
||||
false,
|
||||
]);
|
||||
// The LEGACY bundle (the one whose telemetry pipeline is alive) received
|
||||
// the authoritative fallback record; the dead next bundle reported nothing.
|
||||
assert.deepEqual(global.__smoke.reports, [
|
||||
{
|
||||
reporter: "legacy",
|
||||
attemptedBundle: "next",
|
||||
actualBundle: "legacy",
|
||||
fallback: true,
|
||||
hasError: true,
|
||||
},
|
||||
]);
|
||||
// Keep the fetch stub installed long enough for an incorrectly delayed
|
||||
// refresh to reach the network boundary before asserting its absence.
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
// Settle a refresh if the loader incorrectly launched one. With the old
|
||||
// ordering it would now promote COHORT_STATE_KEY back to next.
|
||||
if (decideCalls(fetchCalls).length > 0) {
|
||||
failedNextRefresh.resolve();
|
||||
await flushAsyncWork();
|
||||
}
|
||||
assert.equal(
|
||||
decideCalls(fetchCalls).length,
|
||||
0,
|
||||
"crash fallback must not refresh the failed cohort",
|
||||
);
|
||||
assert.equal(context.globalState._dump()["cline.rollout.bundle"], "legacy");
|
||||
},
|
||||
);
|
||||
|
||||
await runScenario(
|
||||
"loader_decision capture carries the loader-side metadata",
|
||||
{
|
||||
env: { CLINE_BUNDLE_OVERRIDE: "next" },
|
||||
telemetryEnabled: true,
|
||||
contextPackageJSON: { name: "claude-dev" },
|
||||
},
|
||||
async ({ fetchCalls }) => {
|
||||
await waitFor(
|
||||
() => loaderDecisionCaptures(fetchCalls).length > 0,
|
||||
"loader_decision capture never reached the network",
|
||||
);
|
||||
const captures = loaderDecisionCaptures(fetchCalls);
|
||||
assert.equal(captures.length, 1);
|
||||
const [capture] = captures;
|
||||
assert.equal(capture.event, "extension.rollout.loader_decision");
|
||||
assert.equal(capture.properties.bundle, "next");
|
||||
assert.equal(capture.properties.attempted_bundle, "next");
|
||||
assert.equal(capture.properties.fallback, false);
|
||||
assert.equal(capture.properties.override, "env");
|
||||
assert.equal(capture.properties.loader_version, "4.1.0-smoke");
|
||||
assert.equal(capture.properties.extension_name, "claude-dev");
|
||||
},
|
||||
);
|
||||
|
||||
await runScenario(
|
||||
"crash fallback captures exactly one loader_decision event",
|
||||
{
|
||||
seed: { "cline.rollout.bundle": "next" },
|
||||
nextThrows: true,
|
||||
telemetryEnabled: true,
|
||||
expectRefresh: false,
|
||||
},
|
||||
async ({ fetchCalls }) => {
|
||||
await waitFor(
|
||||
() => loaderDecisionCaptures(fetchCalls).length > 0,
|
||||
"fallback loader_decision capture never reached the network",
|
||||
);
|
||||
// Give an incorrect second capture (the pre-fix fallback:false event from
|
||||
// the recursive legacy success) time to reach the network before counting.
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
const captures = loaderDecisionCaptures(fetchCalls);
|
||||
assert.equal(
|
||||
captures.length,
|
||||
1,
|
||||
"fallback must emit exactly ONE loader event (regression: duplicate fallback:false event)",
|
||||
);
|
||||
const [capture] = captures;
|
||||
assert.equal(capture.event, "extension.rollout.loader_decision");
|
||||
assert.equal(capture.properties.bundle, "legacy");
|
||||
assert.equal(capture.properties.attempted_bundle, "next");
|
||||
assert.equal(capture.properties.fallback, true);
|
||||
assert.match(capture.properties.error_message, /next activation exploded/);
|
||||
},
|
||||
);
|
||||
|
||||
await runScenario(
|
||||
"a bundle without the reportRolloutActivation export still activates",
|
||||
{ omitReportExport: true },
|
||||
async ({ api }) => {
|
||||
assert.deepEqual(api, { bundle: "legacy" });
|
||||
assert.deepEqual(global.__smoke.reports, []);
|
||||
},
|
||||
);
|
||||
|
||||
await runScenario(
|
||||
"nightly identity: namespaced setting + context key, status bar indicator",
|
||||
{
|
||||
contextPackageJSON: { name: "cline-nightly" },
|
||||
settings: { "cline-nightly.rollout.bundleOverride": "next" },
|
||||
},
|
||||
async ({ api, context }) => {
|
||||
assert.deepEqual(api, { bundle: "next" });
|
||||
assert.deepEqual(executedCommands[0], [
|
||||
"setContext",
|
||||
"cline-nightly.sdkBundle",
|
||||
true,
|
||||
]);
|
||||
assert.equal(statusBarItems.length, 1);
|
||||
const [item] = statusBarItems;
|
||||
assert.equal(item.shown, true);
|
||||
assert.equal(item.text, "Cline: Next");
|
||||
assert.match(item.tooltip, /bundleOverride setting/);
|
||||
assert.ok(
|
||||
context.subscriptions.includes(item),
|
||||
"indicator must be disposed with the extension",
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
await runScenario(
|
||||
"double failure: both bundles throw, loader reports and rethrows",
|
||||
{
|
||||
seed: { "cline.rollout.bundle": "next" },
|
||||
nextThrows: true,
|
||||
legacyThrows: true,
|
||||
telemetryEnabled: true,
|
||||
expectFailure: true,
|
||||
expectRefresh: false,
|
||||
},
|
||||
async ({ activationError, fetchCalls }) => {
|
||||
assert.match(String(activationError), /legacy activation exploded/);
|
||||
assert.deepEqual(
|
||||
global.__smoke.reports,
|
||||
[],
|
||||
"no bundle survived to report the authoritative event",
|
||||
);
|
||||
await waitFor(
|
||||
() => loaderDecisionCaptures(fetchCalls).length >= 2,
|
||||
"double failure should capture the fallback AND the double_failure events",
|
||||
);
|
||||
const captures = loaderDecisionCaptures(fetchCalls);
|
||||
assert.equal(captures.length, 2);
|
||||
for (const capture of captures) {
|
||||
assert.equal(capture.event, "extension.rollout.loader_decision");
|
||||
assert.equal(capture.properties.fallback, true);
|
||||
}
|
||||
const doubleFailure = captures.find(
|
||||
(c) => c.properties.double_failure === true,
|
||||
);
|
||||
assert.ok(doubleFailure, "one capture must be flagged double_failure");
|
||||
assert.equal(doubleFailure.properties.attempted_bundle, "next");
|
||||
assert.match(
|
||||
doubleFailure.properties.error_message,
|
||||
/legacy activation exploded/,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
await runScenario(
|
||||
"deactivate delegates to active bundle",
|
||||
{},
|
||||
async () => {},
|
||||
async () => {
|
||||
assert.deepEqual(global.__smoke.deactivated, ["legacy"]);
|
||||
},
|
||||
);
|
||||
|
||||
console.log("\nall loader smoke scenarios passed");
|
||||
@@ -0,0 +1,180 @@
|
||||
/**
|
||||
* Assemble the combined (loader + next + legacy) VSIX staging directory.
|
||||
*
|
||||
* Layout produced:
|
||||
* <out>/
|
||||
* extension.js loader bundle (this package's dist/extension.js)
|
||||
* package.json union manifest (gen-manifest.mjs)
|
||||
* README.md next's marketplace README
|
||||
* LICENSE, CHANGELOG.md, assets/, walkthrough/ from next (manifest-referenced, VSIX-root-relative)
|
||||
* next/ SDK extension payload (dist/, webview-ui/build/, assets/, package.json)
|
||||
* legacy/ legacy extension payload (dist/, webview-ui/build/, assets/,
|
||||
* node_modules/@vscode/codicons/dist/, package.json)
|
||||
*
|
||||
* Each bundle resolves its own resources under its subdirectory because the
|
||||
* loader hands it an ExtensionContext whose extensionUri/extensionPath point
|
||||
* there (see src/scoped-context.ts). Manifest-referenced resources (icons,
|
||||
* walkthrough media, codicon font declared in contributes.icons) resolve from
|
||||
* the VSIX root, where the stitcher places next's copies.
|
||||
*
|
||||
* Usage:
|
||||
* node stitch.mjs --next <apps/vscode dir, built> --legacy <apps/vscode dir, built> \
|
||||
* --loader <dist/extension.js> --version <x.y.z> --out <staging dir>
|
||||
*/
|
||||
import {
|
||||
cpSync,
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import path from "node:path";
|
||||
import { generateManifest } from "./gen-manifest.mjs";
|
||||
|
||||
// Legacy's webview loads codicon.css straight from node_modules (see its
|
||||
// WebviewProvider); next bundles the font into its webview build but its own
|
||||
// .vscodeignore still re-includes the codicons dist, so mirror that here.
|
||||
const BUNDLE_PAYLOAD = {
|
||||
next: [
|
||||
"dist",
|
||||
"webview-ui/build",
|
||||
"assets",
|
||||
"package.json",
|
||||
"node_modules/@vscode/codicons/dist",
|
||||
],
|
||||
legacy: [
|
||||
"dist",
|
||||
"webview-ui/build",
|
||||
"assets",
|
||||
"package.json",
|
||||
"node_modules/@vscode/codicons/dist",
|
||||
],
|
||||
};
|
||||
|
||||
/** VSIX-root files, all taken from the next checkout (manifest fields come from next too). */
|
||||
const ROOT_PAYLOAD = ["LICENSE", "CHANGELOG.md", "assets", "walkthrough"];
|
||||
|
||||
function parseArgs(argv) {
|
||||
const args = {};
|
||||
for (let i = 2; i < argv.length; i += 2) {
|
||||
args[argv[i].replace(/^--/, "")] = argv[i + 1];
|
||||
}
|
||||
return args;
|
||||
}
|
||||
|
||||
function copyInto(sourceRoot, relPaths, destRoot, { optional = [] } = {}) {
|
||||
for (const rel of relPaths) {
|
||||
const source = path.join(sourceRoot, rel);
|
||||
if (!existsSync(source)) {
|
||||
if (optional.includes(rel)) {
|
||||
console.warn(` skip (missing, optional): ${rel}`);
|
||||
continue;
|
||||
}
|
||||
throw new Error(
|
||||
`required payload missing: ${source} — did the bundle build run?`,
|
||||
);
|
||||
}
|
||||
cpSync(source, path.join(destRoot, rel), {
|
||||
recursive: true,
|
||||
dereference: true,
|
||||
});
|
||||
console.log(` + ${rel}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function stitch({ next, legacy, loader, version, out }) {
|
||||
for (const [name, value] of Object.entries({
|
||||
next,
|
||||
legacy,
|
||||
loader,
|
||||
version,
|
||||
out,
|
||||
})) {
|
||||
if (!value) {
|
||||
throw new Error(`--${name} is required`);
|
||||
}
|
||||
}
|
||||
// Refuse to stage from an unbuilt tree early, with a clear message.
|
||||
for (const [name, root] of [
|
||||
["next", next],
|
||||
["legacy", legacy],
|
||||
]) {
|
||||
if (!existsSync(path.join(root, "dist", "extension.js"))) {
|
||||
throw new Error(
|
||||
`${name} bundle not built: ${root}/dist/extension.js missing`,
|
||||
);
|
||||
}
|
||||
if (
|
||||
!existsSync(path.join(root, "webview-ui", "build", "assets", "index.js"))
|
||||
) {
|
||||
throw new Error(
|
||||
`${name} webview not built: ${root}/webview-ui/build/assets/index.js missing`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
rmSync(out, { recursive: true, force: true });
|
||||
mkdirSync(out, { recursive: true });
|
||||
|
||||
console.log("root payload (from next):");
|
||||
copyInto(next, ROOT_PAYLOAD, out, {
|
||||
optional: ["CHANGELOG.md", "walkthrough"],
|
||||
});
|
||||
cpSync(loader, path.join(out, "extension.js"));
|
||||
console.log(" + extension.js (loader)");
|
||||
|
||||
const readme = path.join(next, "README.marketplace.md");
|
||||
cpSync(
|
||||
existsSync(readme) ? readme : path.join(next, "README.md"),
|
||||
path.join(out, "README.md"),
|
||||
);
|
||||
console.log(" + README.md");
|
||||
|
||||
for (const [bundle, payload] of Object.entries(BUNDLE_PAYLOAD)) {
|
||||
const sourceRoot = bundle === "next" ? next : legacy;
|
||||
console.log(`${bundle} payload:`);
|
||||
copyInto(sourceRoot, payload, path.join(out, bundle), {
|
||||
optional: ["walkthrough"],
|
||||
});
|
||||
}
|
||||
|
||||
const manifest = generateManifest(
|
||||
JSON.parse(readFileSync(path.join(next, "package.json"), "utf8")),
|
||||
JSON.parse(readFileSync(path.join(legacy, "package.json"), "utf8")),
|
||||
version,
|
||||
);
|
||||
writeFileSync(
|
||||
path.join(out, "package.json"),
|
||||
`${JSON.stringify(manifest, null, "\t")}\n`,
|
||||
);
|
||||
console.log(" + package.json (union manifest)");
|
||||
|
||||
// vsce packages everything in the staging dir; only strip sourcemaps and
|
||||
// junk. The codicons files under legacy/node_modules must survive, so no
|
||||
// blanket node_modules ignore here — staging only ever contains what this
|
||||
// script copied.
|
||||
writeFileSync(
|
||||
path.join(out, ".vscodeignore"),
|
||||
["**/*.map", "**/.DS_Store", ""].join("\n"),
|
||||
);
|
||||
|
||||
console.log(`\nstaged ${out} (version ${version})`);
|
||||
// Keep the scanner exemption category-scoped to match the standalone bundle
|
||||
// workflows; see the README for its scope and verification notes.
|
||||
console.log(
|
||||
`package it with:\n cd ${out} && vsce package --no-dependencies --allow-package-secrets sendgrid`,
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
process.argv[1] &&
|
||||
import.meta.url === `file://${path.resolve(process.argv[1])}`
|
||||
) {
|
||||
try {
|
||||
stitch(parseArgs(process.argv));
|
||||
} catch (error) {
|
||||
console.error(`stitch failed: ${error.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import {
|
||||
bundleContextKey,
|
||||
decideBundle,
|
||||
decisionOverrideSource,
|
||||
idPrefix,
|
||||
parseRolloutAssignment,
|
||||
ROLLOUT_FLAG,
|
||||
settingSection,
|
||||
} from "./cohort";
|
||||
|
||||
const base = {
|
||||
envOverride: undefined,
|
||||
settingOverride: undefined,
|
||||
cached: undefined,
|
||||
previousFailure: false,
|
||||
};
|
||||
|
||||
describe("decideBundle", () => {
|
||||
it("defaults to legacy with no cached assignment", () => {
|
||||
expect(decideBundle(base)).toBe("legacy");
|
||||
});
|
||||
|
||||
it("uses the cached assignment", () => {
|
||||
expect(decideBundle({ ...base, cached: "next" })).toBe("next");
|
||||
expect(decideBundle({ ...base, cached: "legacy" })).toBe("legacy");
|
||||
});
|
||||
|
||||
it("treats unknown cached values as legacy", () => {
|
||||
expect(decideBundle({ ...base, cached: "garbage" })).toBe("legacy");
|
||||
});
|
||||
|
||||
it("a previous activation failure on this version forces legacy", () => {
|
||||
expect(
|
||||
decideBundle({ ...base, cached: "next", previousFailure: true }),
|
||||
).toBe("legacy");
|
||||
});
|
||||
|
||||
it("env override beats everything, including a previous failure", () => {
|
||||
expect(
|
||||
decideBundle({ ...base, envOverride: "next", previousFailure: true }),
|
||||
).toBe("next");
|
||||
expect(
|
||||
decideBundle({ ...base, envOverride: "legacy", cached: "next" }),
|
||||
).toBe("legacy");
|
||||
});
|
||||
|
||||
it("user setting overrides in both directions", () => {
|
||||
expect(
|
||||
decideBundle({ ...base, settingOverride: "next", previousFailure: true }),
|
||||
).toBe("next");
|
||||
expect(
|
||||
decideBundle({ ...base, settingOverride: "legacy", cached: "next" }),
|
||||
).toBe("legacy");
|
||||
});
|
||||
|
||||
it("env override beats the user setting", () => {
|
||||
expect(
|
||||
decideBundle({ ...base, envOverride: "legacy", settingOverride: "next" }),
|
||||
).toBe("legacy");
|
||||
});
|
||||
|
||||
it("ignores invalid and 'auto' overrides", () => {
|
||||
expect(decideBundle({ ...base, envOverride: "beta", cached: "next" })).toBe(
|
||||
"next",
|
||||
);
|
||||
expect(
|
||||
decideBundle({ ...base, settingOverride: "auto", cached: "next" }),
|
||||
).toBe("next");
|
||||
});
|
||||
});
|
||||
|
||||
describe("decisionOverrideSource", () => {
|
||||
it("reports which override was active", () => {
|
||||
expect(decisionOverrideSource(base)).toBeUndefined();
|
||||
expect(decisionOverrideSource({ ...base, settingOverride: "next" })).toBe(
|
||||
"setting",
|
||||
);
|
||||
expect(
|
||||
decisionOverrideSource({
|
||||
...base,
|
||||
envOverride: "legacy",
|
||||
settingOverride: "next",
|
||||
}),
|
||||
).toBe("env");
|
||||
expect(
|
||||
decisionOverrideSource({ ...base, settingOverride: "auto" }),
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseRolloutAssignment", () => {
|
||||
it("promotes only on a literal boolean true", () => {
|
||||
expect(
|
||||
parseRolloutAssignment({ featureFlags: { [ROLLOUT_FLAG]: true } }),
|
||||
).toBe("next");
|
||||
});
|
||||
|
||||
it("is two-way: anything else resolves to legacy (fail-safe)", () => {
|
||||
// false = dialed out of the cohort; the rest = mis-configured flag.
|
||||
for (const value of ["test", "control", 1, 0.5, {}, false, undefined]) {
|
||||
expect(
|
||||
parseRolloutAssignment({ featureFlags: { [ROLLOUT_FLAG]: value } }),
|
||||
).toBe("legacy");
|
||||
}
|
||||
// Flag deleted / not created yet: nobody promoted.
|
||||
expect(parseRolloutAssignment({ featureFlags: {} })).toBe("legacy");
|
||||
});
|
||||
|
||||
it("returns undefined for malformed responses (cache left untouched)", () => {
|
||||
expect(parseRolloutAssignment(undefined)).toBeUndefined();
|
||||
expect(parseRolloutAssignment({})).toBeUndefined();
|
||||
expect(parseRolloutAssignment({ featureFlags: null })).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("identity prefix", () => {
|
||||
it("maps the nightly manifest name to the cline-nightly namespace", () => {
|
||||
expect(idPrefix("cline-nightly")).toBe("cline-nightly");
|
||||
});
|
||||
|
||||
it("maps everything else (stable claude-dev, unknown, missing) to cline", () => {
|
||||
expect(idPrefix("claude-dev")).toBe("cline");
|
||||
expect(idPrefix("some-fork")).toBe("cline");
|
||||
expect(idPrefix(undefined)).toBe("cline");
|
||||
});
|
||||
|
||||
it("derives the setting section and context key from the prefix", () => {
|
||||
expect(settingSection("cline")).toBe("cline.rollout");
|
||||
expect(settingSection("cline-nightly")).toBe("cline-nightly.rollout");
|
||||
expect(bundleContextKey("cline")).toBe("cline.sdkBundle");
|
||||
expect(bundleContextKey("cline-nightly")).toBe("cline-nightly.sdkBundle");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,134 @@
|
||||
export type Bundle = "next" | "legacy";
|
||||
|
||||
/**
|
||||
* The combined VSIX ships under two identities: the stable extension
|
||||
* (manifest name "claude-dev", contribution IDs under "cline.*") and the
|
||||
* nightly (name "cline-nightly", IDs under "cline-nightly.*" — the nightly
|
||||
* packaging rewrites every `"cline.` prefix in the manifest, see
|
||||
* scripts/nightlify.mjs and apps/vscode/scripts/publish-nightly.mjs). Anything
|
||||
* the loader reads from or feeds back into the manifest namespace — the
|
||||
* bundleOverride setting and the sdkBundle context key — must use the prefix
|
||||
* matching the installed identity. scripts/gen-manifest.mjs derives the same
|
||||
* prefix when generating the union manifest; keep them in sync.
|
||||
*/
|
||||
export const NIGHTLY_EXTENSION_NAME = "cline-nightly";
|
||||
export type IdPrefix = "cline" | "cline-nightly";
|
||||
|
||||
export function idPrefix(extensionName: string | undefined): IdPrefix {
|
||||
return extensionName === NIGHTLY_EXTENSION_NAME ? "cline-nightly" : "cline";
|
||||
}
|
||||
|
||||
/** Settings section holding the bundleOverride escape hatch. */
|
||||
export function settingSection(prefix: IdPrefix): string {
|
||||
return `${prefix}.rollout`;
|
||||
}
|
||||
|
||||
/** Context key gating per-cohort menus/keybindings in the union manifest. */
|
||||
export function bundleContextKey(prefix: IdPrefix): string {
|
||||
return `${prefix}.sdkBundle`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Loader-owned VS Code memento keys. Never touched by either bundle. These
|
||||
* deliberately stay un-prefixed by identity: globalState is already scoped to
|
||||
* the extension ID, so a stable and a nightly install can never collide.
|
||||
*/
|
||||
export const COHORT_STATE_KEY = "cline.rollout.bundle";
|
||||
/** Version of the combined VSIX whose `next` bundle failed to activate, if any. */
|
||||
export const FAILED_VERSION_STATE_KEY =
|
||||
"cline.rollout.nextActivationFailedVersion";
|
||||
/** Epoch ms of the previous loader activation, for launch-cadence telemetry. */
|
||||
export const LAST_ACTIVATION_STATE_KEY = "cline.rollout.lastActivationAt";
|
||||
|
||||
/**
|
||||
* PostHog rollout flag (created in the Cline PostHog project). Must be a
|
||||
* plain BOOLEAN release flag with a percentage rollout.
|
||||
*
|
||||
* The assignment is TWO-WAY: each background refresh caches exactly what the
|
||||
* flag says (true => next, anything else => legacy) for the next window, so
|
||||
* dialing the percentage down moves machines back to legacy on their next
|
||||
* reload — the single emergency lever is "set the rollout to 0%". Demoted
|
||||
* machines keep their settings/creds (the state files round-trip), but tasks
|
||||
* created on the SDK bundle aren't visible in legacy's history until
|
||||
* re-promoted, and tokens rotated on next may require re-auth on legacy.
|
||||
*/
|
||||
export const ROLLOUT_FLAG = "ext-sdk-bundle-rollout";
|
||||
|
||||
/** Env var for local dev / e2e to force a bundle. Beats everything. */
|
||||
export const BUNDLE_OVERRIDE_ENV = "CLINE_BUNDLE_OVERRIDE";
|
||||
|
||||
/**
|
||||
* User-visible escape hatch: `<prefix>.rollout.bundleOverride` in VS Code
|
||||
* settings ("auto" | "next" | "legacy") — see settingSection() for the
|
||||
* identity-dependent section name. Editable from settings.json without
|
||||
* touching mementos, beats the remote flag in either direction, applies on
|
||||
* window reload. Injected into the union manifest by gen-manifest.mjs — keep
|
||||
* the schema there in sync with these constants.
|
||||
*/
|
||||
export const SETTING_BUNDLE_OVERRIDE = "bundleOverride";
|
||||
|
||||
function asBundle(value: unknown): Bundle | undefined {
|
||||
return value === "next" || value === "legacy" ? value : undefined;
|
||||
}
|
||||
|
||||
export interface CohortInputs {
|
||||
/** CLINE_BUNDLE_OVERRIDE, if set. */
|
||||
envOverride: string | undefined;
|
||||
/** The <prefix>.rollout.bundleOverride user setting ("auto" = no override). */
|
||||
settingOverride: string | undefined;
|
||||
/** Cached assignment from the previous window's background flag refresh. */
|
||||
cached: string | undefined;
|
||||
/** The next bundle failed to activate on this VSIX version before. */
|
||||
previousFailure: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide which bundle to activate for this window. Must be synchronous and
|
||||
* never block on the network: it only consumes state cached by the previous
|
||||
* window's background refresh, so a percentage change applies on the next
|
||||
* window reload, mirroring how VS Code's own experiments behave.
|
||||
*/
|
||||
export function decideBundle(inputs: CohortInputs): Bundle {
|
||||
const forced =
|
||||
asBundle(inputs.envOverride) ?? asBundle(inputs.settingOverride);
|
||||
if (forced) {
|
||||
return forced;
|
||||
}
|
||||
if (inputs.previousFailure) {
|
||||
return "legacy";
|
||||
}
|
||||
return inputs.cached === "next" ? "next" : "legacy";
|
||||
}
|
||||
|
||||
/** Which override produced the decision, if any — reported on the activation event. */
|
||||
export function decisionOverrideSource(
|
||||
inputs: Pick<CohortInputs, "envOverride" | "settingOverride">,
|
||||
): "env" | "setting" | undefined {
|
||||
if (asBundle(inputs.envOverride)) {
|
||||
return "env";
|
||||
}
|
||||
if (asBundle(inputs.settingOverride)) {
|
||||
return "setting";
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a PostHog /decide (v3) response into the assignment to cache for the
|
||||
* next window, or undefined when the response is malformed (leave the cached
|
||||
* assignment untouched — sticky on transient failures).
|
||||
*
|
||||
* Deliberately strict so a mis-configured flag fails SAFE toward legacy: only
|
||||
* boolean `true` promotes. A multivariate variant string, a number, a payload,
|
||||
* or a missing/deleted flag all resolve to legacy — the flag must stay a plain
|
||||
* boolean release flag with a percentage rollout.
|
||||
*/
|
||||
export function parseRolloutAssignment(response: unknown): Bundle | undefined {
|
||||
const flags = (
|
||||
response as { featureFlags?: Record<string, unknown> } | undefined
|
||||
)?.featureFlags;
|
||||
if (!flags || typeof flags !== "object") {
|
||||
return undefined;
|
||||
}
|
||||
return flags[ROLLOUT_FLAG] === true ? "next" : "legacy";
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
import { createRequire } from "node:module";
|
||||
import path from "node:path";
|
||||
import * as vscode from "vscode";
|
||||
import {
|
||||
BUNDLE_OVERRIDE_ENV,
|
||||
type Bundle,
|
||||
bundleContextKey,
|
||||
COHORT_STATE_KEY,
|
||||
decideBundle,
|
||||
decisionOverrideSource,
|
||||
FAILED_VERSION_STATE_KEY,
|
||||
type IdPrefix,
|
||||
idPrefix,
|
||||
LAST_ACTIVATION_STATE_KEY,
|
||||
SETTING_BUNDLE_OVERRIDE,
|
||||
settingSection,
|
||||
} from "./cohort";
|
||||
import { refreshCohort, reportLoaderDecision } from "./rollout";
|
||||
import { scopedContext } from "./scoped-context";
|
||||
|
||||
/**
|
||||
* Cline rollout loader.
|
||||
*
|
||||
* The VSIX ships two complete, independently built extension bundles:
|
||||
* next/ — the SDK-based extension (built from main's apps/vscode)
|
||||
* legacy/ — the pre-SDK extension (built from the legacy-extension branch)
|
||||
*
|
||||
* This entrypoint picks exactly one per window — from state cached by the
|
||||
* previous window's background flag refresh, never from a blocking network
|
||||
* call — activates it with a context whose install-root paths point into its
|
||||
* subdirectory, and delegates everything else to it. If the next bundle throws
|
||||
* during activation, the loader disposes whatever it half-registered, pins
|
||||
* this VSIX version back to legacy, and activates legacy instead.
|
||||
*/
|
||||
|
||||
// Resolved at runtime relative to the installed VSIX root; must stay opaque to
|
||||
// esbuild so the bundles aren't inlined into the loader.
|
||||
const requireFromVsixRoot = createRequire(__filename);
|
||||
|
||||
interface BundleModule {
|
||||
activate(context: vscode.ExtensionContext): Promise<unknown> | unknown;
|
||||
deactivate?(): Promise<void> | void;
|
||||
/**
|
||||
* Exported by both bundles' entrypoints (see rollout-metadata.ts on each
|
||||
* branch): captures the AUTHORITATIVE `extension.rollout.bundle_activated`
|
||||
* event through the bundle's own variant-attributed telemetry pipeline.
|
||||
* Optional so the loader keeps working against a bundle built before the
|
||||
* export existed.
|
||||
*/
|
||||
reportRolloutActivation?(input: {
|
||||
attemptedBundle: Bundle;
|
||||
actualBundle: Bundle;
|
||||
fallback: boolean;
|
||||
error?: unknown;
|
||||
}): Promise<void>;
|
||||
}
|
||||
|
||||
let activeBundle: { module: BundleModule; name: Bundle } | undefined;
|
||||
|
||||
interface ActivationMeta {
|
||||
msSinceLastActivation?: number;
|
||||
override?: "env" | "setting";
|
||||
}
|
||||
|
||||
/** Set when the original decision crashed and this activation is the fallback. */
|
||||
interface FallbackFrom {
|
||||
attempted: Bundle;
|
||||
error: unknown;
|
||||
}
|
||||
|
||||
export async function activate(context: vscode.ExtensionContext) {
|
||||
const loaderVersion: string =
|
||||
context.extension.packageJSON?.version ?? "unknown";
|
||||
const prefix = idPrefix(context.extension.packageJSON?.name);
|
||||
|
||||
// Launch-cadence telemetry: how stale the previous activation is bounds how
|
||||
// fast a percentage change can actually reach users' windows.
|
||||
const lastActivationAt = context.globalState.get<number>(
|
||||
LAST_ACTIVATION_STATE_KEY,
|
||||
);
|
||||
const now = Date.now();
|
||||
void context.globalState.update(LAST_ACTIVATION_STATE_KEY, now);
|
||||
|
||||
const overrides = {
|
||||
envOverride: process.env[BUNDLE_OVERRIDE_ENV],
|
||||
settingOverride: vscode.workspace
|
||||
.getConfiguration(settingSection(prefix))
|
||||
.get<string>(SETTING_BUNDLE_OVERRIDE),
|
||||
};
|
||||
const bundle = decideBundle({
|
||||
...overrides,
|
||||
cached: context.globalState.get<string>(COHORT_STATE_KEY),
|
||||
previousFailure:
|
||||
context.globalState.get<string>(FAILED_VERSION_STATE_KEY) ===
|
||||
loaderVersion,
|
||||
});
|
||||
const meta: ActivationMeta = {
|
||||
msSinceLastActivation:
|
||||
typeof lastActivationAt === "number" && lastActivationAt <= now
|
||||
? now - lastActivationAt
|
||||
: undefined,
|
||||
override: decisionOverrideSource(overrides),
|
||||
};
|
||||
|
||||
return activateBundle(context, prefix, bundle, loaderVersion, meta, true);
|
||||
}
|
||||
|
||||
async function activateBundle(
|
||||
context: vscode.ExtensionContext,
|
||||
prefix: IdPrefix,
|
||||
bundle: Bundle,
|
||||
loaderVersion: string,
|
||||
meta: ActivationMeta,
|
||||
refreshAssignmentOnSuccess: boolean,
|
||||
fallbackFrom?: FallbackFrom,
|
||||
): Promise<unknown> {
|
||||
// Menus/keybindings gated per cohort in package.json key off this.
|
||||
await vscode.commands.executeCommand(
|
||||
"setContext",
|
||||
bundleContextKey(prefix),
|
||||
bundle === "next",
|
||||
);
|
||||
|
||||
const subscriptionsBefore = context.subscriptions.length;
|
||||
try {
|
||||
const module = requireFromVsixRoot(
|
||||
path.join(__dirname, bundle, "dist", "extension.js"),
|
||||
) as BundleModule;
|
||||
const api = await module.activate(scopedContext(context, bundle));
|
||||
activeBundle = { module, name: bundle };
|
||||
// Cache the next window's assignment only after the originally selected
|
||||
// bundle activates. A crash fallback must not start a refresh that could
|
||||
// promote the cohort back to next after the handler pins it to legacy.
|
||||
if (refreshAssignmentOnSuccess) {
|
||||
void refreshCohort(context).catch(() => {});
|
||||
}
|
||||
// Authoritative activation event, captured by the bundle's own telemetry
|
||||
// (built with CLINE_ROLLOUT_VARIANT). On fallback this runs in the legacy
|
||||
// bundle — next's pipeline is the thing that just crashed.
|
||||
if (typeof module.reportRolloutActivation === "function") {
|
||||
void module
|
||||
.reportRolloutActivation({
|
||||
attemptedBundle: fallbackFrom?.attempted ?? bundle,
|
||||
actualBundle: bundle,
|
||||
fallback: fallbackFrom !== undefined,
|
||||
error: fallbackFrom?.error,
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
// The loader's own decision event fires once per window: the fallback
|
||||
// path already reported (fallback: true) from the catch block below.
|
||||
if (!fallbackFrom) {
|
||||
void reportLoaderDecision(context, bundle, {
|
||||
...meta,
|
||||
fallback: false,
|
||||
}).catch(() => {});
|
||||
}
|
||||
showNightlyBundleIndicator(context, prefix, bundle, meta, fallbackFrom);
|
||||
return api;
|
||||
} catch (error) {
|
||||
if (bundle === "legacy") {
|
||||
// Nothing left to fall back to; let VS Code surface the failure. When
|
||||
// this was already the crash fallback, no bundle telemetry pipeline is
|
||||
// alive — the loader's direct event is the only record.
|
||||
void reportLoaderDecision(context, "legacy", {
|
||||
...meta,
|
||||
attemptedBundle: fallbackFrom?.attempted ?? "legacy",
|
||||
fallback: fallbackFrom !== undefined,
|
||||
doubleFailure: fallbackFrom !== undefined,
|
||||
errorMessage: formatActivationError(error),
|
||||
}).catch(() => {});
|
||||
throw error;
|
||||
}
|
||||
console.error(
|
||||
"[cline-rollout] next bundle failed to activate, falling back to legacy:",
|
||||
error,
|
||||
);
|
||||
disposeSubscriptionsAddedAfter(context, subscriptionsBefore);
|
||||
// Pin this VSIX version to legacy so we don't crash-loop every window.
|
||||
// A new release (new version string) gets to try next again.
|
||||
await context.globalState.update(FAILED_VERSION_STATE_KEY, loaderVersion);
|
||||
await context.globalState.update(COHORT_STATE_KEY, "legacy");
|
||||
void reportLoaderDecision(context, "legacy", {
|
||||
...meta,
|
||||
attemptedBundle: "next",
|
||||
fallback: true,
|
||||
errorMessage: formatActivationError(error),
|
||||
}).catch(() => {});
|
||||
return activateBundle(
|
||||
context,
|
||||
prefix,
|
||||
"legacy",
|
||||
loaderVersion,
|
||||
meta,
|
||||
false,
|
||||
{ attempted: "next", error },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function formatActivationError(error: unknown): string {
|
||||
return error instanceof Error
|
||||
? `${error.message}\n${error.stack ?? ""}`.slice(0, 2000)
|
||||
: String(error);
|
||||
}
|
||||
|
||||
/**
|
||||
* Nightly-only visible indicator of which bundle this window is running.
|
||||
* The stable combined VSIX (and any ordinary build) never shows it: the
|
||||
* prefix is derived from the packaged manifest name. Best-effort — the
|
||||
* indicator must never take down an otherwise successful activation.
|
||||
*/
|
||||
function showNightlyBundleIndicator(
|
||||
context: vscode.ExtensionContext,
|
||||
prefix: IdPrefix,
|
||||
bundle: Bundle,
|
||||
meta: ActivationMeta,
|
||||
fallbackFrom: FallbackFrom | undefined,
|
||||
) {
|
||||
if (prefix !== "cline-nightly") {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const item = vscode.window.createStatusBarItem(
|
||||
vscode.StatusBarAlignment.Right,
|
||||
-1000,
|
||||
);
|
||||
item.text = bundle === "next" ? "Cline: Next" : "Cline: Legacy";
|
||||
const detail = fallbackFrom
|
||||
? "crash fallback from the next bundle"
|
||||
: meta.override
|
||||
? `forced by ${meta.override === "env" ? `the ${BUNDLE_OVERRIDE_ENV} env var` : "the bundleOverride setting"}`
|
||||
: "rollout assignment";
|
||||
item.tooltip = `Cline nightly A/B rollout: running the ${bundle === "next" ? "next (SDK)" : "legacy"} bundle (${detail}).`;
|
||||
item.show();
|
||||
context.subscriptions.push(item);
|
||||
} catch (error) {
|
||||
console.warn("[cline-rollout] could not show bundle indicator:", error);
|
||||
}
|
||||
}
|
||||
|
||||
/** Dispose anything a failed activation managed to register before it threw. */
|
||||
function disposeSubscriptionsAddedAfter(
|
||||
context: vscode.ExtensionContext,
|
||||
startIndex: number,
|
||||
) {
|
||||
const added = context.subscriptions.splice(startIndex);
|
||||
for (const disposable of added) {
|
||||
try {
|
||||
disposable.dispose();
|
||||
} catch {
|
||||
// best effort — a broken disposable must not block the fallback
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function deactivate() {
|
||||
return activeBundle?.module.deactivate?.();
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { machineId } from "node-machine-id";
|
||||
import * as vscode from "vscode";
|
||||
import {
|
||||
type Bundle,
|
||||
COHORT_STATE_KEY,
|
||||
parseRolloutAssignment,
|
||||
ROLLOUT_FLAG,
|
||||
} from "./cohort";
|
||||
|
||||
/**
|
||||
* Same PostHog project + reverse proxy the extension's telemetry uses.
|
||||
* The API key is injected at build time by CI (see esbuild.mjs), matching how
|
||||
* apps/vscode injects TELEMETRY_SERVICE_API_KEY. Local builds without the key
|
||||
* skip all network calls, so the loader defaults everyone to legacy.
|
||||
*/
|
||||
const POSTHOG_HOST = "https://data.cline.bot";
|
||||
const POSTHOG_API_KEY = process.env.TELEMETRY_SERVICE_API_KEY;
|
||||
const FETCH_TIMEOUT_MS = 10_000;
|
||||
const FEATURE_FLAG_CALLED_EVENT = "$feature_flag_called";
|
||||
|
||||
/**
|
||||
* Mirror the distinct-id derivation in apps/vscode
|
||||
* (src/services/logging/distinctId.ts) so PostHog evaluates the rollout flag
|
||||
* against the same id the bundles report telemetry with — otherwise cohort
|
||||
* membership can't be correlated with cohort behavior in dashboards.
|
||||
* Falls back to vscode.env.machineId rather than generating + persisting a new
|
||||
* id: the loader must never write to the shared ~/.cline state files.
|
||||
*/
|
||||
async function getDistinctId(): Promise<string> {
|
||||
const generated = await readSharedGlobalStateKey("cline.generatedMachineId");
|
||||
if (typeof generated === "string" && generated.length > 0) {
|
||||
return generated;
|
||||
}
|
||||
try {
|
||||
const id = await machineId();
|
||||
if (id) {
|
||||
return id;
|
||||
}
|
||||
} catch {
|
||||
// fall through
|
||||
}
|
||||
return vscode.env.machineId;
|
||||
}
|
||||
|
||||
/** Read one key from the file-backed global state both bundles share. */
|
||||
async function readSharedGlobalStateKey(key: string): Promise<unknown> {
|
||||
try {
|
||||
const clineDir = process.env.CLINE_DIR || path.join(os.homedir(), ".cline");
|
||||
const raw = await readFile(
|
||||
path.join(clineDir, "data", "globalState.json"),
|
||||
"utf8",
|
||||
);
|
||||
const state = JSON.parse(raw);
|
||||
return state?.[key];
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
async function postJson(
|
||||
url: string,
|
||||
body: object,
|
||||
): Promise<Response | undefined> {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
||||
try {
|
||||
return await fetch(url, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
signal: controller.signal,
|
||||
});
|
||||
} catch {
|
||||
return undefined;
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchAssignment(
|
||||
distinctId: string,
|
||||
): Promise<Bundle | undefined> {
|
||||
if (!POSTHOG_API_KEY) {
|
||||
return undefined;
|
||||
}
|
||||
const response = await postJson(`${POSTHOG_HOST}/decide?v=3`, {
|
||||
api_key: POSTHOG_API_KEY,
|
||||
distinct_id: distinctId,
|
||||
});
|
||||
if (!response?.ok) {
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
const decideResponse = await response.json();
|
||||
const assignment = parseRolloutAssignment(decideResponse);
|
||||
if (!assignment) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Mirror FeatureFlagsService/PostHog SDK exposure tracking for this
|
||||
// loader-owned flag evaluation. This event is intentionally not gated by
|
||||
// telemetry opt-out: feature-flag evaluation remains enabled so PostHog can
|
||||
// correctly attribute rollout cohorts, while loader_decision below still
|
||||
// respects user/host telemetry settings.
|
||||
void reportFeatureFlagCalled(
|
||||
distinctId,
|
||||
getRolloutFlagResponse(decideResponse),
|
||||
).catch(() => {});
|
||||
|
||||
return assignment;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function getRolloutFlagResponse(response: unknown): unknown {
|
||||
const flags = (
|
||||
response as { featureFlags?: Record<string, unknown> } | undefined
|
||||
)?.featureFlags;
|
||||
return flags && typeof flags === "object" ? flags[ROLLOUT_FLAG] : undefined;
|
||||
}
|
||||
|
||||
async function reportFeatureFlagCalled(
|
||||
distinctId: string,
|
||||
flagResponse: unknown,
|
||||
): Promise<void> {
|
||||
if (!POSTHOG_API_KEY) {
|
||||
return;
|
||||
}
|
||||
await postJson(`${POSTHOG_HOST}/capture/`, {
|
||||
api_key: POSTHOG_API_KEY,
|
||||
event: FEATURE_FLAG_CALLED_EVENT,
|
||||
distinct_id: distinctId,
|
||||
properties: {
|
||||
$feature_flag: ROLLOUT_FLAG,
|
||||
$feature_flag_response: flagResponse,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Background refresh: evaluate the rollout flag and cache exactly what it
|
||||
* says for the NEXT window (two-way: dialing the percentage down demotes on
|
||||
* the next reload). Never affects the bundle already activated in this
|
||||
* window, and failures leave the cached assignment untouched (sticky on
|
||||
* transient errors only).
|
||||
*/
|
||||
export async function refreshCohort(
|
||||
context: vscode.ExtensionContext,
|
||||
): Promise<void> {
|
||||
const distinctId = await getDistinctId();
|
||||
const assignment = await fetchAssignment(distinctId);
|
||||
if (!assignment) {
|
||||
return;
|
||||
}
|
||||
await context.globalState.update(COHORT_STATE_KEY, assignment);
|
||||
}
|
||||
|
||||
/**
|
||||
* The loader's own decision event. Distinct from the AUTHORITATIVE
|
||||
* `extension.rollout.bundle_activated` event, which the activated bundle
|
||||
* itself captures through its variant-attributed telemetry pipeline (the
|
||||
* loader triggers it via the bundle's reportRolloutActivation export — see
|
||||
* src/extension.ts). This event carries the loader-side metadata that event
|
||||
* can't (override source, launch cadence, loader version) and is the only
|
||||
* signal left when BOTH bundles fail to activate.
|
||||
*/
|
||||
export const LOADER_DECISION_EVENT = "extension.rollout.loader_decision";
|
||||
|
||||
/**
|
||||
* Report the loader's bundle decision (and whether it was a crash fallback).
|
||||
* Feature-flag evaluation is always allowed (matching the extension's
|
||||
* FeatureFlagsService), but event capture respects the user's telemetry
|
||||
* opt-out and VS Code's global telemetry setting.
|
||||
*/
|
||||
export async function reportLoaderDecision(
|
||||
context: vscode.ExtensionContext,
|
||||
bundle: Bundle,
|
||||
options: {
|
||||
fallback: boolean;
|
||||
/** Bundle the loader originally decided on; differs from `bundle` on fallback. */
|
||||
attemptedBundle?: Bundle;
|
||||
/** Both bundles threw — nothing activated, and no bundle telemetry exists. */
|
||||
doubleFailure?: boolean;
|
||||
errorMessage?: string;
|
||||
/** Time since the previous loader activation on this machine, if known. */
|
||||
msSinceLastActivation?: number;
|
||||
/** Whether an env var or user setting forced this bundle. */
|
||||
override?: "env" | "setting";
|
||||
},
|
||||
): Promise<void> {
|
||||
if (!POSTHOG_API_KEY) {
|
||||
return;
|
||||
}
|
||||
const telemetrySetting = await readSharedGlobalStateKey("telemetrySetting");
|
||||
if (telemetrySetting === "disabled" || !vscode.env.isTelemetryEnabled) {
|
||||
return;
|
||||
}
|
||||
const distinctId = await getDistinctId();
|
||||
await postJson(`${POSTHOG_HOST}/capture/`, {
|
||||
api_key: POSTHOG_API_KEY,
|
||||
event: LOADER_DECISION_EVENT,
|
||||
distinct_id: distinctId,
|
||||
properties: {
|
||||
bundle,
|
||||
attempted_bundle: options.attemptedBundle ?? bundle,
|
||||
fallback: options.fallback,
|
||||
double_failure: options.doubleFailure,
|
||||
error_message: options.errorMessage,
|
||||
// Launch-cadence distribution: how long promotions take to reach real
|
||||
// windows tells us how fast the rollout percentage can safely be dialed.
|
||||
ms_since_last_activation: options.msSinceLastActivation,
|
||||
override: options.override,
|
||||
loader_version: context.extension.packageJSON?.version,
|
||||
// Separates nightly traffic from the (future) stable combined VSIX.
|
||||
extension_name: context.extension.packageJSON?.name,
|
||||
vscode_version: vscode.version,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import path from "node:path";
|
||||
import * as vscode from "vscode";
|
||||
import type { Bundle } from "./cohort";
|
||||
|
||||
/**
|
||||
* Wrap the real ExtensionContext so a bundle living under `<vsix root>/<sub>/`
|
||||
* resolves extension-root-relative resources (webview-ui build, walkthrough
|
||||
* assets, bundled codicons, ...) from its own subtree, without either codebase
|
||||
* knowing it was relocated.
|
||||
*
|
||||
* Only install-root properties are redirected. Storage-related properties
|
||||
* (globalState, workspaceState, secrets, globalStorageUri, storageUri, logUri)
|
||||
* intentionally pass through untouched: both bundles must keep sharing the
|
||||
* exact storage the standalone extension used, so user state survives cohort
|
||||
* changes and VSIX upgrades.
|
||||
*/
|
||||
export function scopedContext(
|
||||
context: vscode.ExtensionContext,
|
||||
sub: Bundle,
|
||||
): vscode.ExtensionContext {
|
||||
const extensionUri = vscode.Uri.joinPath(context.extensionUri, sub);
|
||||
const extensionPath = extensionUri.fsPath;
|
||||
|
||||
const scopedExtension = new Proxy(context.extension, {
|
||||
get(target, prop, _receiver) {
|
||||
if (prop === "extensionUri") {
|
||||
return extensionUri;
|
||||
}
|
||||
if (prop === "extensionPath") {
|
||||
return extensionPath;
|
||||
}
|
||||
const value = Reflect.get(target, prop, target);
|
||||
return typeof value === "function" ? value.bind(target) : value;
|
||||
},
|
||||
});
|
||||
|
||||
const overrides = new Map<PropertyKey, unknown>([
|
||||
["extensionUri", extensionUri],
|
||||
["extensionPath", extensionPath],
|
||||
[
|
||||
"asAbsolutePath",
|
||||
(relativePath: string) => path.join(extensionPath, relativePath),
|
||||
],
|
||||
["extension", scopedExtension],
|
||||
]);
|
||||
|
||||
return new Proxy(context, {
|
||||
get(target, prop, _receiver) {
|
||||
if (overrides.has(prop)) {
|
||||
return overrides.get(prop);
|
||||
}
|
||||
const value = Reflect.get(target, prop, target);
|
||||
return typeof value === "function" ? value.bind(target) : value;
|
||||
},
|
||||
}) as vscode.ExtensionContext;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "CommonJS",
|
||||
"moduleResolution": "node",
|
||||
"lib": ["ES2022"],
|
||||
"types": ["node", "vscode"],
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": ["src/**/*.test.ts"]
|
||||
}
|
||||
@@ -63,7 +63,7 @@ service ModelsService {
|
||||
rpc readProviderConfig(StringRequest) returns (ProviderConfigResponse);
|
||||
// Writes provider configuration fields and returns redacted effective configuration
|
||||
rpc writeProviderConfig(WriteProviderConfigRequest) returns (ProviderConfigResponse);
|
||||
// Commits a mode-specific model selection atomically with its model metadata
|
||||
// Commits a mode-specific model ID with optional user-authored metadata overrides
|
||||
rpc commitModelSelection(CommitModelSelectionRequest) returns (Empty);
|
||||
}
|
||||
|
||||
@@ -122,6 +122,36 @@ message OpenRouterModelInfo {
|
||||
optional ApiFormat api_format = 16;
|
||||
}
|
||||
|
||||
// User-authored per-model metadata stored in models.json.
|
||||
//
|
||||
// Semantics:
|
||||
// - `capabilities` accepts only the SDK ModelCapability values (e.g.
|
||||
// "images", "tools", "prompt-cache", "reasoning", "files"); unknown
|
||||
// strings are silently dropped by the host. The array is additive over
|
||||
// the base metadata; the explicit supports_* booleans win when both are
|
||||
// present.
|
||||
// - `is_r1_format_required` is a legacy alias that forces the R1 chat
|
||||
// format only when true; `api_format` is canonical.
|
||||
// - Invalid numbers (non-positive token limits, negative prices or
|
||||
// temperature, non-finite values) are silently discarded, not rejected.
|
||||
message ModelOverrides {
|
||||
optional string name = 1;
|
||||
optional int64 max_tokens = 2;
|
||||
optional int64 context_window = 3;
|
||||
optional int64 max_input_tokens = 4;
|
||||
repeated string capabilities = 5;
|
||||
optional bool supports_vision = 6;
|
||||
optional bool supports_attachments = 7;
|
||||
optional bool supports_reasoning = 8;
|
||||
optional double input_price = 9;
|
||||
optional double output_price = 10;
|
||||
optional double cache_reads_price = 11;
|
||||
optional double cache_writes_price = 12;
|
||||
optional double temperature = 13;
|
||||
optional ApiFormat api_format = 14;
|
||||
optional bool is_r1_format_required = 15;
|
||||
}
|
||||
|
||||
// Shared response message for model information
|
||||
message OpenRouterCompatibleModelInfo {
|
||||
map<string, OpenRouterModelInfo> models = 1;
|
||||
@@ -222,12 +252,16 @@ message ProviderConfigResponse {
|
||||
optional CommittedModelSelection act_selection = 11;
|
||||
optional AwsProviderConfig aws = 12;
|
||||
optional GcpProviderConfig gcp = 13;
|
||||
// Provider-level context window (providers.json `contextWindow`). Used by
|
||||
// bring-your-own-model providers (e.g. Ollama, where it maps to num_ctx).
|
||||
optional int32 context_window = 14;
|
||||
}
|
||||
|
||||
message CommittedModelSelection {
|
||||
string provider_id = 1;
|
||||
string model_id = 2;
|
||||
OpenRouterModelInfo model_info = 3;
|
||||
optional ModelOverrides overrides = 4;
|
||||
}
|
||||
|
||||
message ProviderReasoningPatch {
|
||||
@@ -249,6 +283,8 @@ message WriteProviderConfigPatch {
|
||||
optional bool clear_headers = 10;
|
||||
optional AwsProviderConfig aws = 11;
|
||||
optional GcpProviderConfig gcp = 12;
|
||||
// Provider-level context window; 0 clears the setting.
|
||||
optional int32 context_window = 13;
|
||||
}
|
||||
|
||||
message WriteProviderConfigRequest {
|
||||
@@ -257,10 +293,18 @@ message WriteProviderConfigRequest {
|
||||
}
|
||||
|
||||
message CommitModelSelectionRequest {
|
||||
// Field 4 carried `OpenRouterModelInfo model_info` in earlier releases.
|
||||
// Reusing the number with a different message type mis-decodes on version
|
||||
// skew, so the retired field stays reserved.
|
||||
reserved 4;
|
||||
reserved "model_info";
|
||||
string provider_id = 1;
|
||||
string mode = 2;
|
||||
string model_id = 3;
|
||||
OpenRouterModelInfo model_info = 4;
|
||||
// Tri-state: ABSENT leaves the model's stored overrides unchanged, an
|
||||
// explicitly EMPTY message clears them, and a populated message replaces
|
||||
// them wholesale (no per-field merge).
|
||||
optional ModelOverrides overrides = 5;
|
||||
}
|
||||
|
||||
message ClineRecommendedModel {
|
||||
|
||||
@@ -16,6 +16,9 @@ service TaskService {
|
||||
rpc cancelQueuedPrompt(StringRequest) returns (Empty);
|
||||
// Cancels the currently running background command
|
||||
rpc cancelBackgroundCommand(EmptyRequest) returns (Empty);
|
||||
// Detaches the running foreground terminal command ("Proceed While Running"):
|
||||
// the agent receives the partial output and a log file path for the rest.
|
||||
rpc proceedWhileRunningCommand(EmptyRequest) returns (Empty);
|
||||
// Clears the current task
|
||||
rpc clearTask(EmptyRequest) returns (Empty);
|
||||
// Gets the total size of all tasks
|
||||
|
||||
+12
@@ -56,4 +56,16 @@ describe("parseYamlFrontmatter", () => {
|
||||
expect(result.data).to.deep.equal({ count: 42, enabled: true, tags: ["a", "b"] })
|
||||
expect(result.body.trim()).to.equal("Content")
|
||||
})
|
||||
|
||||
// Regression test for https://github.com/cline/cline/issues/12151
|
||||
// A leading UTF-8 BOM (e.g. saved by Windows Notepad's "UTF-8 with BOM" encoding) must not
|
||||
// prevent frontmatter from being recognized.
|
||||
it("parses frontmatter correctly when the content has a leading UTF-8 BOM", () => {
|
||||
const input = `\uFEFF---\nname: my-skill\ndescription: A test skill\n---\n# my-skill\nThis is a test skill.`
|
||||
const result = parseYamlFrontmatter(input)
|
||||
expect(result.hadFrontmatter).to.equal(true)
|
||||
expect(result.parseError).to.equal(undefined)
|
||||
expect(result.data).to.deep.equal({ name: "my-skill", description: "A test skill" })
|
||||
expect(result.body.trim()).to.equal("# my-skill\nThis is a test skill.")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -139,6 +139,34 @@ Instructions here`)
|
||||
expect(skills[0].source).to.equal("global")
|
||||
})
|
||||
|
||||
// Regression test for https://github.com/cline/cline/issues/12151:
|
||||
// SKILL.md files saved with a UTF-8 BOM (e.g. by Windows Notepad's "UTF-8 with BOM"
|
||||
// encoding) were silently skipped because the frontmatter regex required "---" at the
|
||||
// very start of the file and never accounted for the leading \uFEFF byte sequence.
|
||||
it("should discover skills whose SKILL.md starts with a UTF-8 BOM", async () => {
|
||||
const skillDir = path.join(GLOBAL_SKILLS_DIR, "my-skill")
|
||||
const skillMdPath = path.join(skillDir, "SKILL.md")
|
||||
|
||||
fileExistsStub.withArgs(GLOBAL_SKILLS_DIR).resolves(true)
|
||||
fileExistsStub.withArgs(skillMdPath).resolves(true)
|
||||
isDirectoryStub.withArgs(GLOBAL_SKILLS_DIR).resolves(true)
|
||||
readdirStub.withArgs(GLOBAL_SKILLS_DIR).resolves(["my-skill"])
|
||||
statStub.withArgs(skillDir).resolves({ isDirectory: () => true })
|
||||
readFileStub.withArgs(skillMdPath, "utf-8").resolves(`\uFEFF---
|
||||
name: my-skill
|
||||
description: A test skill
|
||||
---
|
||||
# my-skill
|
||||
This is a test skill.`)
|
||||
|
||||
const skills = await discoverSkills(TEST_CWD)
|
||||
|
||||
expect(skills).to.have.lengthOf(1)
|
||||
expect(skills[0].name).to.equal("my-skill")
|
||||
expect(skills[0].description).to.equal("A test skill")
|
||||
expect(skills[0].source).to.equal("global")
|
||||
})
|
||||
|
||||
it("should discover skills from project .clinerules/skills directory", async () => {
|
||||
const projectSkillsDir = path.join(TEST_CWD, ".clinerules", "skills")
|
||||
const skillDir = path.join(projectSkillsDir, "explaining-code")
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { stripUtf8Bom } from "@cline/shared"
|
||||
import * as yaml from "js-yaml"
|
||||
|
||||
export type FrontmatterParseResult = {
|
||||
@@ -35,11 +36,16 @@ export type FrontmatterParseResult = {
|
||||
* - If no frontmatter exists, returns data={} and body=original markdown.
|
||||
*/
|
||||
export function parseYamlFrontmatter(markdown: string): FrontmatterParseResult {
|
||||
// Strip a leading UTF-8 BOM (e.g. added by Windows Notepad's "UTF-8 with BOM" encoding),
|
||||
// which Node's `utf-8` decoding does not strip on its own. Without this the frontmatter
|
||||
// regex below never matches a file that starts with "\uFEFF---" (see cline/cline#12151).
|
||||
const normalizedMarkdown = stripUtf8Bom(markdown)
|
||||
|
||||
const frontmatterRegex = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/
|
||||
const match = markdown.match(frontmatterRegex)
|
||||
const match = normalizedMarkdown.match(frontmatterRegex)
|
||||
|
||||
if (!match) {
|
||||
return { data: {}, body: markdown, hadFrontmatter: false }
|
||||
return { data: {}, body: normalizedMarkdown, hadFrontmatter: false }
|
||||
}
|
||||
|
||||
const [, yamlContent, body] = match
|
||||
@@ -48,6 +54,6 @@ export function parseYamlFrontmatter(markdown: string): FrontmatterParseResult {
|
||||
return { data, body, hadFrontmatter: true }
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
return { data: {}, body: markdown, hadFrontmatter: true, parseError: message }
|
||||
return { data: {}, body: normalizedMarkdown, hadFrontmatter: true, parseError: message }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"
|
||||
import type { EffectiveProviderConfig, ProviderCatalog, ProviderConfigStore } from "@/sdk/model-catalog/contracts"
|
||||
import { computeConfigFingerprint } from "@/sdk/model-catalog/fingerprint"
|
||||
import { parseProviderId } from "@/sdk/model-catalog/provider-id"
|
||||
import { ApiFormat, OpenRouterModelInfo } from "@/shared/proto/cline/models"
|
||||
import { ApiFormat, ModelOverrides } from "@/shared/proto/cline/models"
|
||||
import type { ProviderCatalogController } from "../providerCatalogShared"
|
||||
|
||||
type TestStateManager = {
|
||||
@@ -153,6 +153,22 @@ describe("provider model catalog handlers", () => {
|
||||
baseUrl: "https://api.example.com/v1",
|
||||
auth: { accessToken: "SECRET_SENTINEL_ACCESS", refreshToken: "SECRET_SENTINEL_REFRESH", accountId: "acct-1" },
|
||||
})
|
||||
vi.mocked(store.readSelection).mockImplementation((_providerId, mode) =>
|
||||
mode === "act"
|
||||
? {
|
||||
providerId,
|
||||
modelId: "custom-model",
|
||||
overrides: {
|
||||
capabilities: ["tools", "custom-capability"],
|
||||
inputPrice: 1.25,
|
||||
supportsVision: false,
|
||||
temperature: 0.3,
|
||||
apiFormat: ApiFormat.OPENAI_RESPONSES,
|
||||
},
|
||||
modelInfo: { name: "Custom model", contextWindow: 64_000, supportsPromptCache: false },
|
||||
}
|
||||
: undefined,
|
||||
)
|
||||
const controller = makeController(store, makeCatalog())
|
||||
|
||||
const response = await readProviderConfig(controller, { value: "cline" })
|
||||
@@ -165,10 +181,24 @@ describe("provider model catalog handlers", () => {
|
||||
hasRefreshToken: true,
|
||||
accountId: "acct-1",
|
||||
})
|
||||
expect(JSON.stringify(response)).not.toContain("SECRET_SENTINEL")
|
||||
expect(response.actSelection).toMatchObject({
|
||||
providerId: "cline",
|
||||
modelId: "custom-model",
|
||||
modelInfo: { name: "Custom model", contextWindow: 64_000 },
|
||||
overrides: {
|
||||
capabilities: ["tools", "custom-capability"],
|
||||
inputPrice: 1.25,
|
||||
supportsVision: false,
|
||||
temperature: 0.3,
|
||||
apiFormat: ApiFormat.OPENAI_RESPONSES,
|
||||
},
|
||||
})
|
||||
expect(JSON.stringify(response)).not.toContain("SECRET_SENTINEL_API_KEY")
|
||||
expect(JSON.stringify(response)).not.toContain("SECRET_SENTINEL_ACCESS")
|
||||
expect(JSON.stringify(response)).not.toContain("SECRET_SENTINEL_REFRESH")
|
||||
})
|
||||
|
||||
it("writeProviderConfig writes a patch and returns redacted updated config", async () => {
|
||||
it("writeProviderConfig writes a patch and returns a redacted response", async () => {
|
||||
const { writeProviderConfig } = await import("../writeProviderConfig")
|
||||
const providerId = parseProviderId("ollama")
|
||||
const updatedConfig: EffectiveProviderConfig = {
|
||||
@@ -188,8 +218,10 @@ describe("provider model catalog handlers", () => {
|
||||
apiKey: "SECRET_SENTINEL_OLLAMA",
|
||||
baseUrl: "http://localhost:11434/v1",
|
||||
})
|
||||
expect(response.apiKeyLength).toBe("SECRET_SENTINEL_OLLAMA".length)
|
||||
expect(JSON.stringify(response)).not.toContain("SECRET_SENTINEL")
|
||||
expect(response).toMatchObject({
|
||||
apiKeyLength: "SECRET_SENTINEL_OLLAMA".length,
|
||||
})
|
||||
expect(JSON.stringify(response)).not.toContain("SECRET_SENTINEL_OLLAMA")
|
||||
})
|
||||
|
||||
it("writeProviderConfig can explicitly clear headers", async () => {
|
||||
@@ -210,7 +242,7 @@ describe("provider model catalog handlers", () => {
|
||||
expect(store.write).toHaveBeenCalledWith(providerId, { headers: {} })
|
||||
})
|
||||
|
||||
it("commitModelSelection validates mode and commits the full selection envelope", async () => {
|
||||
it("commitModelSelection validates mode and commits model settings", async () => {
|
||||
const { commitModelSelection } = await import("../commitModelSelection")
|
||||
const providerId = parseProviderId("deepseek")
|
||||
const store = makeStore({ providerId })
|
||||
@@ -224,22 +256,20 @@ describe("provider model catalog handlers", () => {
|
||||
providerId: "deepseek",
|
||||
mode: "act",
|
||||
modelId: "deepseek-v4-flash",
|
||||
modelInfo: OpenRouterModelInfo.create({
|
||||
overrides: ModelOverrides.create({
|
||||
name: "DeepSeek V4 Flash",
|
||||
contextWindow: 456,
|
||||
supportsPromptCache: true,
|
||||
apiFormat: ApiFormat.OPENAI_CHAT,
|
||||
capabilities: ["prompt-cache"],
|
||||
}),
|
||||
})
|
||||
|
||||
expect(store.commitSelection).toHaveBeenCalledWith(providerId, "act", {
|
||||
providerId,
|
||||
modelId: "deepseek-v4-flash",
|
||||
modelInfo: expect.objectContaining({
|
||||
overrides: expect.objectContaining({
|
||||
name: "DeepSeek V4 Flash",
|
||||
contextWindow: 456,
|
||||
supportsPromptCache: true,
|
||||
apiFormat: ApiFormat.OPENAI_CHAT,
|
||||
capabilities: ["prompt-cache"],
|
||||
}),
|
||||
})
|
||||
expect(stateManager.setGlobalStateBatch).toHaveBeenCalledWith({
|
||||
@@ -249,6 +279,49 @@ describe("provider model catalog handlers", () => {
|
||||
expect(stateManager.flushPendingState).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
// The overrides field is tri-state: absent preserves the model's stored
|
||||
// overrides, an explicitly empty message clears them, and a populated
|
||||
// message replaces them. The two boundary cases are pinned here because
|
||||
// the webview relies on both (see useProviderConfig.test.ts).
|
||||
it("commitModelSelection maps an ABSENT overrides field to undefined (preserve stored overrides)", async () => {
|
||||
const { commitModelSelection } = await import("../commitModelSelection")
|
||||
const providerId = parseProviderId("deepseek")
|
||||
const store = makeStore({ providerId })
|
||||
const controller = makeController(store, makeCatalog())
|
||||
|
||||
await commitModelSelection(controller, {
|
||||
providerId: "deepseek",
|
||||
mode: "act",
|
||||
modelId: "deepseek-v4-flash",
|
||||
})
|
||||
|
||||
expect(store.commitSelection).toHaveBeenCalledWith(providerId, "act", {
|
||||
providerId,
|
||||
modelId: "deepseek-v4-flash",
|
||||
overrides: undefined,
|
||||
})
|
||||
})
|
||||
|
||||
it("commitModelSelection maps an EMPTY overrides message to an empty object (clear stored overrides)", async () => {
|
||||
const { commitModelSelection } = await import("../commitModelSelection")
|
||||
const providerId = parseProviderId("deepseek")
|
||||
const store = makeStore({ providerId })
|
||||
const controller = makeController(store, makeCatalog())
|
||||
|
||||
await commitModelSelection(controller, {
|
||||
providerId: "deepseek",
|
||||
mode: "act",
|
||||
modelId: "deepseek-v4-flash",
|
||||
overrides: ModelOverrides.create({}),
|
||||
})
|
||||
|
||||
expect(store.commitSelection).toHaveBeenCalledWith(providerId, "act", {
|
||||
providerId,
|
||||
modelId: "deepseek-v4-flash",
|
||||
overrides: {},
|
||||
})
|
||||
})
|
||||
|
||||
it("commitModelSelection reports provider changes when config is initialized", async () => {
|
||||
const { commitModelSelection } = await import("../commitModelSelection")
|
||||
const providerId = parseProviderId("deepseek")
|
||||
@@ -268,10 +341,7 @@ describe("provider model catalog handlers", () => {
|
||||
providerId: "deepseek",
|
||||
mode: "act",
|
||||
modelId: "deepseek-v4-flash",
|
||||
modelInfo: OpenRouterModelInfo.create({
|
||||
name: "DeepSeek V4 Flash",
|
||||
apiFormat: ApiFormat.OPENAI_CHAT,
|
||||
}),
|
||||
overrides: ModelOverrides.create({ name: "DeepSeek V4 Flash" }),
|
||||
})
|
||||
|
||||
expect(handleApiConfigurationChanged).toHaveBeenCalledWith({}, { actModeApiProvider: "deepseek" })
|
||||
@@ -289,7 +359,7 @@ describe("provider model catalog handlers", () => {
|
||||
providerId: "deepseek",
|
||||
mode: "invalid",
|
||||
modelId: "deepseek-v4-flash",
|
||||
modelInfo: OpenRouterModelInfo.create({ supportsPromptCache: true }),
|
||||
overrides: ModelOverrides.create({ capabilities: ["prompt-cache"] }),
|
||||
}),
|
||||
).rejects.toThrow('mode must be "plan" or "act"')
|
||||
expect(store.commitSelection).not.toHaveBeenCalled()
|
||||
|
||||
@@ -75,7 +75,6 @@ describe("provider model catalog backend smoke", () => {
|
||||
providerId: "deepseek",
|
||||
mode: "act",
|
||||
modelId,
|
||||
modelInfo,
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -3,12 +3,14 @@ import type {
|
||||
EffectiveProviderConfig,
|
||||
Mode,
|
||||
ModelSelection,
|
||||
ModelSelectionOverrides,
|
||||
ProviderCatalog,
|
||||
ProviderConfigPatch,
|
||||
ProviderConfigStore,
|
||||
ProviderId,
|
||||
ProviderListing,
|
||||
ProviderModelsResult,
|
||||
ResolvedModelSelection,
|
||||
} from "@/sdk/model-catalog/contracts"
|
||||
import { parseProviderId } from "@/sdk/model-catalog/provider-id"
|
||||
import {
|
||||
@@ -17,13 +19,15 @@ import {
|
||||
CommitModelSelectionRequest,
|
||||
CommittedModelSelection,
|
||||
GcpProviderConfig,
|
||||
ModelOverrides as ModelOverridesProto,
|
||||
OpenRouterModelInfo,
|
||||
ProviderConfigResponse,
|
||||
ProviderListing as ProviderListingProto,
|
||||
ProviderModelsResponse,
|
||||
WriteProviderConfigPatch,
|
||||
} from "@/shared/proto/cline/models"
|
||||
import { fromProtobufModelInfo, toProtobufModelInfo } from "@/shared/proto-conversions/models/typeConversion"
|
||||
import { fromProtobufModelOverrides, toProtobufModelOverrides } from "@/shared/proto-conversions/models/modelOverrides"
|
||||
import { toProtobufModelInfo } from "@/shared/proto-conversions/models/typeConversion"
|
||||
import type { GlobalStateAndSettings } from "@/shared/storage/state-keys"
|
||||
|
||||
export interface ProviderCatalogController {
|
||||
@@ -94,7 +98,11 @@ function toProtobufModels(models: ReadonlyMap<string, ModelInfo>): Record<string
|
||||
return result
|
||||
}
|
||||
|
||||
function toCommittedModelSelectionProto(selection: ModelSelection | undefined): CommittedModelSelection | undefined {
|
||||
function toModelOverridesProto(overrides: ModelSelectionOverrides | undefined): ModelOverridesProto | undefined {
|
||||
return overrides ? toProtobufModelOverrides(overrides) : undefined
|
||||
}
|
||||
|
||||
function toCommittedModelSelectionProto(selection: ResolvedModelSelection | undefined): CommittedModelSelection | undefined {
|
||||
if (!selection) {
|
||||
return undefined
|
||||
}
|
||||
@@ -102,6 +110,7 @@ function toCommittedModelSelectionProto(selection: ModelSelection | undefined):
|
||||
providerId: selection.providerId,
|
||||
modelId: selection.modelId,
|
||||
modelInfo: toProtobufModelInfo(selection.modelInfo),
|
||||
overrides: toModelOverridesProto(selection.overrides),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -199,6 +208,7 @@ export function toRedactedProviderConfigResponse(
|
||||
actSelection: toCommittedModelSelectionProto(store?.readSelection(config.providerId, "act")),
|
||||
aws: toRedactedAwsProviderConfigProto(config.aws),
|
||||
gcp: toRedactedGcpProviderConfigProto(config.gcp),
|
||||
contextWindow: config.contextWindow,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -218,6 +228,10 @@ export function toProviderConfigPatch(protoPatch: WriteProviderConfigPatch | und
|
||||
...(protoPatch.apiLine !== undefined ? { apiLine: protoPatch.apiLine } : {}),
|
||||
...(protoPatch.aws !== undefined ? { aws: toAwsProviderConfigPatch(protoPatch) } : {}),
|
||||
...(protoPatch.gcp !== undefined ? { gcp: toGcpProviderConfigPatch(protoPatch) } : {}),
|
||||
// A zero context window over the wire means "clear the setting".
|
||||
...(protoPatch.contextWindow !== undefined
|
||||
? { contextWindow: protoPatch.contextWindow > 0 ? protoPatch.contextWindow : null }
|
||||
: {}),
|
||||
...(protoPatch.accessToken !== undefined || protoPatch.refreshToken !== undefined || protoPatch.accountId !== undefined
|
||||
? {
|
||||
auth: {
|
||||
@@ -241,17 +255,18 @@ export function toProviderConfigPatch(protoPatch: WriteProviderConfigPatch | und
|
||||
}
|
||||
}
|
||||
|
||||
function toSelectionOverrides(overrides: ModelOverridesProto | undefined): ModelSelectionOverrides | undefined {
|
||||
return fromProtobufModelOverrides(overrides)
|
||||
}
|
||||
|
||||
export function toModelSelection(request: CommitModelSelectionRequest, providerId: ProviderId): ModelSelection {
|
||||
const modelId = request.modelId.trim()
|
||||
if (!modelId) {
|
||||
throw new Error("model_id is required")
|
||||
}
|
||||
if (!request.modelInfo) {
|
||||
throw new Error("model_info is required")
|
||||
}
|
||||
return {
|
||||
providerId,
|
||||
modelId,
|
||||
modelInfo: fromProtobufModelInfo(request.modelInfo),
|
||||
overrides: toSelectionOverrides(request.overrides),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,10 +12,11 @@ import { type ProviderCatalogController, parseProviderIdRequest } from "./provid
|
||||
* Resolution order:
|
||||
*
|
||||
* 1. Committed selection — the user's most-recently-chosen plan/act
|
||||
* selection in the provider config store. This is the source of
|
||||
* truth for dynamic-list providers (openrouter, openai-compatible,
|
||||
* ollama, lmstudio, requesty, litellm, …) where the picker writes
|
||||
* the live `ModelInfo` into the selection when the user commits.
|
||||
* model ID resolved against SDK catalog metadata, the picker's state
|
||||
* snapshot, and stored overrides by the provider config store. A
|
||||
* selection whose metadata is pure fallback fabrication (no catalog or
|
||||
* state base, no overrides) is deferred behind the catalog steps below
|
||||
* and only returned as a last resort.
|
||||
*
|
||||
* 2. Catalog peek — a non-fetching look-up of the catalog cache for
|
||||
* the provider's current effective config fingerprint. Hits when
|
||||
@@ -41,23 +42,24 @@ export async function resolveModelInfo(
|
||||
const requestedModelId = request.modelId?.trim() || ""
|
||||
|
||||
const store = controller.getProviderConfigStore()
|
||||
// A committed selection whose metadata is pure fallback fabrication (no
|
||||
// catalog/state base and no user overrides) must not shadow the live
|
||||
// catalog below; it is kept only as a last resort before "unknown".
|
||||
let fallbackSelection: ReturnType<typeof store.readSelection>
|
||||
if (requestedModelId) {
|
||||
const actSelection = store.readSelection(providerId, "act")
|
||||
if (actSelection?.modelId === requestedModelId) {
|
||||
for (const mode of ["act", "plan"] as const) {
|
||||
const selection = store.readSelection(providerId, mode)
|
||||
if (selection?.modelId !== requestedModelId) {
|
||||
continue
|
||||
}
|
||||
if (selection.modelInfoSource === "fallback" && !selection.overrides) {
|
||||
fallbackSelection ??= selection
|
||||
continue
|
||||
}
|
||||
return ResolveModelInfoResponse.create({
|
||||
providerId,
|
||||
modelId: actSelection.modelId,
|
||||
modelInfo: toProtobufModelInfo(actSelection.modelInfo),
|
||||
source: "committed-selection",
|
||||
})
|
||||
}
|
||||
|
||||
const planSelection = store.readSelection(providerId, "plan")
|
||||
if (planSelection?.modelId === requestedModelId) {
|
||||
return ResolveModelInfoResponse.create({
|
||||
providerId,
|
||||
modelId: planSelection.modelId,
|
||||
modelInfo: toProtobufModelInfo(planSelection.modelInfo),
|
||||
modelId: selection.modelId,
|
||||
modelInfo: toProtobufModelInfo(selection.modelInfo),
|
||||
source: "committed-selection",
|
||||
})
|
||||
}
|
||||
@@ -74,7 +76,9 @@ export async function resolveModelInfo(
|
||||
const cached = catalog.peekModels(providerId)
|
||||
if (cached?.ok) {
|
||||
const hit = pickFromCatalog(cached, requestedModelId, allowCustomModelIds)
|
||||
if (hit) {
|
||||
// A default-model substitution answers a question about a different
|
||||
// model; the committed selection, even fallback-grade, is closer.
|
||||
if (hit && (hit.matchedRequested || !fallbackSelection)) {
|
||||
return ResolveModelInfoResponse.create({
|
||||
providerId,
|
||||
modelId: hit.modelId,
|
||||
@@ -90,7 +94,7 @@ export async function resolveModelInfo(
|
||||
const resolved = await catalog.resolveModels(providerId).catch(() => undefined)
|
||||
if (resolved?.ok) {
|
||||
const hit = pickFromCatalog(resolved, requestedModelId, allowCustomModelIds)
|
||||
if (hit) {
|
||||
if (hit && (hit.matchedRequested || !fallbackSelection)) {
|
||||
return ResolveModelInfoResponse.create({
|
||||
providerId,
|
||||
modelId: hit.modelId,
|
||||
@@ -100,6 +104,15 @@ export async function resolveModelInfo(
|
||||
}
|
||||
}
|
||||
|
||||
if (fallbackSelection) {
|
||||
return ResolveModelInfoResponse.create({
|
||||
providerId,
|
||||
modelId: fallbackSelection.modelId,
|
||||
modelInfo: toProtobufModelInfo(fallbackSelection.modelInfo),
|
||||
source: "committed-selection",
|
||||
})
|
||||
}
|
||||
|
||||
return ResolveModelInfoResponse.create({
|
||||
providerId,
|
||||
modelId: requestedModelId,
|
||||
|
||||
@@ -25,6 +25,7 @@ export async function getStateToPostToWebview(controller: {
|
||||
mcpHub?: any
|
||||
backgroundCommandRunning?: boolean
|
||||
backgroundCommandTaskId?: string
|
||||
foregroundCommandRunning?: boolean
|
||||
workspaceManager?: any
|
||||
checkpointRestoreInput?: ExtensionState["checkpointRestoreInput"]
|
||||
}): Promise<ExtensionState> {
|
||||
@@ -157,6 +158,7 @@ export async function getStateToPostToWebview(controller: {
|
||||
favoritedModelIds,
|
||||
backgroundCommandRunning: controller.backgroundCommandRunning ?? false,
|
||||
backgroundCommandTaskId: controller.backgroundCommandTaskId,
|
||||
foregroundCommandRunning: controller.foregroundCommandRunning ?? false,
|
||||
workspaceRoots: controller.workspaceManager?.getRoots?.() ?? [],
|
||||
primaryRootIndex: controller.workspaceManager?.getPrimaryIndex?.() ?? 0,
|
||||
isMultiRootWorkspace: (controller.workspaceManager?.getRoots?.()?.length ?? 0) > 1,
|
||||
|
||||
@@ -239,7 +239,10 @@ export async function updateSettings(controller: Controller, request: UpdateSett
|
||||
controller.stateManager.setGlobalState("defaultTerminalProfile", request.defaultTerminalProfile)
|
||||
// Update the live terminal manager so new terminals use the new profile.
|
||||
// Existing terminals are left open — they're keyed by effective shell
|
||||
// and reused when compatible, or skipped when not.
|
||||
// and reused when compatible, or skipped when not. No session rebuild
|
||||
// is needed: the run_commands tool re-reads the profile each time a
|
||||
// model request is built, so the description and execution both pick
|
||||
// up the new shell at the next request boundary.
|
||||
controller.terminalManager?.setDefaultTerminalProfile(request.defaultTerminalProfile)
|
||||
}
|
||||
|
||||
|
||||
@@ -192,7 +192,10 @@ export async function updateSettingsCli(controller: Controller, request: UpdateS
|
||||
controller.stateManager.setGlobalState("defaultTerminalProfile", defaultTerminalProfile)
|
||||
// Update the live terminal manager so new terminals use the new profile.
|
||||
// Existing terminals are left open — they're keyed by effective shell
|
||||
// and reused when compatible, or skipped when not.
|
||||
// and reused when compatible, or skipped when not. No session rebuild
|
||||
// is needed: the run_commands tool re-reads the profile each time a
|
||||
// model request is built, so the description and execution both pick
|
||||
// up the new shell at the next request boundary.
|
||||
controller.terminalManager?.setDefaultTerminalProfile(defaultTerminalProfile)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { Empty, EmptyRequest } from "@shared/proto/cline/common"
|
||||
import { Controller } from ".."
|
||||
|
||||
/**
|
||||
* "Proceed While Running": detach the in-flight foreground terminal command(s)
|
||||
* so the agent turn continues with the partial output while the commands keep
|
||||
* running in the user's terminal, streaming further output to a log file.
|
||||
*/
|
||||
export async function proceedWhileRunningCommand(controller: Controller, _request: EmptyRequest): Promise<Empty> {
|
||||
const controllerWithProceed = controller as Controller & {
|
||||
proceedWhileRunningCommand: () => Promise<void>
|
||||
}
|
||||
await controllerWithProceed.proceedWhileRunningCommand()
|
||||
return Empty.create()
|
||||
}
|
||||
@@ -15,8 +15,9 @@ Designed to be driven from an agentic loop via `curl` commands.
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
# Terminal 1: Start the debug harness server
|
||||
bun src/dev/debug-harness/server.ts --auto-launch --skip-build
|
||||
# Terminal 1: Start the debug harness server.
|
||||
# Run with node, NOT bun — Playwright's Electron launch times out under bun.
|
||||
node src/dev/debug-harness/server.ts --auto-launch --skip-build
|
||||
|
||||
# Terminal 2: Interact via curl
|
||||
curl localhost:19229/api -d '{"method":"status"}'
|
||||
@@ -27,7 +28,7 @@ curl localhost:19229/api -d '{"method":"ui.screenshot"}'
|
||||
## Server Options
|
||||
|
||||
```
|
||||
bun src/dev/debug-harness/server.ts [options]
|
||||
node src/dev/debug-harness/server.ts [options]
|
||||
|
||||
Options:
|
||||
--skip-build Skip building extension/webview (use existing dist/)
|
||||
@@ -42,7 +43,7 @@ Options:
|
||||
```bash
|
||||
# This builds protos, extension (unminified+sourcemaps), webview (unminified+sourcemaps),
|
||||
# downloads VSCode, launches it, and connects CDP to the extension host.
|
||||
bun src/dev/debug-harness/server.ts --auto-launch
|
||||
node src/dev/debug-harness/server.ts --auto-launch
|
||||
```
|
||||
|
||||
## Data Isolation
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
#!/usr/bin/env bun
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Debug Harness Server
|
||||
@@ -10,7 +10,12 @@
|
||||
* - UI automation (click, type, screenshot) via Playwright
|
||||
*
|
||||
* Usage:
|
||||
* bun src/dev/debug-harness/server.ts [options]
|
||||
* node src/dev/debug-harness/server.ts [options]
|
||||
*
|
||||
* Run with node, not bun: Playwright's _electron.launch() never finishes
|
||||
* attaching to the debugee under bun (the Electron process starts, but the
|
||||
* launch times out), while the same launch works under node. Node >= 22.6
|
||||
* runs this file directly via type stripping.
|
||||
*
|
||||
* Options:
|
||||
* --skip-build Skip building extension/webview
|
||||
@@ -39,7 +44,6 @@ import path from "node:path"
|
||||
import { fileURLToPath } from "node:url"
|
||||
import { downloadAndUnzipVSCode, SilentReporter } from "@vscode/test-electron"
|
||||
import { _electron, type CDPSession, type ElectronApplication, type Frame, type Page } from "playwright"
|
||||
import WebSocket from "ws"
|
||||
|
||||
const __script_dir = typeof __dirname !== "undefined" ? __dirname : path.dirname(fileURLToPath(import.meta.url))
|
||||
|
||||
@@ -201,19 +205,21 @@ class CdpClient {
|
||||
|
||||
async connect(wsUrl: string): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
// The runtime's built-in WebSocket (browser-style events), so the
|
||||
// harness has no dependency on the `ws` package.
|
||||
const ws = new WebSocket(wsUrl)
|
||||
ws.on("open", () => {
|
||||
ws.addEventListener("open", () => {
|
||||
this.ws = ws
|
||||
resolve()
|
||||
})
|
||||
ws.on("error", (e: Error) => {
|
||||
if (!this.ws) reject(e)
|
||||
ws.addEventListener("error", () => {
|
||||
if (!this.ws) reject(new Error(`WebSocket connection failed: ${wsUrl}`))
|
||||
})
|
||||
ws.on("close", () => {
|
||||
ws.addEventListener("close", () => {
|
||||
this.ws = null
|
||||
})
|
||||
ws.on("message", (raw: WebSocket.Data) => {
|
||||
const msg = JSON.parse(raw.toString())
|
||||
ws.addEventListener("message", (event: MessageEvent) => {
|
||||
const msg = JSON.parse(typeof event.data === "string" ? event.data : Buffer.from(event.data).toString())
|
||||
if (msg.id !== undefined) {
|
||||
const p = this.pending.get(msg.id)
|
||||
if (p) {
|
||||
|
||||
@@ -262,10 +262,15 @@ export class VscodeTerminalManager {
|
||||
return mergePromise(process, promise)
|
||||
}
|
||||
|
||||
async getOrCreateTerminal(cwd: string): Promise<ITerminalInfo> {
|
||||
/**
|
||||
* @param profileId Terminal profile to create/match the terminal with.
|
||||
* Defaults to the current setting; callers that captured the profile
|
||||
* earlier (e.g. when the model request was built) pass it here so a
|
||||
* settings change does not switch shells under an in-flight tool call.
|
||||
*/
|
||||
async getOrCreateTerminal(cwd: string, profileId: string = this.defaultTerminalProfile): Promise<ITerminalInfo> {
|
||||
const terminals = TerminalRegistry.getAllTerminals()
|
||||
const expectedShellPath =
|
||||
this.defaultTerminalProfile !== "default" ? getShellForProfile(this.defaultTerminalProfile) : undefined
|
||||
const expectedShellPath = profileId !== "default" ? getShellForProfile(profileId) : undefined
|
||||
// Resolve effective shell for comparison (so "default" and "zsh" match on macOS)
|
||||
const effectiveExpected = VscodeTerminalManager.effectiveShellPath(expectedShellPath)
|
||||
|
||||
|
||||
@@ -694,4 +694,41 @@ describe("TerminalProcess (Integration Tests)", () => {
|
||||
;(emitSpy as sinon.SinonSpy).calledWith("line", "line 3 continued").should.be.true()
|
||||
processAny.buffer.should.equal("")
|
||||
})
|
||||
|
||||
it("detach emits continue but keeps line listeners attached and listening", () => {
|
||||
const processAny = process as any
|
||||
const continueEvents: number[] = []
|
||||
const lines: string[] = []
|
||||
process.on("continue", () => continueEvents.push(1))
|
||||
process.on("line", (line) => lines.push(line))
|
||||
|
||||
process.detach()
|
||||
continueEvents.length.should.equal(1)
|
||||
|
||||
// Unlike continue(), detach must not stop listening or drop 'line'
|
||||
// listeners: output after detach still reaches subscribers (this is
|
||||
// what streams the rest of a detached command to the log file).
|
||||
processAny.isListening.should.be.true()
|
||||
processAny.emitIfEol("after detach\n")
|
||||
lines.should.containEql("after detach")
|
||||
})
|
||||
|
||||
it("detach flushes a buffered partial line before emitting continue", () => {
|
||||
const processAny = process as any
|
||||
const events: string[] = []
|
||||
process.on("continue", () => events.push("continue"))
|
||||
process.on("line", (line) => events.push(`line:${line}`))
|
||||
|
||||
// A chunk with no trailing newline stays in the internal buffer.
|
||||
processAny.emitIfEol("partial output")
|
||||
processAny.buffer.should.equal("partial output")
|
||||
|
||||
process.detach()
|
||||
|
||||
// The partial line must reach listeners before 'continue' resolves the
|
||||
// awaited promise; otherwise it is missing from the partial output and
|
||||
// from the log's initial flush.
|
||||
events.should.eql(["line:partial output", "continue"])
|
||||
processAny.buffer.should.equal("")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -16,8 +16,8 @@ import {
|
||||
TRUNCATE_KEEP_LINES,
|
||||
} from "@/integrations/terminal/constants"
|
||||
import type { ITerminalProcess, TerminalCompletionDetails, TerminalProcessEvents } from "@/integrations/terminal/types"
|
||||
import { Logger } from "@/shared/services/Logger"
|
||||
import type { MarkerlessCompletionCause } from "@/services/telemetry/TelemetryService"
|
||||
import { Logger } from "@/shared/services/Logger"
|
||||
import { Osc633EventType, Osc633Parser } from "./osc633Parser"
|
||||
import { classifyShellPrompt, getLastLine } from "./shellPromptHeuristics"
|
||||
|
||||
@@ -522,6 +522,23 @@ export class VscodeTerminalProcess extends EventEmitter<TerminalProcessEvents> i
|
||||
this.emit("continue")
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the awaited promise while the command keeps running ("Proceed
|
||||
* While Running"). Listeners stay attached and 'line' events keep
|
||||
* flowing — unlike continue() — so callers can stream the remaining
|
||||
* output until the command actually completes. Because 'completed' is
|
||||
* only emitted by the read loop when the command genuinely ends, the
|
||||
* terminal stays busy and is not eligible for reuse until then.
|
||||
*/
|
||||
detach() {
|
||||
// Flush any partial line (no trailing newline yet) so it reaches
|
||||
// listeners before the awaited promise resolves; otherwise it would be
|
||||
// dropped from both the partial output and the log capture if the
|
||||
// command exits without further newline-terminated output.
|
||||
this.emitRemainingBufferIfListening()
|
||||
this.emit("continue")
|
||||
}
|
||||
|
||||
/**
|
||||
* Get output that hasn't been retrieved yet.
|
||||
* Truncates if output is too large to prevent context window overflow.
|
||||
|
||||
@@ -63,10 +63,17 @@ export interface ITerminalProcess extends EventEmitter<TerminalProcessEvents> {
|
||||
/**
|
||||
* Continue execution without waiting for completion.
|
||||
* Stops event emission and resolves the promise.
|
||||
* This is called when user clicks "Proceed While Running".
|
||||
*/
|
||||
continue(): void
|
||||
|
||||
/**
|
||||
* Resolve the awaited promise while the command keeps running ("Proceed
|
||||
* While Running"). Unlike continue(), output listeners stay attached and
|
||||
* 'line'/'completed' events keep flowing, so callers can stream the rest
|
||||
* of the output (e.g. to a log file) until the command completes.
|
||||
*/
|
||||
detach(): void
|
||||
|
||||
/**
|
||||
* Get output that hasn't been retrieved yet.
|
||||
* @returns The unretrieved output
|
||||
|
||||
@@ -68,6 +68,7 @@ import {
|
||||
import { SdkCompactionCoordinator } from "./sdk-compaction-coordinator"
|
||||
import { SdkDiffEditCoordinator } from "./sdk-diff-edit-coordinator"
|
||||
import { SdkFollowupCoordinator } from "./sdk-followup-coordinator"
|
||||
import { SdkForegroundCommandCoordinator } from "./sdk-foreground-command-coordinator"
|
||||
import { SdkInteractionCoordinator } from "./sdk-interaction-coordinator"
|
||||
import { SdkMcpCoordinator } from "./sdk-mcp-coordinator"
|
||||
import { SdkMessageCoordinator, type SessionEventListener } from "./sdk-message-coordinator"
|
||||
@@ -204,6 +205,15 @@ export class Controller {
|
||||
// standalone (JetBrains/CLI) host run commands through the SDK's built-in tool.
|
||||
private _terminalManager?: VscodeTerminalManager
|
||||
|
||||
// Registry of in-flight foreground (VS Code terminal) command executions.
|
||||
// Owned here — not by the session — so it survives session rebuilds, which
|
||||
// recreate the tool set. Drives the "Proceed While Running" button.
|
||||
private readonly foregroundCommands = new SdkForegroundCommandCoordinator({
|
||||
onRunningChanged: () => {
|
||||
void this.postStateToWebview()
|
||||
},
|
||||
})
|
||||
|
||||
// Private state kept for stub compatibility
|
||||
private backgroundCommandRunning = false
|
||||
private backgroundCommandTaskId?: string
|
||||
@@ -330,6 +340,7 @@ export class Controller {
|
||||
},
|
||||
onDidBecomeIdle: () => this.handleSessionBecameIdle(),
|
||||
getRemoteConfigIntegration: () => this.remoteConfigCoreIntegration,
|
||||
foregroundCommands: this.foregroundCommands,
|
||||
getTerminalManager: () => {
|
||||
// Guarded by getEffectiveTerminalExecutionMode() at the read sites
|
||||
// (vscode-session-host.ts, sdk-terminal-execution-mode-coordinator.ts):
|
||||
@@ -1174,6 +1185,19 @@ export class Controller {
|
||||
stubWarn("cancelBackgroundCommand")
|
||||
}
|
||||
|
||||
/**
|
||||
* "Proceed While Running": detach every in-flight foreground terminal
|
||||
* command. Each pending run_commands call returns its partial output plus
|
||||
* the log file path the remaining output is redirected to, and the agent
|
||||
* turn continues while the commands keep running in their terminals.
|
||||
*/
|
||||
async proceedWhileRunningCommand(): Promise<void> {
|
||||
const detached = this.foregroundCommands.proceedWhileRunning()
|
||||
if (detached === 0) {
|
||||
Logger.warn("[SdkController] proceedWhileRunningCommand: No foreground command is running")
|
||||
}
|
||||
}
|
||||
|
||||
async cancelQueuedPrompt(promptId: string): Promise<void> {
|
||||
const trimmedPromptId = promptId.trim()
|
||||
if (!trimmedPromptId) {
|
||||
@@ -1868,6 +1892,7 @@ export class Controller {
|
||||
mcpHub: this.mcpHub,
|
||||
backgroundCommandRunning: this.backgroundCommandRunning,
|
||||
backgroundCommandTaskId: this.backgroundCommandTaskId,
|
||||
foregroundCommandRunning: this.foregroundCommands.isRunning,
|
||||
})
|
||||
const sdkTaskHistory = (await this.taskHistory.listHistory({ limit: 100, hydrate: false }))
|
||||
.map(sessionHistoryRecordToHistoryItem)
|
||||
|
||||
@@ -2,6 +2,9 @@ import fs from "node:fs"
|
||||
import os from "node:os"
|
||||
import path from "node:path"
|
||||
import type { CoreSessionConfig } from "@cline/core"
|
||||
import * as LlmsModels from "@cline/llms"
|
||||
import { ApiFormat } from "@shared/proto/cline/models"
|
||||
import { Logger } from "@shared/services/Logger"
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
|
||||
import {
|
||||
buildResumeSessionInput,
|
||||
@@ -15,9 +18,12 @@ import {
|
||||
resolveApiKey,
|
||||
updateHistoryItem,
|
||||
} from "./cline-session-factory"
|
||||
import { parseProviderId } from "./model-catalog/provider-id"
|
||||
import { createProviderConfigStore } from "./model-catalog/store"
|
||||
|
||||
const mocks = vi.hoisted(() => {
|
||||
const providerSettingsManager = {
|
||||
getFilePath: vi.fn(() => path.join(tempDir, "settings", "providers.json")),
|
||||
getLastUsedProviderSettings: vi.fn(() => undefined),
|
||||
getProviderSettings: vi.fn((_providerId?: string) => undefined),
|
||||
saveProviderSettings: vi.fn(),
|
||||
@@ -39,6 +45,9 @@ const mocks = vi.hoisted(() => {
|
||||
}
|
||||
return undefined
|
||||
}),
|
||||
setGlobalStateBatch: vi.fn(),
|
||||
setGlobalState: vi.fn(),
|
||||
setSecret: vi.fn(),
|
||||
},
|
||||
}
|
||||
})
|
||||
@@ -72,11 +81,14 @@ vi.mock("@shared/services/Logger", () => ({
|
||||
|
||||
let tempDir: string
|
||||
const previousGlobalSettingsPath = process.env.CLINE_GLOBAL_SETTINGS_PATH
|
||||
const previousDataDir = process.env.CLINE_DATA_DIR
|
||||
|
||||
beforeEach(() => {
|
||||
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "cline-session-factory-"))
|
||||
process.env.CLINE_DATA_DIR = tempDir
|
||||
process.env.CLINE_GLOBAL_SETTINGS_PATH = path.join(tempDir, "global-settings.json")
|
||||
vi.clearAllMocks()
|
||||
LlmsModels.resetRegistry()
|
||||
mocks.stateManager.getApiConfiguration.mockReturnValue({
|
||||
actModeApiProvider: "anthropic",
|
||||
actModeApiModelId: "claude-sonnet-4-6",
|
||||
@@ -88,12 +100,14 @@ beforeEach(() => {
|
||||
}
|
||||
return undefined
|
||||
})
|
||||
mocks.providerSettingsManager.getFilePath.mockReturnValue(path.join(tempDir, "settings", "providers.json"))
|
||||
mocks.providerSettingsManager.getLastUsedProviderSettings.mockReturnValue(undefined)
|
||||
mocks.providerSettingsManager.getProviderSettings.mockReturnValue(undefined)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
process.env.CLINE_GLOBAL_SETTINGS_PATH = previousGlobalSettingsPath
|
||||
process.env.CLINE_DATA_DIR = previousDataDir
|
||||
fs.rmSync(tempDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
@@ -135,6 +149,11 @@ describe("getDefaultModelIdForProvider", () => {
|
||||
expect(getDefaultModelIdForProvider("unknown-provider")).toBeUndefined()
|
||||
})
|
||||
|
||||
it("returns no default for local-model-source providers so a cloud-catalog model is never silently selected", () => {
|
||||
expect(getDefaultModelIdForProvider("ollama")).toBeUndefined()
|
||||
expect(getDefaultModelIdForProvider("lmstudio")).toBeUndefined()
|
||||
})
|
||||
|
||||
it("resolves the OpenAI Compatible default through the extension's openai alias", () => {
|
||||
// The extension stores the OpenAI Compatible provider as "openai" while
|
||||
// the SDK catalog keys it as "openai-compatible". toSdkProviderId bridges
|
||||
@@ -230,9 +249,11 @@ describe("normalizeSdkBaseUrl", () => {
|
||||
expect(normalizeSdkBaseUrl("openai-compatible", " ")).toBeUndefined()
|
||||
})
|
||||
|
||||
it("uses provider catalog defaults to add the SDK endpoint path when the user supplies only an origin", () => {
|
||||
expect(normalizeSdkBaseUrl("ollama", "http://localhost:11434")).toBe("http://localhost:11434/v1")
|
||||
expect(normalizeSdkBaseUrl("ollama", "http://localhost:11434/")).toBe("http://localhost:11434/v1")
|
||||
it("passes Ollama origins through unchanged (the native-API vendor appends /api itself)", () => {
|
||||
expect(normalizeSdkBaseUrl("ollama", "http://localhost:11434")).toBe("http://localhost:11434")
|
||||
expect(normalizeSdkBaseUrl("ollama", "http://localhost:11434/")).toBe("http://localhost:11434/")
|
||||
// Legacy 4.0.x configs may carry the OpenAI-compat /v1 suffix; it is
|
||||
// preserved here and rewritten to /api by the vendor.
|
||||
expect(normalizeSdkBaseUrl("ollama", "http://localhost:11434/v1")).toBe("http://localhost:11434/v1")
|
||||
})
|
||||
|
||||
@@ -429,6 +450,40 @@ describe("buildSessionConfig", () => {
|
||||
expect(config.providerConfig).not.toHaveProperty("apiKey")
|
||||
})
|
||||
|
||||
it("preserves rich SDK catalog entries without extension-side replacement", async () => {
|
||||
const expectedModel = structuredClone((await LlmsModels.getModelsForProvider("anthropic"))["claude-sonnet-4-6"])
|
||||
mocks.stateManager.getApiConfiguration.mockReturnValue({
|
||||
actModeApiProvider: "anthropic",
|
||||
actModeApiModelId: "claude-sonnet-4-6",
|
||||
apiKey: "anthropic-key",
|
||||
} as any)
|
||||
|
||||
const config = await buildSessionConfig({ cwd: "/tmp/workspace" })
|
||||
const knownModel = (config.providerConfig as any).knownModels["claude-sonnet-4-6"]
|
||||
|
||||
expect(knownModel).toEqual(expectedModel)
|
||||
expect(knownModel.capabilities).toEqual(
|
||||
expect.arrayContaining(["images", "files", "tools", "reasoning", "structured_output", "temperature", "prompt-cache"]),
|
||||
)
|
||||
expect(knownModel.pricing).toEqual(expectedModel.pricing)
|
||||
expect(knownModel.releaseDate).toBe(expectedModel.releaseDate)
|
||||
expect(knownModel.family).toBe(expectedModel.family)
|
||||
})
|
||||
|
||||
it("keeps session creation non-fatal when known-model lookup fails", async () => {
|
||||
const lookupError = new Error("registry unavailable")
|
||||
const getModelsSpy = vi.spyOn(LlmsModels, "getModelsForProvider").mockRejectedValueOnce(lookupError)
|
||||
|
||||
const config = await buildSessionConfig({ cwd: "/tmp/workspace" })
|
||||
|
||||
expect(config.providerConfig).not.toHaveProperty("knownModels")
|
||||
expect(Logger.warn).toHaveBeenCalledWith(
|
||||
"[SessionFactory] Failed to resolve known models for provider=anthropic:",
|
||||
lookupError,
|
||||
)
|
||||
getModelsSpy.mockRestore()
|
||||
})
|
||||
|
||||
it("passes OpenAI Compatible max output tokens as an explicit request limit", async () => {
|
||||
mocks.stateManager.getApiConfiguration.mockReturnValue({
|
||||
actModeApiProvider: "openai",
|
||||
@@ -451,11 +506,87 @@ describe("buildSessionConfig", () => {
|
||||
expect(config.providerId).toBe("openai-compatible")
|
||||
expect(config.modelId).toBe("custom-reasoner")
|
||||
expect(config.knownModels).toBeUndefined()
|
||||
expect((config.providerConfig as any).knownModels).toBeUndefined()
|
||||
expect((config.providerConfig as any).knownModels).toBeDefined()
|
||||
expect((config.providerConfig as any).maxOutputTokens).toBeUndefined()
|
||||
expect((config as any).maxTokensPerTurn).toBe(4_096)
|
||||
})
|
||||
|
||||
it("uses OpenAI Compatible overrides from models.json for runtime request settings", async () => {
|
||||
mocks.stateManager.getApiConfiguration.mockReturnValue({
|
||||
actModeApiProvider: "openai",
|
||||
actModeOpenAiModelId: "custom-reasoner",
|
||||
openAiApiKey: "openai-compatible-key",
|
||||
openAiBaseUrl: "https://openai-compatible.example/v1",
|
||||
actModeOpenAiModelInfo: { supportsPromptCache: false },
|
||||
} as any)
|
||||
createProviderConfigStore().commitSelection(parseProviderId("openai"), "act", {
|
||||
providerId: parseProviderId("openai"),
|
||||
modelId: "custom-reasoner",
|
||||
overrides: {
|
||||
name: "Custom Reasoner",
|
||||
contextWindow: 16_000,
|
||||
maxInputTokens: 15_000,
|
||||
maxTokens: 1_234,
|
||||
capabilities: ["images", "reasoning", "streaming", "tools"],
|
||||
supportsVision: false,
|
||||
supportsAttachments: true,
|
||||
supportsReasoning: false,
|
||||
temperature: 0,
|
||||
inputPrice: 1,
|
||||
outputPrice: 2,
|
||||
cacheReadsPrice: 0.1,
|
||||
cacheWritesPrice: 0.5,
|
||||
apiFormat: ApiFormat.OPENAI_RESPONSES,
|
||||
},
|
||||
})
|
||||
|
||||
const config = await buildSessionConfig({ cwd: "/tmp/workspace" })
|
||||
const knownModel = (config.providerConfig as any).knownModels["custom-reasoner"]
|
||||
|
||||
expect(config.providerId).toBe("openai-compatible")
|
||||
expect(config.modelId).toBe("custom-reasoner")
|
||||
expect((config as any).maxTokensPerTurn).toBe(1_234)
|
||||
expect((config as any).temperature).toBe(0)
|
||||
expect(knownModel).toMatchObject({
|
||||
id: "custom-reasoner",
|
||||
name: "Custom Reasoner",
|
||||
contextWindow: 16_000,
|
||||
maxInputTokens: 15_000,
|
||||
maxTokens: 1_234,
|
||||
capabilities: ["streaming", "tools", "files"],
|
||||
apiFormat: "openai-responses",
|
||||
temperature: 0,
|
||||
pricing: { input: 1, output: 2, cacheRead: 0.1, cacheWrite: 0.5 },
|
||||
})
|
||||
})
|
||||
|
||||
it("keeps -1 OpenAI Compatible values out of request settings and fallback knownModels", async () => {
|
||||
mocks.stateManager.getApiConfiguration.mockReturnValue({
|
||||
actModeApiProvider: "openai",
|
||||
actModeOpenAiModelId: "custom-reasoner",
|
||||
openAiApiKey: "openai-compatible-key",
|
||||
openAiBaseUrl: "https://openai-compatible.example/v1",
|
||||
actModeOpenAiModelInfo: { supportsPromptCache: false },
|
||||
} as any)
|
||||
createProviderConfigStore().commitSelection(parseProviderId("openai"), "act", {
|
||||
providerId: parseProviderId("openai"),
|
||||
modelId: "custom-reasoner",
|
||||
overrides: {
|
||||
name: "Custom Reasoner",
|
||||
maxTokens: -1,
|
||||
temperature: -1,
|
||||
},
|
||||
})
|
||||
|
||||
const config = await buildSessionConfig({ cwd: "/tmp/workspace" })
|
||||
|
||||
expect((config as any).maxTokensPerTurn).toBeUndefined()
|
||||
expect((config as any).temperature).toBeUndefined()
|
||||
const knownModel = (config.providerConfig as any).knownModels["custom-reasoner"]
|
||||
expect(knownModel).not.toHaveProperty("maxTokens")
|
||||
expect(knownModel).not.toHaveProperty("temperature", -1)
|
||||
})
|
||||
|
||||
it("passes OCA reasoning effort from legacy mode settings to SDK sessions", async () => {
|
||||
mocks.stateManager.getApiConfiguration.mockReturnValue({
|
||||
actModeApiProvider: "oca",
|
||||
|
||||
@@ -17,7 +17,13 @@ import {
|
||||
resolveProviderApiKeyFromSettings,
|
||||
type StartSessionResult,
|
||||
} from "@cline/core"
|
||||
import { getGeneratedModelsForProvider, MODEL_COLLECTIONS_BY_PROVIDER_ID } from "@cline/llms"
|
||||
import type { ModelInfo as SdkModelInfo } from "@cline/llms"
|
||||
import {
|
||||
getGeneratedModelsForProvider,
|
||||
getModelsForProvider,
|
||||
MODEL_COLLECTIONS_BY_PROVIDER_ID,
|
||||
OLLAMA_DEFAULT_CONTEXT_WINDOW,
|
||||
} from "@cline/llms"
|
||||
import { buildClineSystemPrompt } from "@cline/shared"
|
||||
import type { ApiConfiguration } from "@shared/api"
|
||||
import { ClineClient } from "@shared/cline"
|
||||
@@ -37,7 +43,11 @@ import { FeatureFlag } from "@/shared/services/feature-flags/feature-flags"
|
||||
import { type BedrockProviderConfig, buildBedrockProviderConfig } from "./bedrock-config"
|
||||
import { buildAgentHooks } from "./hooks-adapter"
|
||||
import { readTaskHistory, resolveDataDir } from "./legacy-state-reader"
|
||||
import type { ResolvedModelSelection } from "./model-catalog/contracts"
|
||||
import { nonNegativeFiniteNumber, positiveFiniteNumber, toSdkApiFormat } from "./model-catalog/model-values"
|
||||
import { parseProviderId } from "./model-catalog/provider-id"
|
||||
import { toSdkProviderId } from "./model-catalog/sdk-provider-id"
|
||||
import { createProviderConfigStore, resolveRuntimeModelSelection } from "./model-catalog/store"
|
||||
import { getProviderSettingsManager } from "./provider-migration"
|
||||
import { buildSapProviderConfig, type SapProviderConfig } from "./sap-config"
|
||||
import type { SdkSessionHost } from "./session-host"
|
||||
@@ -209,8 +219,73 @@ function resolveOcaReasoningConfig(mode: Mode, apiConfig: ApiConfiguration | und
|
||||
|
||||
function resolveOpenAiCompatibleMaxTokens(config: ApiConfiguration | undefined, mode: Mode): number | undefined {
|
||||
const modelInfo = mode === "plan" ? config?.planModeOpenAiModelInfo : config?.actModeOpenAiModelInfo
|
||||
const maxTokens = modelInfo?.maxTokens
|
||||
return typeof maxTokens === "number" && Number.isFinite(maxTokens) && maxTokens > 0 ? maxTokens : undefined
|
||||
return positiveFiniteNumber(modelInfo?.maxTokens)
|
||||
}
|
||||
|
||||
function toSdkModelInfo(selection: ResolvedModelSelection): SdkModelInfo {
|
||||
const modelInfo = selection.modelInfo
|
||||
const capabilities = new Set<NonNullable<SdkModelInfo["capabilities"]>[number]>(
|
||||
(selection.overrides?.capabilities ?? []) as NonNullable<SdkModelInfo["capabilities"]>,
|
||||
)
|
||||
const setCapability = (capability: NonNullable<SdkModelInfo["capabilities"]>[number], enabled: boolean): void => {
|
||||
if (enabled) capabilities.add(capability)
|
||||
else capabilities.delete(capability)
|
||||
}
|
||||
if (modelInfo.supportsImages !== undefined) setCapability("images", modelInfo.supportsImages)
|
||||
setCapability("prompt-cache", modelInfo.supportsPromptCache)
|
||||
if (modelInfo.supportsReasoning !== undefined) setCapability("reasoning", modelInfo.supportsReasoning)
|
||||
if (selection.overrides?.supportsAttachments !== undefined) setCapability("files", selection.overrides.supportsAttachments)
|
||||
|
||||
const maxTokens = positiveFiniteNumber(modelInfo.maxTokens)
|
||||
const contextWindow = positiveFiniteNumber(modelInfo.contextWindow)
|
||||
const maxInputTokens = positiveFiniteNumber(selection.overrides?.maxInputTokens)
|
||||
const temperature = nonNegativeFiniteNumber(modelInfo.temperature)
|
||||
const inputPrice = nonNegativeFiniteNumber(modelInfo.inputPrice)
|
||||
const outputPrice = nonNegativeFiniteNumber(modelInfo.outputPrice)
|
||||
const cacheRead = nonNegativeFiniteNumber(modelInfo.cacheReadsPrice)
|
||||
const cacheWrite = nonNegativeFiniteNumber(modelInfo.cacheWritesPrice)
|
||||
const apiFormat = toSdkApiFormat(modelInfo.apiFormat)
|
||||
const hasPricing =
|
||||
inputPrice !== undefined || outputPrice !== undefined || cacheRead !== undefined || cacheWrite !== undefined
|
||||
|
||||
return {
|
||||
id: selection.modelId,
|
||||
name: modelInfo.name ?? selection.modelId,
|
||||
...(maxTokens !== undefined ? { maxTokens } : {}),
|
||||
...(contextWindow !== undefined ? { contextWindow } : {}),
|
||||
...(maxInputTokens !== undefined ? { maxInputTokens } : {}),
|
||||
...(capabilities.size > 0 ? { capabilities: [...capabilities] } : {}),
|
||||
...(apiFormat !== undefined ? { apiFormat } : {}),
|
||||
...(temperature !== undefined ? { temperature } : {}),
|
||||
...(hasPricing
|
||||
? {
|
||||
pricing: {
|
||||
...(inputPrice !== undefined ? { input: inputPrice } : {}),
|
||||
...(outputPrice !== undefined ? { output: outputPrice } : {}),
|
||||
...(cacheRead !== undefined ? { cacheRead } : {}),
|
||||
...(cacheWrite !== undefined ? { cacheWrite } : {}),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
}
|
||||
}
|
||||
|
||||
function resolveCommittedRuntimeModel(
|
||||
providerId: string,
|
||||
mode: Mode,
|
||||
modelId: string | undefined,
|
||||
): ResolvedModelSelection | undefined {
|
||||
if (!modelId) {
|
||||
return undefined
|
||||
}
|
||||
try {
|
||||
const parsedProviderId = parseProviderId(providerId)
|
||||
const selection = createProviderConfigStore().readSelection(parsedProviderId, mode)
|
||||
return selection?.modelId === modelId ? selection : resolveRuntimeModelSelection(parsedProviderId, modelId)
|
||||
} catch (error) {
|
||||
Logger.warn(`[SessionFactory] Failed to resolve committed model settings for provider=${providerId}:`, error)
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -303,8 +378,21 @@ const PROVIDER_MODEL_ID_MAP: Record<string, { plan: keyof ApiConfiguration; act:
|
||||
|
||||
const DEFAULT_PROVIDER_ID = "cline"
|
||||
|
||||
/**
|
||||
* Providers whose model list comes from a live local endpoint (Ollama's
|
||||
* `/api/tags`, LM Studio's `/v1/models`). Their installed models are the only
|
||||
* meaningful catalog; a bundled-catalog default would silently select a model
|
||||
* the user never installed (e.g. an Ollama Cloud nemotron model).
|
||||
*/
|
||||
function providerHasLocalModelSource(providerId: string): boolean {
|
||||
return Boolean(MODEL_COLLECTIONS_BY_PROVIDER_ID[toSdkProviderId(providerId)]?.provider.modelsSourceUrl)
|
||||
}
|
||||
|
||||
export function getDefaultModelIdForProvider(providerId: string): string | undefined {
|
||||
const sdkProviderId = toSdkProviderId(providerId)
|
||||
if (providerHasLocalModelSource(providerId)) {
|
||||
return undefined
|
||||
}
|
||||
const collection = MODEL_COLLECTIONS_BY_PROVIDER_ID[sdkProviderId]
|
||||
if (!collection) {
|
||||
return undefined
|
||||
@@ -479,6 +567,42 @@ export function resolveVertexProviderConfig(config: ApiConfiguration): Pick<Prov
|
||||
}
|
||||
}
|
||||
|
||||
type OllamaProviderConfig = {
|
||||
modelInfo?: { id: string; name: string; contextWindow: number }
|
||||
timeoutMs?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the user's "Model Context Window" setting for Ollama and surface it
|
||||
* as the selected model's `contextWindow`. The gateway carries it on the
|
||||
* resolved model definition, and the Ollama vendor maps it onto the wire as
|
||||
* `options.num_ctx` — without it Ollama loads every model with its 4096-token
|
||||
* server default. Keeping it on the model also means context management
|
||||
* budgets against the window Ollama actually applies (Ollama truncates the
|
||||
* prompt to `num_ctx` server-side).
|
||||
*/
|
||||
export function resolveOllamaProviderConfig(config: ApiConfiguration, modelId: string | undefined): OllamaProviderConfig {
|
||||
// providers.json (`contextWindow`) is the source of truth; the legacy
|
||||
// StateManager string is a migration fallback (the config store mirrors
|
||||
// writes to both).
|
||||
let settingsContextWindow: number | undefined
|
||||
try {
|
||||
const value = getProviderSettingsManager().getProviderSettings("ollama")?.contextWindow
|
||||
settingsContextWindow = typeof value === "number" && Number.isFinite(value) && value > 0 ? Math.floor(value) : undefined
|
||||
} catch {
|
||||
Logger.warn("[SessionFactory] Failed to read Ollama settings from providers.json")
|
||||
}
|
||||
const raw = config.ollamaApiOptionsCtxNum?.trim()
|
||||
const parsed = raw ? Number(raw) : Number.NaN
|
||||
const legacyContextWindow = Number.isFinite(parsed) && parsed > 0 ? Math.floor(parsed) : undefined
|
||||
const contextWindow = settingsContextWindow ?? legacyContextWindow ?? OLLAMA_DEFAULT_CONTEXT_WINDOW
|
||||
const timeoutMs = config.requestTimeoutMs
|
||||
return {
|
||||
...(typeof timeoutMs === "number" && timeoutMs > 0 ? { timeoutMs } : {}),
|
||||
...(modelId ? { modelInfo: { id: modelId, name: modelId, contextWindow } } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveBaseUrl(providerId: string, config: ApiConfiguration): string | undefined {
|
||||
const baseUrlMap: Record<string, keyof ApiConfiguration> = {
|
||||
anthropic: "anthropicBaseUrl",
|
||||
@@ -536,6 +660,7 @@ export async function buildSessionConfig(input: SessionConfigInput): Promise<Cor
|
||||
let bedrockProviderConfig: BedrockProviderConfig | undefined
|
||||
let vertexProviderConfig: Pick<ProviderSettings, "gcp" | "region"> | undefined
|
||||
let sapProviderConfig: SapProviderConfig | undefined
|
||||
let ollamaProviderConfig: ReturnType<typeof resolveOllamaProviderConfig> | undefined
|
||||
|
||||
try {
|
||||
const stateManager = StateManager.get()
|
||||
@@ -571,6 +696,10 @@ export async function buildSessionConfig(input: SessionConfigInput): Promise<Cor
|
||||
baseUrl = sapProviderConfig.baseUrl
|
||||
}
|
||||
|
||||
if (providerId === "ollama") {
|
||||
ollamaProviderConfig = resolveOllamaProviderConfig(apiConfig, modelId)
|
||||
}
|
||||
|
||||
Logger.log(
|
||||
`[SessionFactory] Resolved from StateManager: provider=${providerId}, model=${modelId}, hasApiKey=${!!apiKey}`,
|
||||
)
|
||||
@@ -606,12 +735,31 @@ export async function buildSessionConfig(input: SessionConfigInput): Promise<Cor
|
||||
// Final defaults. Keep this aligned with the provider catalog so the UI and
|
||||
// session factory share one source of truth for default models.
|
||||
providerId = providerId ?? DEFAULT_PROVIDER_ID
|
||||
modelId = modelId ?? getDefaultModelIdForProvider(providerId) ?? getDefaultModelIdForProvider(DEFAULT_PROVIDER_ID) ?? ""
|
||||
if (!modelId && providerHasLocalModelSource(providerId)) {
|
||||
// Local-model-source providers: the committed selection lives in
|
||||
// providers.json when the legacy state slot is empty (e.g. configs
|
||||
// created through the SDK settings store). Never fall through to a
|
||||
// catalog default — an empty model id surfaces an explicit "select a
|
||||
// model" state instead of silently running a model the user never chose.
|
||||
try {
|
||||
modelId = getProviderSettingsManager().getProviderSettings(providerSettingsProviderId(providerId))?.model?.trim()
|
||||
} catch {
|
||||
Logger.warn(`[SessionFactory] Failed to read ${providerId} model from providers.json`)
|
||||
}
|
||||
modelId = modelId || ""
|
||||
} else {
|
||||
modelId = modelId ?? getDefaultModelIdForProvider(providerId) ?? getDefaultModelIdForProvider(DEFAULT_PROVIDER_ID) ?? ""
|
||||
}
|
||||
if (!apiKey && apiConfig) {
|
||||
apiKey = resolveApiKey(providerId, apiConfig)
|
||||
}
|
||||
apiKey = apiKey ?? ""
|
||||
const maxTokensPerTurn = providerId === "openai" ? resolveOpenAiCompatibleMaxTokens(apiConfig, mode) : undefined
|
||||
const committedRuntimeModel = resolveCommittedRuntimeModel(providerId, mode, modelId)
|
||||
const overriddenMaxTokens = committedRuntimeModel?.overrides?.maxTokens
|
||||
const maxTokensPerTurn =
|
||||
positiveFiniteNumber(overriddenMaxTokens) ??
|
||||
(providerId === "openai" ? resolveOpenAiCompatibleMaxTokens(apiConfig, mode) : undefined)
|
||||
const temperature = nonNegativeFiniteNumber(committedRuntimeModel?.overrides?.temperature)
|
||||
const reasoningConfig =
|
||||
providerId === "oca"
|
||||
? (resolveOcaReasoningConfig(mode, apiConfig) ?? resolveProviderReasoningConfig(providerId))
|
||||
@@ -662,13 +810,34 @@ export async function buildSessionConfig(input: SessionConfigInput): Promise<Cor
|
||||
const sdkProviderId = toSdkProviderId(providerId)
|
||||
const hostIdentity = await resolveHostIdentity()
|
||||
const isMultiRoot = await resolveIsMultiRootWorkspace()
|
||||
let knownModels: Awaited<ReturnType<typeof getModelsForProvider>> | undefined
|
||||
try {
|
||||
// Constructing the settings manager loads providers.json and models.json into
|
||||
// the @cline/llms registry. Reading models from that registry ensures custom
|
||||
// model overrides are included in the inference provider config, not just in
|
||||
// the webview/display path.
|
||||
getProviderSettingsManager(resolveDataDir())
|
||||
knownModels = await getModelsForProvider(sdkProviderId)
|
||||
// Only inject host-resolved metadata that carries real information
|
||||
// (catalog/state base or user overrides). Pure fallback fabrications
|
||||
// must not reach the runtime; the SDK's own resolution handles those.
|
||||
const isPureFallbackModel = committedRuntimeModel?.modelInfoSource === "fallback" && !committedRuntimeModel.overrides
|
||||
if (committedRuntimeModel && !isPureFallbackModel && !knownModels?.[modelId]) {
|
||||
knownModels = {
|
||||
...(knownModels ?? {}),
|
||||
[modelId]: toSdkModelInfo(committedRuntimeModel),
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
Logger.warn(`[SessionFactory] Failed to resolve known models for provider=${sdkProviderId}:`, error)
|
||||
}
|
||||
|
||||
// Always pass a providerConfig so the proxy/CA-aware fetch reaches the SDK
|
||||
// gateway; without it the agent loop uses bare global fetch and corporate
|
||||
// proxy/self-signed CA setups fail on JetBrains and CLI. Cloud providers
|
||||
// additionally need structured options (region/project/auth/SAP OAuth), which core
|
||||
// reads from providerConfig in createAgentModelFromConfig.
|
||||
const cloudProviderConfig = bedrockProviderConfig ?? vertexProviderConfig ?? sapProviderConfig
|
||||
const cloudProviderConfig = bedrockProviderConfig ?? vertexProviderConfig ?? sapProviderConfig ?? ollamaProviderConfig
|
||||
// Spread the cloud config first so the explicit fields below — notably the
|
||||
// proxy/CA-aware fetch — can never be clobbered if those types gain matching keys.
|
||||
const providerConfig = {
|
||||
@@ -677,6 +846,7 @@ export async function buildSessionConfig(input: SessionConfigInput): Promise<Cor
|
||||
modelId,
|
||||
...(apiKey ? { apiKey } : {}),
|
||||
...(baseUrl !== undefined ? { baseUrl } : {}),
|
||||
...(knownModels && Object.keys(knownModels).length > 0 ? { knownModels } : {}),
|
||||
fetch,
|
||||
}
|
||||
|
||||
@@ -707,6 +877,7 @@ export async function buildSessionConfig(input: SessionConfigInput): Promise<Cor
|
||||
mode: mode === "plan" ? "plan" : "act",
|
||||
...reasoningConfig,
|
||||
...(maxTokensPerTurn !== undefined ? { maxTokensPerTurn } : {}),
|
||||
...(temperature !== undefined ? { temperature } : {}),
|
||||
maxIterations: undefined,
|
||||
logger: sdkLogger,
|
||||
extensionContext: {
|
||||
|
||||
@@ -3,10 +3,10 @@ import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"
|
||||
import type {
|
||||
EffectiveProviderConfig,
|
||||
Fingerprint,
|
||||
ModelSelection,
|
||||
ProviderConfigChange,
|
||||
ProviderConfigReader,
|
||||
ProviderModelsResult,
|
||||
ResolvedModelSelection,
|
||||
} from "./contracts"
|
||||
import { computeConfigFingerprint } from "./fingerprint"
|
||||
import { parseProviderId } from "./provider-id"
|
||||
@@ -95,7 +95,7 @@ function record(
|
||||
}
|
||||
}
|
||||
|
||||
function makeReader(initialConfig: EffectiveProviderConfig, selection?: ModelSelection): TestReader {
|
||||
function makeReader(initialConfig: EffectiveProviderConfig, selection?: ResolvedModelSelection): TestReader {
|
||||
let config = initialConfig
|
||||
const listeners = new Set<(event: ProviderConfigChange) => void>()
|
||||
return {
|
||||
@@ -241,7 +241,7 @@ describe("ProviderCatalog Phase 3.2 resolveModels happy path", () => {
|
||||
})
|
||||
const providerId = parseProviderId("openrouter")
|
||||
const config: EffectiveProviderConfig = { providerId, apiKey: "secret", baseUrl: "https://provider.example.com" }
|
||||
const selection: ModelSelection = { providerId, modelId: "selected", modelInfo }
|
||||
const selection: ResolvedModelSelection = { providerId, modelId: "selected", modelInfo }
|
||||
const reader = makeReader(config, selection)
|
||||
const catalog = createProviderCatalog(reader)
|
||||
|
||||
@@ -507,7 +507,7 @@ describe("ProviderCatalog Phase 3.4 store-driven invalidation", () => {
|
||||
const providerId = parseProviderId("openrouter")
|
||||
const reader = makeReader({ providerId, apiKey: "same" })
|
||||
const catalog = createProviderCatalog(reader)
|
||||
const selection: ModelSelection = { providerId, modelId: "different", modelInfo }
|
||||
const selection: ResolvedModelSelection = { providerId, modelId: "different", modelInfo }
|
||||
|
||||
const first = await catalog.resolveModels(providerId)
|
||||
reader.emit({ kind: "selection", providerId, mode: "act", selection })
|
||||
@@ -692,7 +692,7 @@ describe("ProviderCatalog Phase 3.6 subscribe", () => {
|
||||
const reader = makeReader({ providerId, baseUrl: "http://localhost:11434/v1" })
|
||||
const catalog = createProviderCatalog(reader)
|
||||
const listener = vi.fn()
|
||||
const selection: ModelSelection = { providerId, modelId: "custom:latest", modelInfo }
|
||||
const selection: ResolvedModelSelection = { providerId, modelId: "custom:latest", modelInfo }
|
||||
catalog.subscribe(providerId, listener)
|
||||
|
||||
reader.emit({ kind: "selection", providerId, mode: "act", selection })
|
||||
|
||||
@@ -68,8 +68,8 @@ export type Fingerprint = string & { readonly [FingerprintBrand]: void }
|
||||
* - Two reads with no intervening write return structurally equal values.
|
||||
* - Consumers must not mutate. The shape is `Readonly`.
|
||||
*
|
||||
* Mode-dependent selection (modelId, modelInfo) is *not* part of this
|
||||
* type. Use `ProviderConfigStore.readSelection(providerId, mode)`.
|
||||
* Mode-dependent selection is *not* part of this type. Use
|
||||
* `ProviderConfigStore.readSelection(providerId, mode)`.
|
||||
*/
|
||||
export interface AwsProviderConfig {
|
||||
readonly accessKey?: string
|
||||
@@ -98,6 +98,12 @@ export interface EffectiveProviderConfig {
|
||||
readonly region?: string
|
||||
readonly aws?: AwsProviderConfig
|
||||
readonly gcp?: GcpProviderConfig
|
||||
/**
|
||||
* Provider-level context window (providers.json `contextWindow`).
|
||||
* Provider-neutral: for Ollama it maps to `options.num_ctx` at the
|
||||
* vendor boundary.
|
||||
*/
|
||||
readonly contextWindow?: number
|
||||
/**
|
||||
* OAuth-style auth bundle (e.g. cline provider's WorkOS token).
|
||||
* Compatible with `apiKey`; some providers populate both.
|
||||
@@ -119,9 +125,9 @@ export interface EffectiveProviderConfig {
|
||||
* A patch describing a field-level write to `ProviderConfigStore`.
|
||||
*
|
||||
* Invariant: `ProviderConfigPatch` cannot describe a model selection. The
|
||||
* type does not contain `modelId` or `modelInfo`. To write a selection,
|
||||
* use `commitSelection`, which is a structurally distinct method on the
|
||||
* store.
|
||||
* type does not contain `modelId` or per-model overrides. To write a
|
||||
* selection, use `commitSelection`, which is a structurally distinct method
|
||||
* on the store.
|
||||
*
|
||||
* Empty patches are allowed and are no-ops. A field present with value
|
||||
* `null` means "clear this field"; an absent field means "leave unchanged."
|
||||
@@ -140,6 +146,7 @@ export interface ProviderConfigPatch {
|
||||
readonly region?: string | null
|
||||
readonly aws?: AwsProviderConfig | null
|
||||
readonly gcp?: GcpProviderConfig | null
|
||||
readonly contextWindow?: number | null
|
||||
readonly auth?: {
|
||||
readonly accessToken?: string
|
||||
readonly refreshToken?: string
|
||||
@@ -153,22 +160,65 @@ export interface ProviderConfigPatch {
|
||||
// Model selection
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Per-model metadata overrides authored by the user for custom models. */
|
||||
export interface ModelSelectionOverrides {
|
||||
readonly name?: string
|
||||
readonly maxTokens?: number
|
||||
readonly contextWindow?: number
|
||||
readonly maxInputTokens?: number
|
||||
readonly capabilities?: readonly string[]
|
||||
readonly supportsVision?: boolean
|
||||
readonly supportsAttachments?: boolean
|
||||
readonly supportsReasoning?: boolean
|
||||
readonly inputPrice?: number
|
||||
readonly outputPrice?: number
|
||||
readonly cacheReadsPrice?: number
|
||||
readonly cacheWritesPrice?: number
|
||||
readonly temperature?: number
|
||||
readonly apiFormat?: ModelInfo["apiFormat"]
|
||||
readonly isR1FormatRequired?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* A user's committed model selection. The triple is atomic by type: every
|
||||
* write of `modelId` carries its `modelInfo` envelope, and vice versa.
|
||||
* A user's committed model selection. The committed write stores only the
|
||||
* selected model id plus optional user-authored overrides. `ModelInfo` is
|
||||
* derived by the host from the SDK catalog, then layered with `models.json`
|
||||
* overrides, then per-provider safe fallback metadata.
|
||||
*
|
||||
* Invariants:
|
||||
* - `modelInfo` was either taken from a `ProviderCatalog.resolveModels`
|
||||
* result, or constructed from per-provider safe defaults when the user
|
||||
* entered a custom id manually. Either way it represents the picker's
|
||||
* best knowledge at the moment of commit. The runtime uses it verbatim.
|
||||
* - The stored selection wins over later SDK catalog changes. Refresh
|
||||
* does not retroactively change committed selections.
|
||||
* - The webview does not commit a `ModelInfo` snapshot.
|
||||
* - Catalog metadata updates may affect the resolved `ModelInfo` for an
|
||||
* existing selection unless the user has explicitly overridden the field.
|
||||
*/
|
||||
export interface ModelSelection {
|
||||
readonly providerId: ProviderId
|
||||
readonly modelId: string
|
||||
readonly overrides?: ModelSelectionOverrides
|
||||
}
|
||||
|
||||
/** A committed selection as read back by consumers that need display/runtime metadata. */
|
||||
export interface ResolvedModelSelection extends ModelSelection {
|
||||
readonly modelInfo: ModelInfo
|
||||
/**
|
||||
* Where the base `modelInfo` came from, before overrides were applied:
|
||||
*
|
||||
* - "catalog" — SDK catalog metadata (generated snapshot or registry).
|
||||
* - "state" — the mode-specific `*ModeModelInfo` snapshot persisted by
|
||||
* the picker at selection time. Authoritative for dynamic-list providers
|
||||
* (openrouter, litellm, requesty, …) whose models are not in the static
|
||||
* catalog.
|
||||
* - "fallback" — provider-safe defaults fabricated because nothing better
|
||||
* was available. Consumers should treat a "fallback" resolution without
|
||||
* overrides as weak data and prefer live catalog lookups over it.
|
||||
*/
|
||||
readonly modelInfoSource?: "catalog" | "state" | "fallback"
|
||||
/**
|
||||
* The base metadata `modelInfo` was resolved from, before overrides were
|
||||
* applied. Persisted (rather than the resolved value) into the legacy
|
||||
* state snapshot so that deleting an override cannot resurrect it from a
|
||||
* snapshot it was previously baked into.
|
||||
*/
|
||||
readonly baseModelInfo?: ModelInfo
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -190,7 +240,7 @@ export type ProviderConfigChange =
|
||||
readonly kind: "selection"
|
||||
readonly providerId: ProviderId
|
||||
readonly mode: Mode
|
||||
readonly selection: ModelSelection
|
||||
readonly selection: ResolvedModelSelection
|
||||
}
|
||||
|
||||
export type ProviderConfigChangeListener = (event: ProviderConfigChange) => void
|
||||
@@ -317,7 +367,7 @@ export interface ProviderModelsEvent {
|
||||
*/
|
||||
export interface ProviderConfigReader {
|
||||
read(providerId: ProviderId): EffectiveProviderConfig
|
||||
readSelection(providerId: ProviderId, mode: Mode): ModelSelection | undefined
|
||||
readSelection(providerId: ProviderId, mode: Mode): ResolvedModelSelection | undefined
|
||||
subscribe(listener: ProviderConfigChangeListener): Disposable
|
||||
}
|
||||
|
||||
@@ -351,8 +401,9 @@ export interface ProviderConfigStore extends ProviderConfigReader {
|
||||
write(providerId: ProviderId, patch: ProviderConfigPatch): EffectiveProviderConfig
|
||||
|
||||
/**
|
||||
* Commit a model selection atomically with its info envelope. The only
|
||||
* entry point that writes `{providerId, modelId, modelInfo}` triples.
|
||||
* Commit a model ID atomically with optional user-authored overrides.
|
||||
* Supplying overrides replaces that model's stored override entry; omitting
|
||||
* them leaves the existing entry unchanged.
|
||||
*
|
||||
* I2: refresh handlers do not have access to this method by type, since
|
||||
* `ProviderCatalog` holds only a `ProviderConfigReader`.
|
||||
|
||||
@@ -54,7 +54,27 @@ describe("buildEffectiveProviderConfig", () => {
|
||||
providerId: parseProviderId("ollama"),
|
||||
apiKey: "provider-ollama-key",
|
||||
baseUrl: "http://state-ollama:11434",
|
||||
extras: { ollamaApiOptionsCtxNum: "8192" },
|
||||
// The legacy state string surfaces as the provider-neutral
|
||||
// contextWindow when providers.json has none.
|
||||
contextWindow: 8192,
|
||||
})
|
||||
})
|
||||
|
||||
it("prefers the providers.json contextWindow over the legacy Ollama state key", async () => {
|
||||
const { buildEffectiveProviderConfig } = await import("./effective-config")
|
||||
mocks.setProviderSettings({
|
||||
ollama: {
|
||||
provider: "ollama",
|
||||
contextWindow: 65536,
|
||||
},
|
||||
})
|
||||
mocks.setApiConfiguration({
|
||||
ollamaApiOptionsCtxNum: "8192",
|
||||
})
|
||||
|
||||
expect(buildEffectiveProviderConfig(parseProviderId("ollama"))).toEqual({
|
||||
providerId: parseProviderId("ollama"),
|
||||
contextWindow: 65536,
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ type ProviderSettingsLike = {
|
||||
readonly region?: string
|
||||
readonly aws?: AwsProviderConfig
|
||||
readonly gcp?: GcpProviderConfig
|
||||
readonly contextWindow?: number
|
||||
readonly auth?: AuthConfig
|
||||
readonly extras?: ExtrasConfig
|
||||
}
|
||||
@@ -102,7 +103,6 @@ const headerFields: Partial<Record<string, keyof ApiConfiguration>> = {
|
||||
}
|
||||
|
||||
const extrasFields: Partial<Record<string, Partial<Record<string, keyof ApiConfiguration>>>> = {
|
||||
ollama: { ollamaApiOptionsCtxNum: "ollamaApiOptionsCtxNum" },
|
||||
lmstudio: { lmStudioMaxTokens: "lmStudioMaxTokens" },
|
||||
litellm: { liteLlmUsePromptCache: "liteLlmUsePromptCache" },
|
||||
openrouter: { openRouterProviderSorting: "openRouterProviderSorting" },
|
||||
@@ -157,6 +157,14 @@ function readBoolean(record: Record<string, unknown>, key: string): boolean | un
|
||||
return typeof value === "boolean" ? value : undefined
|
||||
}
|
||||
|
||||
function readPositiveInteger(value: unknown): number | undefined {
|
||||
const parsed = typeof value === "string" ? Number(value) : value
|
||||
if (typeof parsed === "number" && Number.isFinite(parsed) && parsed > 0) {
|
||||
return Math.floor(parsed)
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function readGcp(record: Record<string, unknown>): GcpProviderConfig | undefined {
|
||||
const gcp = record.gcp
|
||||
if (!isPlainRecord(gcp)) {
|
||||
@@ -206,6 +214,7 @@ function readProviderSettings(providerId: ProviderId): ConfigParts {
|
||||
region: readString(settings, "region"),
|
||||
aws: readAws(settings),
|
||||
gcp: readGcp(settings),
|
||||
contextWindow: readPositiveInteger(settings.contextWindow),
|
||||
auth: readAuth(settings),
|
||||
extras: isPlainRecord(settings.extras) ? settings.extras : undefined,
|
||||
} satisfies ProviderSettingsLike
|
||||
@@ -298,6 +307,16 @@ function readStateAws(provider: string, config: ApiConfiguration): AwsProviderCo
|
||||
return Object.values(aws).some((value) => value !== undefined) ? aws : undefined
|
||||
}
|
||||
|
||||
function readStateContextWindow(provider: string, config: ApiConfiguration): number | undefined {
|
||||
// Only Ollama has a legacy context-window state key; other providers keep
|
||||
// theirs in providers.json exclusively.
|
||||
if (provider !== "ollama") {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return readPositiveInteger(config.ollamaApiOptionsCtxNum)
|
||||
}
|
||||
|
||||
function readStateConfig(providerId: ProviderId, config: ApiConfiguration): ConfigParts {
|
||||
const provider = providerId.toString()
|
||||
return {
|
||||
@@ -308,6 +327,7 @@ function readStateConfig(providerId: ProviderId, config: ApiConfiguration): Conf
|
||||
region: readStringFromConfig(config, regionFields[provider]),
|
||||
aws: readStateAws(provider, config),
|
||||
gcp: readStateGcp(provider, config),
|
||||
contextWindow: readStateContextWindow(provider, config),
|
||||
auth: readStateAuth(provider, config),
|
||||
extras: readStateExtras(provider, config),
|
||||
}
|
||||
@@ -372,6 +392,10 @@ export function buildEffectiveProviderConfig(providerId: ProviderId): EffectiveP
|
||||
// fields as a fallback for old installs, but let providers.json win when both exist.
|
||||
assignIfDefined(merged, "aws", mergeAws(stateConfig.aws, providerSettings.aws))
|
||||
assignIfDefined(merged, "gcp", mergeGcp(stateConfig.gcp, providerSettings.gcp))
|
||||
// providers.json is the source of truth for the context window; the legacy
|
||||
// Ollama StateManager key is a migration fallback (the store mirrors writes
|
||||
// to both).
|
||||
assignIfDefined(merged, "contextWindow", providerSettings.contextWindow ?? stateConfig.contextWindow)
|
||||
assignIfDefined(merged, "auth", stateConfig.auth ?? providerSettings.auth)
|
||||
assignIfDefined(merged, "extras", mergeExtras(providerSettings.extras, stateConfig.extras))
|
||||
|
||||
|
||||
@@ -170,6 +170,9 @@ export function computeConfigFingerprint(providerId: ProviderId, config: Effecti
|
||||
region: config.region ?? null,
|
||||
aws: sanitizeAws(config.aws),
|
||||
gcp: sanitizeGcp(config.gcp),
|
||||
// The context window is not a secret; include it raw so changes
|
||||
// invalidate cached model lists.
|
||||
contextWindow: config.contextWindow ?? null,
|
||||
extras: sanitizeExtras(config.extras),
|
||||
auth: {
|
||||
accountId: config.auth?.accountId ?? null,
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
/**
|
||||
* Applies the `ModelInfo` fields the extension owns locally, on top of
|
||||
* an adapted SDK `ModelInfo`. Today this is just Vertex's
|
||||
* `supportsGlobalEndpoint` allowlist (see `./vertex-global-endpoint.ts`).
|
||||
* an adapted SDK `ModelInfo`. Today this is Vertex's
|
||||
* `supportsGlobalEndpoint` allowlist (see `./vertex-global-endpoint.ts`)
|
||||
* and Ollama's effective context window.
|
||||
*
|
||||
* Both the model-list resolution path (`resolveSdkModels`) and the
|
||||
* single-model lookup path (`resolveModelInfo`) pass adapted
|
||||
@@ -10,13 +11,52 @@
|
||||
* flags upstream, the override and this file can be removed together.
|
||||
*/
|
||||
|
||||
import { OLLAMA_DEFAULT_CONTEXT_WINDOW } from "@cline/llms"
|
||||
import type { ModelInfo } from "@shared/api"
|
||||
import { StateManager } from "@/core/storage/StateManager"
|
||||
import { getProviderSettingsManager } from "../provider-migration"
|
||||
import type { ProviderId } from "./contracts"
|
||||
import { vertexModelSupportsGlobalEndpoint } from "./vertex-global-endpoint"
|
||||
|
||||
/**
|
||||
* The context window Ollama actually applies is the requested `num_ctx`,
|
||||
* not the model's native maximum — Ollama truncates the prompt to it
|
||||
* server-side. Surface the user's "Model Context Window" setting (or the
|
||||
* request default) instead of catalog/safe-default values so the chat
|
||||
* indicator and context management match reality.
|
||||
*/
|
||||
function resolveOllamaContextWindow(): number {
|
||||
// providers.json (`contextWindow`) is the source of truth; the legacy
|
||||
// StateManager string is a migration fallback (the config store mirrors
|
||||
// writes to both).
|
||||
try {
|
||||
const value = getProviderSettingsManager().getProviderSettings("ollama")?.contextWindow
|
||||
if (typeof value === "number" && Number.isFinite(value) && value > 0) {
|
||||
return Math.floor(value)
|
||||
}
|
||||
} catch {
|
||||
// providers.json unavailable — fall through to the legacy state key.
|
||||
}
|
||||
try {
|
||||
const raw = StateManager.get().getApiConfiguration().ollamaApiOptionsCtxNum?.trim()
|
||||
if (raw) {
|
||||
const value = Number(raw)
|
||||
if (Number.isFinite(value) && value > 0) {
|
||||
return Math.floor(value)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// StateManager unavailable (e.g. tests) — fall through to the default.
|
||||
}
|
||||
return OLLAMA_DEFAULT_CONTEXT_WINDOW
|
||||
}
|
||||
|
||||
export function applyHostModelInfoOverrides(providerId: ProviderId, modelId: string, modelInfo: ModelInfo): ModelInfo {
|
||||
if (providerId === "vertex" && vertexModelSupportsGlobalEndpoint(providerId, modelId)) {
|
||||
return { ...modelInfo, supportsGlobalEndpoint: true }
|
||||
}
|
||||
if (providerId === "ollama") {
|
||||
return { ...modelInfo, contextWindow: resolveOllamaContextWindow() }
|
||||
}
|
||||
return modelInfo
|
||||
}
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import type { ModelInfo } from "@shared/api"
|
||||
import { ApiFormat } from "@shared/proto/cline/models"
|
||||
|
||||
/** SDK string spelling of an API format (matches @cline/shared ApiFormatSchema). */
|
||||
export type SdkApiFormatString = "r1" | "openai-responses" | "default"
|
||||
|
||||
export function finiteNumber(value: unknown): number | undefined {
|
||||
return typeof value === "number" && Number.isFinite(value) ? value : undefined
|
||||
}
|
||||
|
||||
export function positiveFiniteNumber(value: unknown): number | undefined {
|
||||
const number = finiteNumber(value)
|
||||
return number !== undefined && number > 0 ? number : undefined
|
||||
}
|
||||
|
||||
export function nonNegativeFiniteNumber(value: unknown): number | undefined {
|
||||
const number = finiteNumber(value)
|
||||
return number !== undefined && number >= 0 ? number : undefined
|
||||
}
|
||||
|
||||
export function toSdkApiFormat(apiFormat: ModelInfo["apiFormat"]): SdkApiFormatString | undefined {
|
||||
switch (apiFormat) {
|
||||
case ApiFormat.R1_CHAT:
|
||||
return "r1"
|
||||
case ApiFormat.OPENAI_RESPONSES:
|
||||
return "openai-responses"
|
||||
case ApiFormat.OPENAI_CHAT:
|
||||
return "default"
|
||||
default:
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
export function fromSdkApiFormat(apiFormat: string | undefined): ModelInfo["apiFormat"] | undefined {
|
||||
switch (apiFormat) {
|
||||
case "r1":
|
||||
return ApiFormat.R1_CHAT
|
||||
case "openai-responses":
|
||||
return ApiFormat.OPENAI_RESPONSES
|
||||
case "default":
|
||||
return ApiFormat.OPENAI_CHAT
|
||||
default:
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
import type { ApiConfiguration, ModelInfo } from "@shared/api"
|
||||
import { syncStoredProviderRegistration } from "@cline/core"
|
||||
import { type ApiConfiguration, type ModelInfo, openAiModelInfoSafeDefaults } from "@shared/api"
|
||||
import { ApiFormat } from "@shared/proto/cline/models"
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest"
|
||||
import type { ProviderConfigChange } from "./contracts"
|
||||
import { parseProviderId } from "./provider-id"
|
||||
@@ -7,6 +9,11 @@ const mocks = vi.hoisted(() => {
|
||||
type MockApiConfiguration = ApiConfiguration & { planActSeparateModelsSetting?: boolean }
|
||||
let apiConfiguration: MockApiConfiguration = {}
|
||||
let providerSettingsById: Record<string, Record<string, unknown>> = {}
|
||||
let generatedModelsByProvider: Record<string, Record<string, ModelInfo>> = {}
|
||||
let modelsFile: { version: 1; providers: Record<string, { models?: Record<string, Record<string, unknown>> }> } = {
|
||||
version: 1,
|
||||
providers: {},
|
||||
}
|
||||
const saveProviderSettings = vi.fn((settings: Record<string, unknown>, _options?: { setLastUsed?: boolean }) => {
|
||||
const provider = settings.provider
|
||||
if (typeof provider !== "string") {
|
||||
@@ -20,6 +27,8 @@ const mocks = vi.hoisted(() => {
|
||||
reset(): void {
|
||||
apiConfiguration = {}
|
||||
providerSettingsById = {}
|
||||
generatedModelsByProvider = {}
|
||||
modelsFile = { version: 1, providers: {} }
|
||||
saveProviderSettings.mockClear()
|
||||
},
|
||||
setApiConfiguration(value: MockApiConfiguration): void {
|
||||
@@ -28,6 +37,12 @@ const mocks = vi.hoisted(() => {
|
||||
setProviderSettings(value: Record<string, Record<string, unknown>>): void {
|
||||
providerSettingsById = { ...value }
|
||||
},
|
||||
setGeneratedModels(providerId: string, models: Record<string, ModelInfo>): void {
|
||||
generatedModelsByProvider = { ...generatedModelsByProvider, [providerId]: models }
|
||||
},
|
||||
getGeneratedModels(providerId: string): Record<string, ModelInfo> {
|
||||
return generatedModelsByProvider[providerId] ?? {}
|
||||
},
|
||||
getSavedProviderSettings(providerId: string): Record<string, unknown> | undefined {
|
||||
return providerSettingsById[providerId]
|
||||
},
|
||||
@@ -37,6 +52,12 @@ const mocks = vi.hoisted(() => {
|
||||
getSaveProviderSettingsMock(): typeof saveProviderSettings {
|
||||
return saveProviderSettings
|
||||
},
|
||||
getModelsFile() {
|
||||
return modelsFile
|
||||
},
|
||||
setModelsFile(value: typeof modelsFile): void {
|
||||
modelsFile = value
|
||||
},
|
||||
getStateManager() {
|
||||
return {
|
||||
getApiConfiguration: () => ({ ...apiConfiguration }),
|
||||
@@ -69,11 +90,24 @@ vi.mock("../provider-migration", () => ({
|
||||
getProviderSettingsManager: mocks.getProviderSettingsManager,
|
||||
}))
|
||||
|
||||
vi.mock("@cline/core", () => ({
|
||||
syncStoredProviderRegistration: vi.fn(),
|
||||
readModelsFileSync: vi.fn(() => mocks.getModelsFile()),
|
||||
resolveModelsRegistryPath: vi.fn(() => "/tmp/models.json"),
|
||||
writeModelsFileSync: vi.fn((_filePath: string, state: ReturnType<typeof mocks.getModelsFile>) => mocks.setModelsFile(state)),
|
||||
}))
|
||||
|
||||
vi.mock("@cline/llms", () => ({
|
||||
getGeneratedModelsForProvider: vi.fn((providerId: string) => mocks.getGeneratedModels(providerId)),
|
||||
MODEL_COLLECTIONS_BY_PROVIDER_ID: {},
|
||||
}))
|
||||
|
||||
const modelInfoA: ModelInfo = {
|
||||
name: "Model A",
|
||||
contextWindow: 128_000,
|
||||
maxTokens: 8_192,
|
||||
supportsPromptCache: true,
|
||||
apiFormat: ApiFormat.OPENAI_RESPONSES,
|
||||
}
|
||||
|
||||
const modelInfoB: ModelInfo = {
|
||||
@@ -83,9 +117,41 @@ const modelInfoB: ModelInfo = {
|
||||
supportsPromptCache: false,
|
||||
}
|
||||
|
||||
function selectionFromModelInfo(providerId: ReturnType<typeof parseProviderId>, modelId: string, modelInfo: ModelInfo) {
|
||||
const capabilities: string[] = []
|
||||
if (modelInfo.supportsPromptCache) capabilities.push("prompt-cache")
|
||||
if (modelInfo.supportsImages) capabilities.push("images")
|
||||
if (modelInfo.supportsReasoning) capabilities.push("reasoning")
|
||||
return {
|
||||
providerId,
|
||||
modelId,
|
||||
overrides: {
|
||||
name: modelInfo.name,
|
||||
contextWindow: modelInfo.contextWindow,
|
||||
maxTokens: modelInfo.maxTokens,
|
||||
...(modelInfo.apiFormat !== undefined ? { apiFormat: modelInfo.apiFormat } : {}),
|
||||
...(capabilities.length > 0 ? { capabilities } : {}),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function expectResolvedSelection(
|
||||
actual: unknown,
|
||||
selection: ReturnType<typeof selectionFromModelInfo>,
|
||||
modelInfo: ModelInfo,
|
||||
): void {
|
||||
expect(actual).toMatchObject({
|
||||
providerId: selection.providerId,
|
||||
modelId: selection.modelId,
|
||||
overrides: selection.overrides,
|
||||
modelInfo,
|
||||
})
|
||||
}
|
||||
|
||||
describe("createProviderConfigStore", () => {
|
||||
beforeEach(() => {
|
||||
mocks.reset()
|
||||
vi.clearAllMocks()
|
||||
vi.resetModules()
|
||||
})
|
||||
|
||||
@@ -121,22 +187,29 @@ describe("createProviderConfigStore", () => {
|
||||
const { createProviderConfigStore } = await import("./store")
|
||||
const store = createProviderConfigStore()
|
||||
const providerId = parseProviderId("openrouter")
|
||||
const selection = { providerId, modelId: "anthropic/claude-sonnet-4", modelInfo: modelInfoA }
|
||||
const selection = selectionFromModelInfo(providerId, "anthropic/claude-sonnet-4", modelInfoA)
|
||||
|
||||
store.commitSelection(providerId, "act", selection)
|
||||
|
||||
expect(store.readSelection(providerId, "act")).toEqual(selection)
|
||||
expectResolvedSelection(store.readSelection(providerId, "act"), selection, modelInfoA)
|
||||
expect(mocks.getModelsFile().providers.openrouter?.models?.["anthropic/claude-sonnet-4"]).toMatchObject({
|
||||
name: "Model A",
|
||||
contextWindow: 128_000,
|
||||
maxTokens: 8_192,
|
||||
apiFormat: "openai-responses",
|
||||
capabilities: ["prompt-cache"],
|
||||
})
|
||||
})
|
||||
|
||||
it("round-trips generic provider selections using the in-process modelInfo envelope", async () => {
|
||||
const { createProviderConfigStore } = await import("./store")
|
||||
const store = createProviderConfigStore()
|
||||
const providerId = parseProviderId("deepseek")
|
||||
const selection = { providerId, modelId: "deepseek-v4-pro", modelInfo: modelInfoA }
|
||||
const selection = selectionFromModelInfo(providerId, "deepseek-v4-pro", modelInfoA)
|
||||
|
||||
store.commitSelection(providerId, "act", selection)
|
||||
|
||||
expect(store.readSelection(providerId, "act")).toEqual(selection)
|
||||
expectResolvedSelection(store.readSelection(providerId, "act"), selection, modelInfoA)
|
||||
})
|
||||
|
||||
it("hydrates a generic provider selection from providers.json after reload", async () => {
|
||||
@@ -148,6 +221,8 @@ describe("createProviderConfigStore", () => {
|
||||
expect(store.readSelection(providerId, "act")).toEqual({
|
||||
providerId,
|
||||
modelId: "manual-zai-model",
|
||||
modelInfoSource: "fallback",
|
||||
baseModelInfo: expect.objectContaining({ name: "manual-zai-model" }),
|
||||
modelInfo: expect.objectContaining({
|
||||
name: "manual-zai-model",
|
||||
supportsPromptCache: false,
|
||||
@@ -160,27 +235,27 @@ describe("createProviderConfigStore", () => {
|
||||
const store = createProviderConfigStore()
|
||||
const geminiProviderId = parseProviderId("gemini")
|
||||
const deepSeekProviderId = parseProviderId("deepseek")
|
||||
const geminiSelection = { providerId: geminiProviderId, modelId: "gemini-3.1-pro-preview", modelInfo: modelInfoA }
|
||||
const deepSeekSelection = { providerId: deepSeekProviderId, modelId: "deepseek-v4-pro", modelInfo: modelInfoB }
|
||||
const geminiSelection = selectionFromModelInfo(geminiProviderId, "gemini-3.1-pro-preview", modelInfoA)
|
||||
const deepSeekSelection = selectionFromModelInfo(deepSeekProviderId, "deepseek-v4-pro", modelInfoB)
|
||||
|
||||
store.commitSelection(geminiProviderId, "act", geminiSelection)
|
||||
store.commitSelection(deepSeekProviderId, "act", deepSeekSelection)
|
||||
|
||||
expect(store.readSelection(geminiProviderId, "act")).toEqual(geminiSelection)
|
||||
expect(store.readSelection(deepSeekProviderId, "act")).toEqual(deepSeekSelection)
|
||||
expectResolvedSelection(store.readSelection(geminiProviderId, "act"), geminiSelection, modelInfoA)
|
||||
expectResolvedSelection(store.readSelection(deepSeekProviderId, "act"), deepSeekSelection, modelInfoB)
|
||||
})
|
||||
|
||||
it("handles normalized nousResearch provider casing for writes and selections", async () => {
|
||||
const { createProviderConfigStore } = await import("./store")
|
||||
const store = createProviderConfigStore()
|
||||
const providerId = parseProviderId("nousResearch")
|
||||
const selection = { providerId, modelId: "nousresearch/hermes-4-70b", modelInfo: modelInfoA }
|
||||
const selection = selectionFromModelInfo(providerId, "nousresearch/hermes-4-70b", modelInfoA)
|
||||
|
||||
const written = store.write(providerId, { apiKey: "nous-key" })
|
||||
store.commitSelection(providerId, "act", selection)
|
||||
|
||||
expect(written).toEqual({ providerId, apiKey: "nous-key" })
|
||||
expect(store.readSelection(providerId, "act")).toEqual(selection)
|
||||
expectResolvedSelection(store.readSelection(providerId, "act"), selection, modelInfoA)
|
||||
expect(mocks.getSavedProviderSettings("nousResearch")).toMatchObject({
|
||||
provider: "nousResearch",
|
||||
apiKey: "nous-key",
|
||||
@@ -227,6 +302,179 @@ describe("createProviderConfigStore", () => {
|
||||
})
|
||||
})
|
||||
|
||||
it("lazily migrates meaningful legacy custom-model metadata once", async () => {
|
||||
const legacyModelInfo = {
|
||||
name: "Legacy Custom",
|
||||
maxTokens: 4_096,
|
||||
contextWindow: 64_000,
|
||||
supportsImages: false,
|
||||
supportsPromptCache: true,
|
||||
supportsReasoning: true,
|
||||
inputPrice: 1,
|
||||
outputPrice: 2,
|
||||
cacheReadsPrice: 0.25,
|
||||
cacheWritesPrice: 0.5,
|
||||
temperature: 0.3,
|
||||
apiFormat: ApiFormat.OPENAI_RESPONSES,
|
||||
isR1FormatRequired: true,
|
||||
}
|
||||
mocks.setApiConfiguration({
|
||||
actModeOpenAiModelId: "legacy-custom",
|
||||
actModeOpenAiModelInfo: legacyModelInfo,
|
||||
})
|
||||
const { createProviderConfigStore } = await import("./store")
|
||||
const store = createProviderConfigStore()
|
||||
const providerId = parseProviderId("openai")
|
||||
|
||||
const first = store.readSelection(providerId, "act")
|
||||
const second = store.readSelection(providerId, "act")
|
||||
|
||||
expect(mocks.getModelsFile().providers["openai-compatible"]?.models?.["legacy-custom"]).toEqual({
|
||||
name: "Legacy Custom",
|
||||
maxTokens: 4_096,
|
||||
contextWindow: 64_000,
|
||||
capabilities: ["prompt-cache"],
|
||||
supportsVision: false,
|
||||
supportsReasoning: true,
|
||||
inputPrice: 1,
|
||||
outputPrice: 2,
|
||||
cacheReadsPrice: 0.25,
|
||||
cacheWritesPrice: 0.5,
|
||||
temperature: 0.3,
|
||||
apiFormat: "openai-responses",
|
||||
isR1FormatRequired: true,
|
||||
})
|
||||
expect(first?.overrides).toEqual(second?.overrides)
|
||||
expect(first?.modelInfo).toMatchObject({
|
||||
name: "Legacy Custom",
|
||||
maxTokens: 4_096,
|
||||
contextWindow: 64_000,
|
||||
supportsImages: false,
|
||||
supportsPromptCache: true,
|
||||
supportsReasoning: true,
|
||||
inputPrice: 1,
|
||||
outputPrice: 2,
|
||||
cacheReadsPrice: 0.25,
|
||||
cacheWritesPrice: 0.5,
|
||||
temperature: 0.3,
|
||||
apiFormat: ApiFormat.R1_CHAT,
|
||||
})
|
||||
expect(syncStoredProviderRegistration).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it("does not create migration noise for legacy safe defaults", async () => {
|
||||
mocks.setApiConfiguration({
|
||||
actModeOpenAiModelId: "default-custom",
|
||||
actModeOpenAiModelInfo: { ...openAiModelInfoSafeDefaults, name: "default-custom" },
|
||||
})
|
||||
const { createProviderConfigStore } = await import("./store")
|
||||
const store = createProviderConfigStore()
|
||||
const providerId = parseProviderId("openai")
|
||||
|
||||
const first = store.readSelection(providerId, "act")
|
||||
const second = store.readSelection(providerId, "act")
|
||||
|
||||
expect(mocks.getModelsFile().providers["openai-compatible"]?.models?.["default-custom"]).toBeUndefined()
|
||||
expect(first?.overrides).toBeUndefined()
|
||||
expect(second?.overrides).toBeUndefined()
|
||||
expect(first?.modelInfo).toMatchObject({ contextWindow: 128_000, supportsImages: true, temperature: 0 })
|
||||
expect(first?.modelInfo.maxTokens).toBeUndefined()
|
||||
expect(syncStoredProviderRegistration).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("never overwrites an existing models.json entry during migration", async () => {
|
||||
mocks.setModelsFile({
|
||||
version: 1,
|
||||
providers: {
|
||||
"openai-compatible": { models: { "existing-custom": { temperature: 0.7 } } },
|
||||
},
|
||||
})
|
||||
mocks.setApiConfiguration({
|
||||
actModeOpenAiModelId: "existing-custom",
|
||||
actModeOpenAiModelInfo: { ...openAiModelInfoSafeDefaults, temperature: 0.2 },
|
||||
})
|
||||
const { createProviderConfigStore } = await import("./store")
|
||||
const store = createProviderConfigStore()
|
||||
const providerId = parseProviderId("openai")
|
||||
|
||||
const selection = store.readSelection(providerId, "act")
|
||||
|
||||
expect(mocks.getModelsFile().providers["openai-compatible"]?.models?.["existing-custom"]).toEqual({
|
||||
temperature: 0.7,
|
||||
})
|
||||
expect(selection?.overrides).toEqual({ temperature: 0.7 })
|
||||
expect(selection?.modelInfo.temperature).toBe(0.7)
|
||||
expect(syncStoredProviderRegistration).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("does not migrate stale legacy snapshots for catalog-known models", async () => {
|
||||
mocks.setGeneratedModels("openai-compatible", {
|
||||
"known-model": {
|
||||
name: "Current Catalog Model",
|
||||
contextWindow: 256_000,
|
||||
supportsPromptCache: false,
|
||||
temperature: 0.1,
|
||||
},
|
||||
})
|
||||
mocks.setApiConfiguration({
|
||||
actModeOpenAiModelId: "known-model",
|
||||
actModeOpenAiModelInfo: {
|
||||
name: "Stale Catalog Model",
|
||||
contextWindow: 32_000,
|
||||
supportsPromptCache: false,
|
||||
temperature: 0.9,
|
||||
},
|
||||
})
|
||||
const { createProviderConfigStore } = await import("./store")
|
||||
const store = createProviderConfigStore()
|
||||
const providerId = parseProviderId("openai")
|
||||
|
||||
const selection = store.readSelection(providerId, "act")
|
||||
|
||||
expect(mocks.getModelsFile().providers["openai-compatible"]?.models?.["known-model"]).toBeUndefined()
|
||||
expect(selection?.overrides).toBeUndefined()
|
||||
expect(selection?.modelInfo).toMatchObject({
|
||||
name: "Current Catalog Model",
|
||||
contextWindow: 256_000,
|
||||
temperature: 0.1,
|
||||
})
|
||||
expect(syncStoredProviderRegistration).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("migrates separate Plan and Act legacy custom models independently", async () => {
|
||||
mocks.setApiConfiguration({
|
||||
planActSeparateModelsSetting: true,
|
||||
planModeOpenAiModelId: "legacy-plan",
|
||||
planModeOpenAiModelInfo: {
|
||||
...openAiModelInfoSafeDefaults,
|
||||
contextWindow: 64_000,
|
||||
apiFormat: ApiFormat.OPENAI_RESPONSES,
|
||||
},
|
||||
actModeOpenAiModelId: "legacy-act",
|
||||
actModeOpenAiModelInfo: {
|
||||
...openAiModelInfoSafeDefaults,
|
||||
maxTokens: 2_048,
|
||||
isR1FormatRequired: true,
|
||||
},
|
||||
})
|
||||
const { createProviderConfigStore } = await import("./store")
|
||||
const store = createProviderConfigStore()
|
||||
const providerId = parseProviderId("openai")
|
||||
|
||||
const plan = store.readSelection(providerId, "plan")
|
||||
const act = store.readSelection(providerId, "act")
|
||||
store.readSelection(providerId, "plan")
|
||||
store.readSelection(providerId, "act")
|
||||
|
||||
expect(mocks.getModelsFile().providers["openai-compatible"]?.models).toMatchObject({
|
||||
"legacy-plan": { contextWindow: 64_000, apiFormat: "openai-responses" },
|
||||
"legacy-act": { maxTokens: 2_048, isR1FormatRequired: true },
|
||||
})
|
||||
expect(plan?.modelInfo).toMatchObject({ contextWindow: 64_000, apiFormat: ApiFormat.OPENAI_RESPONSES })
|
||||
expect(act?.modelInfo).toMatchObject({ maxTokens: 2_048, apiFormat: ApiFormat.R1_CHAT })
|
||||
expect(syncStoredProviderRegistration).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it("preserves migrated OpenAI Compatible settings when committing model selections", async () => {
|
||||
const { createProviderConfigStore } = await import("./store")
|
||||
mocks.setProviderSettings({
|
||||
@@ -237,7 +485,7 @@ describe("createProviderConfigStore", () => {
|
||||
})
|
||||
const store = createProviderConfigStore()
|
||||
const providerId = parseProviderId("openai")
|
||||
const selection = { providerId, modelId: "gpt-oss-120b", modelInfo: modelInfoA }
|
||||
const selection = selectionFromModelInfo(providerId, "gpt-oss-120b", modelInfoA)
|
||||
|
||||
store.commitSelection(providerId, "act", selection)
|
||||
|
||||
@@ -248,6 +496,242 @@ describe("createProviderConfigStore", () => {
|
||||
})
|
||||
})
|
||||
|
||||
it("preserves per-model OpenAI Compatible overrides when switching models without new overrides", async () => {
|
||||
const { createProviderConfigStore } = await import("./store")
|
||||
const store = createProviderConfigStore()
|
||||
const providerId = parseProviderId("openai")
|
||||
const modelASelection = selectionFromModelInfo(providerId, "model-a", modelInfoA)
|
||||
|
||||
store.commitSelection(providerId, "act", modelASelection)
|
||||
store.commitSelection(providerId, "act", { providerId, modelId: "model-b" })
|
||||
store.commitSelection(providerId, "act", { providerId, modelId: "model-a" })
|
||||
|
||||
expectResolvedSelection(store.readSelection(providerId, "act"), modelASelection, modelInfoA)
|
||||
expect(mocks.getModelsFile().providers["openai-compatible"]?.models?.["model-a"]).toMatchObject({
|
||||
name: "Model A",
|
||||
maxTokens: 8_192,
|
||||
contextWindow: 128_000,
|
||||
})
|
||||
})
|
||||
|
||||
it("deletes a model entry when an explicit replacement override set is empty", async () => {
|
||||
const { createProviderConfigStore } = await import("./store")
|
||||
const store = createProviderConfigStore()
|
||||
const providerId = parseProviderId("openai")
|
||||
|
||||
store.commitSelection(providerId, "act", {
|
||||
providerId,
|
||||
modelId: "custom-model",
|
||||
overrides: {
|
||||
apiFormat: ApiFormat.OPENAI_RESPONSES,
|
||||
capabilities: ["tools", "streaming"],
|
||||
temperature: 0.2,
|
||||
},
|
||||
})
|
||||
expect(mocks.getModelsFile().providers["openai-compatible"]?.models?.["custom-model"]).toMatchObject({
|
||||
apiFormat: "openai-responses",
|
||||
capabilities: ["tools", "streaming"],
|
||||
temperature: 0.2,
|
||||
})
|
||||
|
||||
store.commitSelection(providerId, "act", { providerId, modelId: "custom-model", overrides: {} })
|
||||
|
||||
expect(mocks.getModelsFile().providers["openai-compatible"]?.models?.["custom-model"]).toBeUndefined()
|
||||
expect(store.readSelection(providerId, "act")?.overrides).toBeUndefined()
|
||||
})
|
||||
|
||||
it("replaces an existing model override set instead of merging stale fields", async () => {
|
||||
const { createProviderConfigStore } = await import("./store")
|
||||
const store = createProviderConfigStore()
|
||||
const providerId = parseProviderId("openai")
|
||||
|
||||
store.commitSelection(providerId, "act", {
|
||||
providerId,
|
||||
modelId: "custom-model",
|
||||
overrides: { apiFormat: ApiFormat.OPENAI_RESPONSES, inputPrice: 1, temperature: 0.2 },
|
||||
})
|
||||
store.commitSelection(providerId, "act", {
|
||||
providerId,
|
||||
modelId: "custom-model",
|
||||
overrides: { temperature: 0.4 },
|
||||
})
|
||||
|
||||
expect(mocks.getModelsFile().providers["openai-compatible"]?.models?.["custom-model"]).toEqual({ temperature: 0.4 })
|
||||
expect(store.readSelection(providerId, "act")?.overrides).toEqual({ temperature: 0.4 })
|
||||
})
|
||||
|
||||
it.each([
|
||||
[ApiFormat.OPENAI_CHAT, "default"],
|
||||
[ApiFormat.R1_CHAT, "r1"],
|
||||
[ApiFormat.OPENAI_RESPONSES, "openai-responses"],
|
||||
] as const)("round-trips supported apiFormat %s through models.json", async (apiFormat, storedApiFormat) => {
|
||||
const { createProviderConfigStore } = await import("./store")
|
||||
const store = createProviderConfigStore()
|
||||
const providerId = parseProviderId("openai")
|
||||
|
||||
store.commitSelection(providerId, "act", {
|
||||
providerId,
|
||||
modelId: "custom-model",
|
||||
overrides: { apiFormat },
|
||||
})
|
||||
|
||||
expect(mocks.getModelsFile().providers["openai-compatible"]?.models?.["custom-model"]).toEqual({
|
||||
apiFormat: storedApiFormat,
|
||||
})
|
||||
expect(store.readSelection(providerId, "act")?.overrides).toEqual({ apiFormat })
|
||||
})
|
||||
|
||||
it("normalizes invalid override values before storage and resolved legacy state", async () => {
|
||||
const { createProviderConfigStore } = await import("./store")
|
||||
const store = createProviderConfigStore()
|
||||
const providerId = parseProviderId("openai")
|
||||
|
||||
store.commitSelection(providerId, "act", {
|
||||
providerId,
|
||||
modelId: "custom-model",
|
||||
overrides: {
|
||||
name: "Custom model",
|
||||
maxTokens: -1,
|
||||
contextWindow: Number.POSITIVE_INFINITY,
|
||||
maxInputTokens: 0,
|
||||
capabilities: ["tools", "tools", "vision", "unknown"],
|
||||
supportsVision: false,
|
||||
supportsReasoning: false,
|
||||
inputPrice: Number.NaN,
|
||||
outputPrice: 2,
|
||||
cacheReadsPrice: 0,
|
||||
cacheWritesPrice: -1,
|
||||
temperature: -1,
|
||||
apiFormat: 999 as ApiFormat,
|
||||
},
|
||||
})
|
||||
|
||||
expect(mocks.getModelsFile().providers["openai-compatible"]?.models?.["custom-model"]).toEqual({
|
||||
name: "Custom model",
|
||||
capabilities: ["tools"],
|
||||
supportsVision: false,
|
||||
supportsReasoning: false,
|
||||
outputPrice: 2,
|
||||
cacheReadsPrice: 0,
|
||||
})
|
||||
const selection = store.readSelection(providerId, "act")
|
||||
expect(selection?.overrides).toEqual({
|
||||
name: "Custom model",
|
||||
capabilities: ["tools"],
|
||||
supportsVision: false,
|
||||
supportsReasoning: false,
|
||||
outputPrice: 2,
|
||||
cacheReadsPrice: 0,
|
||||
})
|
||||
expect(selection?.modelInfo.maxTokens).toBeUndefined()
|
||||
expect(selection?.modelInfo.temperature).toBe(0)
|
||||
expect(mocks.getApiConfiguration().actModeOpenAiModelInfo).not.toHaveProperty("maxTokens")
|
||||
expect(mocks.getApiConfiguration().actModeOpenAiModelInfo).not.toHaveProperty("temperature", -1)
|
||||
expect(syncStoredProviderRegistration).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it("deletes a stored entry when normalization removes every replacement field", async () => {
|
||||
const { createProviderConfigStore } = await import("./store")
|
||||
const store = createProviderConfigStore()
|
||||
const providerId = parseProviderId("openai")
|
||||
|
||||
store.commitSelection(providerId, "act", {
|
||||
providerId,
|
||||
modelId: "custom-model",
|
||||
overrides: { temperature: 0.2 },
|
||||
})
|
||||
store.commitSelection(providerId, "act", {
|
||||
providerId,
|
||||
modelId: "custom-model",
|
||||
overrides: {
|
||||
maxTokens: -1,
|
||||
contextWindow: 0,
|
||||
capabilities: ["vision", "unknown"],
|
||||
inputPrice: Number.NaN,
|
||||
temperature: -1,
|
||||
apiFormat: 999 as ApiFormat,
|
||||
},
|
||||
})
|
||||
|
||||
expect(mocks.getModelsFile().providers["openai-compatible"]?.models?.["custom-model"]).toBeUndefined()
|
||||
expect(store.readSelection(providerId, "act")?.overrides).toBeUndefined()
|
||||
expect(syncStoredProviderRegistration).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it("normalizes invalid values already present in models.json on read", async () => {
|
||||
const { createProviderConfigStore } = await import("./store")
|
||||
mocks.setProviderSettings({
|
||||
"openai-compatible": { provider: "openai-compatible", model: "custom-model" },
|
||||
})
|
||||
mocks.setModelsFile({
|
||||
version: 1,
|
||||
providers: {
|
||||
"openai-compatible": {
|
||||
models: {
|
||||
"custom-model": {
|
||||
maxTokens: -1,
|
||||
contextWindow: 64_000,
|
||||
inputPrice: -2,
|
||||
temperature: -1,
|
||||
capabilities: ["tools"],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
const store = createProviderConfigStore()
|
||||
const providerId = parseProviderId("openai")
|
||||
|
||||
const selection = store.readSelection(providerId, "act")
|
||||
|
||||
expect(selection?.overrides).toEqual({ contextWindow: 64_000, capabilities: ["tools"] })
|
||||
expect(selection?.modelInfo.maxTokens).toBeUndefined()
|
||||
expect(selection?.modelInfo.temperature).toBe(0)
|
||||
expect(syncStoredProviderRegistration).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("lets explicit capability booleans win and applies the R1 alias deterministically", async () => {
|
||||
const { createProviderConfigStore } = await import("./store")
|
||||
const store = createProviderConfigStore()
|
||||
const providerId = parseProviderId("openai")
|
||||
|
||||
store.commitSelection(providerId, "act", {
|
||||
providerId,
|
||||
modelId: "custom-model",
|
||||
overrides: {
|
||||
apiFormat: ApiFormat.OPENAI_RESPONSES,
|
||||
capabilities: ["images", "prompt-cache", "reasoning"],
|
||||
supportsVision: false,
|
||||
supportsReasoning: false,
|
||||
isR1FormatRequired: false,
|
||||
},
|
||||
})
|
||||
let selection = store.readSelection(providerId, "act")
|
||||
expect(selection?.modelInfo).toMatchObject({
|
||||
supportsImages: false,
|
||||
supportsPromptCache: true,
|
||||
supportsReasoning: false,
|
||||
apiFormat: ApiFormat.OPENAI_RESPONSES,
|
||||
})
|
||||
|
||||
store.commitSelection(providerId, "act", {
|
||||
providerId,
|
||||
modelId: "custom-model",
|
||||
overrides: {
|
||||
apiFormat: ApiFormat.OPENAI_RESPONSES,
|
||||
capabilities: ["prompt-cache"],
|
||||
supportsVision: true,
|
||||
isR1FormatRequired: true,
|
||||
},
|
||||
})
|
||||
selection = store.readSelection(providerId, "act")
|
||||
expect(selection?.modelInfo).toMatchObject({
|
||||
supportsImages: true,
|
||||
supportsPromptCache: true,
|
||||
apiFormat: ApiFormat.R1_CHAT,
|
||||
})
|
||||
})
|
||||
|
||||
it("keeps OpenAI Compatible Plan and Act selections independent when separate models are enabled", async () => {
|
||||
const { createProviderConfigStore } = await import("./store")
|
||||
mocks.setApiConfiguration({ planActSeparateModelsSetting: true })
|
||||
@@ -259,14 +743,14 @@ describe("createProviderConfigStore", () => {
|
||||
})
|
||||
const store = createProviderConfigStore()
|
||||
const providerId = parseProviderId("openai")
|
||||
const planSelection = { providerId, modelId: "plan-openai-model", modelInfo: modelInfoA }
|
||||
const actSelection = { providerId, modelId: "act-openai-model", modelInfo: modelInfoB }
|
||||
const planSelection = selectionFromModelInfo(providerId, "plan-openai-model", modelInfoA)
|
||||
const actSelection = selectionFromModelInfo(providerId, "act-openai-model", modelInfoB)
|
||||
|
||||
store.commitSelection(providerId, "plan", planSelection)
|
||||
store.commitSelection(providerId, "act", actSelection)
|
||||
|
||||
expect(store.readSelection(providerId, "plan")).toEqual(planSelection)
|
||||
expect(store.readSelection(providerId, "act")).toEqual(actSelection)
|
||||
expectResolvedSelection(store.readSelection(providerId, "plan"), planSelection, modelInfoA)
|
||||
expectResolvedSelection(store.readSelection(providerId, "act"), actSelection, modelInfoB)
|
||||
expect(mocks.getApiConfiguration()).toMatchObject({
|
||||
planModeOpenAiModelId: "plan-openai-model",
|
||||
planModeOpenAiModelInfo: modelInfoA,
|
||||
@@ -286,12 +770,12 @@ describe("createProviderConfigStore", () => {
|
||||
mocks.setApiConfiguration({ planActSeparateModelsSetting: false })
|
||||
const store = createProviderConfigStore()
|
||||
const providerId = parseProviderId("openai")
|
||||
const selection = { providerId, modelId: "shared-openai-model", modelInfo: modelInfoA }
|
||||
const selection = selectionFromModelInfo(providerId, "shared-openai-model", modelInfoA)
|
||||
|
||||
store.commitSelection(providerId, "act", selection)
|
||||
|
||||
expect(store.readSelection(providerId, "plan")).toEqual(selection)
|
||||
expect(store.readSelection(providerId, "act")).toEqual(selection)
|
||||
expectResolvedSelection(store.readSelection(providerId, "plan"), selection, modelInfoA)
|
||||
expectResolvedSelection(store.readSelection(providerId, "act"), selection, modelInfoA)
|
||||
expect(mocks.getApiConfiguration()).toMatchObject({
|
||||
planModeOpenAiModelId: "shared-openai-model",
|
||||
planModeOpenAiModelInfo: modelInfoA,
|
||||
@@ -320,14 +804,22 @@ describe("createProviderConfigStore", () => {
|
||||
expect(mocks.getApiConfiguration().zaiApiKey).toBe("shared-zai-key")
|
||||
})
|
||||
|
||||
it("returns undefined from readSelection when modelId or modelInfo is missing", async () => {
|
||||
it("resolves a bare state modelId with fallback metadata and ignores a bare modelInfo", async () => {
|
||||
const { createProviderConfigStore } = await import("./store")
|
||||
const store = createProviderConfigStore()
|
||||
const providerId = parseProviderId("openrouter")
|
||||
|
||||
// The mode-specific model id alone identifies the selection; commits
|
||||
// whose resolution was pure fallback intentionally leave the state
|
||||
// modelInfo snapshot unset.
|
||||
mocks.setApiConfiguration({ actModeOpenRouterModelId: "anthropic/claude-sonnet-4" })
|
||||
expect(store.readSelection(providerId, "act")).toBeUndefined()
|
||||
expect(store.readSelection(providerId, "act")).toMatchObject({
|
||||
providerId,
|
||||
modelId: "anthropic/claude-sonnet-4",
|
||||
modelInfoSource: "fallback",
|
||||
})
|
||||
|
||||
// A modelInfo snapshot without a model id is not a selection.
|
||||
mocks.setApiConfiguration({ actModeOpenRouterModelInfo: modelInfoA })
|
||||
expect(store.readSelection(providerId, "act")).toBeUndefined()
|
||||
})
|
||||
@@ -337,14 +829,14 @@ describe("createProviderConfigStore", () => {
|
||||
mocks.setApiConfiguration({ planActSeparateModelsSetting: true })
|
||||
const store = createProviderConfigStore()
|
||||
const providerId = parseProviderId("openrouter")
|
||||
const planSelection = { providerId, modelId: "provider/model-a", modelInfo: modelInfoA }
|
||||
const actSelection = { providerId, modelId: "provider/model-b", modelInfo: modelInfoB }
|
||||
const planSelection = selectionFromModelInfo(providerId, "provider/model-a", modelInfoA)
|
||||
const actSelection = selectionFromModelInfo(providerId, "provider/model-b", modelInfoB)
|
||||
|
||||
store.commitSelection(providerId, "plan", planSelection)
|
||||
store.commitSelection(providerId, "act", actSelection)
|
||||
|
||||
expect(store.readSelection(providerId, "plan")).toEqual(planSelection)
|
||||
expect(store.readSelection(providerId, "act")).toEqual(actSelection)
|
||||
expectResolvedSelection(store.readSelection(providerId, "plan"), planSelection, modelInfoA)
|
||||
expectResolvedSelection(store.readSelection(providerId, "act"), actSelection, modelInfoB)
|
||||
expect(mocks.getSavedProviderSettings("openrouter")).toMatchObject({
|
||||
provider: "openrouter",
|
||||
model: "provider/model-b",
|
||||
@@ -361,7 +853,7 @@ describe("createProviderConfigStore", () => {
|
||||
})
|
||||
const store = createProviderConfigStore()
|
||||
const providerId = parseProviderId("openrouter")
|
||||
const selection = { providerId, modelId: "provider/model-a", modelInfo: modelInfoA }
|
||||
const selection = selectionFromModelInfo(providerId, "provider/model-a", modelInfoA)
|
||||
|
||||
store.commitSelection(providerId, "act", selection)
|
||||
|
||||
@@ -381,7 +873,7 @@ describe("createProviderConfigStore", () => {
|
||||
const { createProviderConfigStore } = await import("./store")
|
||||
const store = createProviderConfigStore()
|
||||
const providerId = parseProviderId("claude-code")
|
||||
const selection = { providerId, modelId: "haiku", modelInfo: modelInfoA }
|
||||
const selection = selectionFromModelInfo(providerId, "haiku", modelInfoA)
|
||||
|
||||
store.commitSelection(providerId, "act", selection)
|
||||
|
||||
@@ -423,7 +915,7 @@ describe("createProviderConfigStore", () => {
|
||||
const store = createProviderConfigStore()
|
||||
const providerId = parseProviderId("openrouter")
|
||||
const events: ProviderConfigChange[] = []
|
||||
const selection = { providerId, modelId: "provider/model-a", modelInfo: modelInfoA }
|
||||
const selection = selectionFromModelInfo(providerId, "provider/model-a", modelInfoA)
|
||||
|
||||
store.subscribe((event) => events.push(event))
|
||||
store.write(providerId, { apiKey: "openrouter-key" })
|
||||
@@ -431,7 +923,12 @@ describe("createProviderConfigStore", () => {
|
||||
|
||||
expect(events.map((event) => event.kind)).toEqual(["fields", "selection"])
|
||||
expect(events[0]).toMatchObject({ kind: "fields", providerId })
|
||||
expect(events[1]).toEqual({ kind: "selection", providerId, mode: "act", selection })
|
||||
expect(events[1]).toEqual({
|
||||
kind: "selection",
|
||||
providerId,
|
||||
mode: "act",
|
||||
selection: store.readSelection(providerId, "act"),
|
||||
})
|
||||
})
|
||||
|
||||
it("dispose unregisters listeners", async () => {
|
||||
@@ -446,4 +943,50 @@ describe("createProviderConfigStore", () => {
|
||||
|
||||
expect(listener).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// Contract test against the REAL SDK schemas (imported by relative path,
|
||||
// bypassing the @cline/core mock above): the store's converters must pass
|
||||
// every SDK capability through, and a fully-populated stored entry must
|
||||
// parse under the schema `writeModelsFileSync` enforces in production.
|
||||
it("round-trips every SDK model capability and a full override set under the real stored-entry schema", async () => {
|
||||
const { ModelCapabilitySchema } = await import("@cline/shared")
|
||||
// vi.importActual bypasses the @cline/core mock above and resolves via
|
||||
// the vitest alias to the stub, which re-exports the real schema.
|
||||
const { StoredModelEntrySchema } = (await vi.importActual("@cline/core")) as {
|
||||
StoredModelEntrySchema: { parse(input: unknown): unknown }
|
||||
}
|
||||
const { createProviderConfigStore } = await import("./store")
|
||||
const store = createProviderConfigStore()
|
||||
const providerId = parseProviderId("openai")
|
||||
|
||||
store.commitSelection(providerId, "act", {
|
||||
providerId,
|
||||
modelId: "contract-model",
|
||||
overrides: {
|
||||
name: "Contract Model",
|
||||
maxTokens: 1024,
|
||||
contextWindow: 200_000,
|
||||
maxInputTokens: 100_000,
|
||||
capabilities: [...ModelCapabilitySchema.options],
|
||||
supportsVision: true,
|
||||
supportsAttachments: true,
|
||||
supportsReasoning: true,
|
||||
inputPrice: 0.5,
|
||||
outputPrice: 1.5,
|
||||
cacheReadsPrice: 0.1,
|
||||
cacheWritesPrice: 0.2,
|
||||
temperature: 0.7,
|
||||
apiFormat: ApiFormat.OPENAI_RESPONSES,
|
||||
isR1FormatRequired: true,
|
||||
},
|
||||
})
|
||||
|
||||
const entry = mocks.getModelsFile().providers["openai-compatible"]?.models?.["contract-model"]
|
||||
expect(entry).toBeDefined()
|
||||
// No SDK capability may be silently stripped by the store's converter.
|
||||
expect([...(entry?.capabilities as string[])].sort()).toEqual([...ModelCapabilitySchema.options].sort())
|
||||
// The entry written by the extension must satisfy the real schema that
|
||||
// the SDK's writeModelsFileSync enforces.
|
||||
expect(() => StoredModelEntrySchema.parse(entry)).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
import {
|
||||
readModelsFileSync,
|
||||
resolveModelsRegistryPath,
|
||||
type StoredModelEntry,
|
||||
syncStoredProviderRegistration,
|
||||
writeModelsFileSync,
|
||||
} from "@cline/core"
|
||||
import { getGeneratedModelsForProvider, MODEL_COLLECTIONS_BY_PROVIDER_ID } from "@cline/llms"
|
||||
import { ModelCapabilitySchema } from "@cline/shared"
|
||||
import { type ApiConfiguration, type ApiProvider, type ModelInfo, openAiModelInfoSafeDefaults } from "@shared/api"
|
||||
import { ApiFormat } from "@shared/proto/cline/models"
|
||||
import { Logger } from "@shared/services/Logger"
|
||||
import { getProviderModelIdKey } from "@shared/storage/provider-keys"
|
||||
import { isSecretKey, isSettingsKey, type SecretKey, type SettingsKey } from "@shared/storage/state-keys"
|
||||
import { StateManager } from "@/core/storage/StateManager"
|
||||
@@ -9,14 +19,17 @@ import type {
|
||||
EffectiveProviderConfig,
|
||||
Mode,
|
||||
ModelSelection,
|
||||
ModelSelectionOverrides,
|
||||
ProviderConfigChange,
|
||||
ProviderConfigChangeListener,
|
||||
ProviderConfigPatch,
|
||||
ProviderConfigStore,
|
||||
ProviderId,
|
||||
ResolvedModelSelection,
|
||||
} from "./contracts"
|
||||
import { buildEffectiveProviderConfig } from "./effective-config"
|
||||
import { applyHostModelInfoOverrides } from "./host-overrides"
|
||||
import { fromSdkApiFormat, nonNegativeFiniteNumber, positiveFiniteNumber, toSdkApiFormat } from "./model-values"
|
||||
import { toSdkProviderId } from "./sdk-provider-id"
|
||||
import { adaptSdkModelInfo } from "./shape-adapter"
|
||||
|
||||
@@ -113,7 +126,7 @@ const modelInfoKeysByProvider: Partial<Record<string, ModelInfoKeys>> = {
|
||||
// provider+mode so that switching between providers that share the same
|
||||
// `*ModeApiModelId` key does not combine one provider's model id with
|
||||
// another provider's model info.
|
||||
const selectionMemory = new Map<string, ModelSelection>()
|
||||
const selectionMemory = new Map<string, ResolvedModelSelection>()
|
||||
|
||||
function providerKey(providerId: ProviderId): string {
|
||||
return providerId.toString()
|
||||
@@ -168,11 +181,210 @@ function readProviderSettingsModelId(providerId: ProviderId): string | undefined
|
||||
return typeof model === "string" && model.trim().length > 0 ? model.trim() : undefined
|
||||
}
|
||||
|
||||
function fallbackModelInfo(modelId: string): ModelInfo {
|
||||
return { ...openAiModelInfoSafeDefaults, name: modelId }
|
||||
function sanitizeResolvedModelInfo(modelInfo: ModelInfo): ModelInfo {
|
||||
const next = { ...modelInfo }
|
||||
if (positiveFiniteNumber(next.maxTokens) === undefined) delete next.maxTokens
|
||||
if (nonNegativeFiniteNumber(next.temperature) === undefined) delete next.temperature
|
||||
return next
|
||||
}
|
||||
|
||||
function readKnownModelInfoForProvider(providerId: ProviderId, modelId: string): ModelInfo | undefined {
|
||||
function fallbackModelInfo(modelId: string): ModelInfo {
|
||||
return sanitizeResolvedModelInfo({ ...openAiModelInfoSafeDefaults, name: modelId })
|
||||
}
|
||||
|
||||
function toStoredCapabilities(capabilities: readonly string[] | undefined): StoredModelEntry["capabilities"] | undefined {
|
||||
if (!capabilities) {
|
||||
return undefined
|
||||
}
|
||||
// Validate against the SDK schema rather than a hardcoded list so new
|
||||
// capabilities added to ModelCapabilitySchema are never silently stripped.
|
||||
const next = new Set<NonNullable<StoredModelEntry["capabilities"]>[number]>()
|
||||
for (const capability of capabilities) {
|
||||
const parsed = ModelCapabilitySchema.safeParse(capability)
|
||||
if (parsed.success) {
|
||||
next.add(parsed.data)
|
||||
}
|
||||
}
|
||||
return next.size > 0 ? [...next] : undefined
|
||||
}
|
||||
|
||||
function toStoredApiFormat(apiFormat: ModelInfo["apiFormat"]): StoredModelEntry["apiFormat"] | undefined {
|
||||
return toSdkApiFormat(apiFormat)
|
||||
}
|
||||
|
||||
function fromStoredApiFormat(apiFormat: StoredModelEntry["apiFormat"]): ModelInfo["apiFormat"] | undefined {
|
||||
return fromSdkApiFormat(apiFormat)
|
||||
}
|
||||
|
||||
function readModelsState() {
|
||||
return readModelsFileSync(resolveModelsRegistryPath(getProviderSettingsManager()))
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes user-authored model metadata at the host/storage boundary.
|
||||
* Token limits must be positive, prices and temperatures non-negative, and
|
||||
* unsupported capabilities/formats are omitted. UI sentinels never cross
|
||||
* this boundary; an object with no meaningful fields becomes undefined.
|
||||
*/
|
||||
function normalizeModelSelectionOverrides(overrides: ModelSelectionOverrides | undefined): ModelSelectionOverrides | undefined {
|
||||
if (!overrides) {
|
||||
return undefined
|
||||
}
|
||||
const maxTokens = positiveFiniteNumber(overrides.maxTokens)
|
||||
const contextWindow = positiveFiniteNumber(overrides.contextWindow)
|
||||
const maxInputTokens = positiveFiniteNumber(overrides.maxInputTokens)
|
||||
const capabilities = toStoredCapabilities(overrides.capabilities)
|
||||
const inputPrice = nonNegativeFiniteNumber(overrides.inputPrice)
|
||||
const outputPrice = nonNegativeFiniteNumber(overrides.outputPrice)
|
||||
const cacheReadsPrice = nonNegativeFiniteNumber(overrides.cacheReadsPrice)
|
||||
const cacheWritesPrice = nonNegativeFiniteNumber(overrides.cacheWritesPrice)
|
||||
const temperature = nonNegativeFiniteNumber(overrides.temperature)
|
||||
const apiFormat = toStoredApiFormat(overrides.apiFormat) !== undefined ? overrides.apiFormat : undefined
|
||||
const next: ModelSelectionOverrides = {
|
||||
...(overrides.name !== undefined ? { name: overrides.name } : {}),
|
||||
...(maxTokens !== undefined ? { maxTokens } : {}),
|
||||
...(contextWindow !== undefined ? { contextWindow } : {}),
|
||||
...(maxInputTokens !== undefined ? { maxInputTokens } : {}),
|
||||
...(capabilities !== undefined ? { capabilities } : {}),
|
||||
...(overrides.supportsVision !== undefined ? { supportsVision: overrides.supportsVision } : {}),
|
||||
...(overrides.supportsAttachments !== undefined ? { supportsAttachments: overrides.supportsAttachments } : {}),
|
||||
...(overrides.supportsReasoning !== undefined ? { supportsReasoning: overrides.supportsReasoning } : {}),
|
||||
...(inputPrice !== undefined ? { inputPrice } : {}),
|
||||
...(outputPrice !== undefined ? { outputPrice } : {}),
|
||||
...(cacheReadsPrice !== undefined ? { cacheReadsPrice } : {}),
|
||||
...(cacheWritesPrice !== undefined ? { cacheWritesPrice } : {}),
|
||||
...(temperature !== undefined ? { temperature } : {}),
|
||||
...(apiFormat !== undefined ? { apiFormat } : {}),
|
||||
...(overrides.isR1FormatRequired !== undefined ? { isR1FormatRequired: overrides.isR1FormatRequired } : {}),
|
||||
}
|
||||
return Object.keys(next).length > 0 ? next : undefined
|
||||
}
|
||||
|
||||
function toStoredModelEntry(overrides: ModelSelectionOverrides): StoredModelEntry {
|
||||
const capabilities = toStoredCapabilities(overrides.capabilities)
|
||||
const apiFormat = toStoredApiFormat(overrides.apiFormat)
|
||||
return {
|
||||
...(overrides.name !== undefined ? { name: overrides.name } : {}),
|
||||
...(overrides.maxTokens !== undefined ? { maxTokens: overrides.maxTokens } : {}),
|
||||
...(overrides.contextWindow !== undefined ? { contextWindow: overrides.contextWindow } : {}),
|
||||
...(overrides.maxInputTokens !== undefined ? { maxInputTokens: overrides.maxInputTokens } : {}),
|
||||
...(capabilities !== undefined ? { capabilities } : {}),
|
||||
...(overrides.supportsVision !== undefined ? { supportsVision: overrides.supportsVision } : {}),
|
||||
...(overrides.supportsAttachments !== undefined ? { supportsAttachments: overrides.supportsAttachments } : {}),
|
||||
...(overrides.supportsReasoning !== undefined ? { supportsReasoning: overrides.supportsReasoning } : {}),
|
||||
...(overrides.inputPrice !== undefined ? { inputPrice: overrides.inputPrice } : {}),
|
||||
...(overrides.outputPrice !== undefined ? { outputPrice: overrides.outputPrice } : {}),
|
||||
...(overrides.cacheReadsPrice !== undefined ? { cacheReadsPrice: overrides.cacheReadsPrice } : {}),
|
||||
...(overrides.cacheWritesPrice !== undefined ? { cacheWritesPrice: overrides.cacheWritesPrice } : {}),
|
||||
...(overrides.temperature !== undefined ? { temperature: overrides.temperature } : {}),
|
||||
...(apiFormat !== undefined ? { apiFormat } : {}),
|
||||
...(overrides.isR1FormatRequired !== undefined ? { isR1FormatRequired: overrides.isR1FormatRequired } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
function toSelectionOverrides(entry: StoredModelEntry | undefined): ModelSelectionOverrides | undefined {
|
||||
if (!entry) {
|
||||
return undefined
|
||||
}
|
||||
const apiFormat = fromStoredApiFormat(entry.apiFormat)
|
||||
return normalizeModelSelectionOverrides({
|
||||
...(entry.name !== undefined ? { name: entry.name } : {}),
|
||||
...(entry.maxTokens !== undefined ? { maxTokens: entry.maxTokens } : {}),
|
||||
...(entry.contextWindow !== undefined ? { contextWindow: entry.contextWindow } : {}),
|
||||
...(entry.maxInputTokens !== undefined ? { maxInputTokens: entry.maxInputTokens } : {}),
|
||||
...(entry.capabilities !== undefined ? { capabilities: [...entry.capabilities] } : {}),
|
||||
...(entry.supportsVision !== undefined ? { supportsVision: entry.supportsVision } : {}),
|
||||
...(entry.supportsAttachments !== undefined ? { supportsAttachments: entry.supportsAttachments } : {}),
|
||||
...(entry.supportsReasoning !== undefined ? { supportsReasoning: entry.supportsReasoning } : {}),
|
||||
...(entry.inputPrice !== undefined ? { inputPrice: entry.inputPrice } : {}),
|
||||
...(entry.outputPrice !== undefined ? { outputPrice: entry.outputPrice } : {}),
|
||||
...(entry.cacheReadsPrice !== undefined ? { cacheReadsPrice: entry.cacheReadsPrice } : {}),
|
||||
...(entry.cacheWritesPrice !== undefined ? { cacheWritesPrice: entry.cacheWritesPrice } : {}),
|
||||
...(entry.temperature !== undefined ? { temperature: entry.temperature } : {}),
|
||||
...(apiFormat !== undefined ? { apiFormat } : {}),
|
||||
...(entry.isR1FormatRequired !== undefined ? { isR1FormatRequired: entry.isR1FormatRequired } : {}),
|
||||
})
|
||||
}
|
||||
|
||||
function readStoredModelEntry(providerId: ProviderId, modelId: string): { exists: boolean; entry: StoredModelEntry | undefined } {
|
||||
const models = readModelsState().providers[providerSettingsProviderId(providerId)]?.models
|
||||
return {
|
||||
exists: models ? Object.hasOwn(models, modelId) : false,
|
||||
entry: models?.[modelId],
|
||||
}
|
||||
}
|
||||
|
||||
function readModelOverrides(providerId: ProviderId, modelId: string): ModelSelectionOverrides | undefined {
|
||||
return toSelectionOverrides(readStoredModelEntry(providerId, modelId).entry)
|
||||
}
|
||||
|
||||
function writeModelOverrides(providerId: ProviderId, modelId: string, overrides: ModelSelectionOverrides | undefined): void {
|
||||
const modelsPath = resolveModelsRegistryPath(getProviderSettingsManager())
|
||||
const state = readModelsFileSync(modelsPath)
|
||||
const provider = providerSettingsProviderId(providerId)
|
||||
const providerEntry = state.providers[provider] ?? {}
|
||||
const nextModels = { ...(providerEntry.models ?? {}) }
|
||||
const normalizedOverrides = normalizeModelSelectionOverrides(overrides)
|
||||
const storedEntry = normalizedOverrides ? toStoredModelEntry(normalizedOverrides) : undefined
|
||||
if (storedEntry && Object.keys(storedEntry).length > 0) {
|
||||
nextModels[modelId] = storedEntry
|
||||
} else {
|
||||
delete nextModels[modelId]
|
||||
}
|
||||
const nextProviderEntry = {
|
||||
...providerEntry,
|
||||
models: nextModels,
|
||||
}
|
||||
writeModelsFileSync(modelsPath, {
|
||||
...state,
|
||||
providers: {
|
||||
...state.providers,
|
||||
[provider]: nextProviderEntry,
|
||||
},
|
||||
})
|
||||
// ensureCustomProvidersLoadedSync is load-once per path and would no-op
|
||||
// here; sync the live registry explicitly so this write is visible to new
|
||||
// sessions without a restart.
|
||||
syncStoredProviderRegistration(provider, state.providers[provider], nextProviderEntry)
|
||||
}
|
||||
|
||||
function applyModelOverrides(modelInfo: ModelInfo, overrides: ModelSelectionOverrides | undefined): ModelInfo {
|
||||
if (!overrides) {
|
||||
return modelInfo
|
||||
}
|
||||
const next: ModelInfo = { ...modelInfo }
|
||||
if (overrides.name !== undefined) next.name = overrides.name
|
||||
if (overrides.maxTokens !== undefined) next.maxTokens = overrides.maxTokens
|
||||
if (overrides.contextWindow !== undefined) next.contextWindow = overrides.contextWindow
|
||||
if (overrides.maxInputTokens !== undefined)
|
||||
(next as ModelInfo & { maxInputTokens?: number }).maxInputTokens = overrides.maxInputTokens
|
||||
if (overrides.inputPrice !== undefined) next.inputPrice = overrides.inputPrice
|
||||
if (overrides.outputPrice !== undefined) next.outputPrice = overrides.outputPrice
|
||||
if (overrides.cacheReadsPrice !== undefined) next.cacheReadsPrice = overrides.cacheReadsPrice
|
||||
if (overrides.cacheWritesPrice !== undefined) next.cacheWritesPrice = overrides.cacheWritesPrice
|
||||
if (overrides.temperature !== undefined) next.temperature = overrides.temperature
|
||||
if (overrides.apiFormat !== undefined) next.apiFormat = overrides.apiFormat
|
||||
|
||||
// Capability arrays are additive fallback flags: they can only enable
|
||||
// capabilities the base metadata lacks, never disable base capabilities
|
||||
// (an array authored for one purpose, e.g. prompt-cache, must not strip
|
||||
// unrelated base flags like vision). Explicit booleans win when both
|
||||
// representations are present.
|
||||
if (overrides.capabilities !== undefined) {
|
||||
if (overrides.capabilities.includes("images")) next.supportsImages = true
|
||||
if (overrides.capabilities.includes("prompt-cache")) next.supportsPromptCache = true
|
||||
if (overrides.capabilities.includes("reasoning")) next.supportsReasoning = true
|
||||
}
|
||||
if (overrides.supportsVision !== undefined) next.supportsImages = overrides.supportsVision
|
||||
if (overrides.supportsReasoning !== undefined) next.supportsReasoning = overrides.supportsReasoning
|
||||
|
||||
// apiFormat is canonical. The legacy R1 flag remains a compatibility alias
|
||||
// that forces R1 only when explicitly true.
|
||||
if (overrides.isR1FormatRequired) next.apiFormat = ApiFormat.R1_CHAT
|
||||
return next
|
||||
}
|
||||
|
||||
function readBaseModelInfoForProvider(providerId: ProviderId, modelId: string): ModelInfo | undefined {
|
||||
const sdkProviderId = toSdkProviderId(providerId)
|
||||
const generatedModelInfo = getGeneratedModelsForProvider(sdkProviderId)[modelId]
|
||||
if (isModelInfo(generatedModelInfo)) {
|
||||
@@ -201,17 +413,36 @@ function readKnownModelInfoForProvider(providerId: ProviderId, modelId: string):
|
||||
return undefined
|
||||
}
|
||||
|
||||
function readSelectionFromProviderSettings(providerId: ProviderId): ModelSelection | undefined {
|
||||
function resolveSelection(selection: ModelSelection, stateModelInfoHint?: ModelInfo): ResolvedModelSelection {
|
||||
const overrides = normalizeModelSelectionOverrides(
|
||||
selection.overrides ?? readModelOverrides(selection.providerId, selection.modelId),
|
||||
)
|
||||
// Base resolution order: SDK catalog, then the picker's persisted state
|
||||
// snapshot (the only accurate data for dynamic-list models the static
|
||||
// catalog does not know), then provider-safe fallback defaults.
|
||||
const catalogModelInfo = readBaseModelInfoForProvider(selection.providerId, selection.modelId)
|
||||
const baseModelInfo = catalogModelInfo ?? stateModelInfoHint ?? fallbackModelInfo(selection.modelId)
|
||||
const modelInfoSource = catalogModelInfo ? "catalog" : stateModelInfoHint ? "state" : "fallback"
|
||||
return {
|
||||
...selection,
|
||||
overrides,
|
||||
modelInfoSource,
|
||||
baseModelInfo,
|
||||
modelInfo: sanitizeResolvedModelInfo(applyModelOverrides(baseModelInfo, overrides)),
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveRuntimeModelSelection(providerId: ProviderId, modelId: string): ResolvedModelSelection {
|
||||
return resolveSelection({ providerId, modelId })
|
||||
}
|
||||
|
||||
function readSelectionFromProviderSettings(providerId: ProviderId): ResolvedModelSelection | undefined {
|
||||
const modelId = readProviderSettingsModelId(providerId)
|
||||
if (!modelId) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return {
|
||||
providerId,
|
||||
modelId,
|
||||
modelInfo: readKnownModelInfoForProvider(providerId, modelId) ?? fallbackModelInfo(modelId),
|
||||
}
|
||||
return resolveSelection({ providerId, modelId })
|
||||
}
|
||||
|
||||
function writeStateKey(key: SecretKey | SettingsKey, value: unknown): void {
|
||||
@@ -281,6 +512,17 @@ function writeStateFields(providerId: ProviderId, patch: ProviderConfigPatch): v
|
||||
writeStateKey("clineApiKey", patch.auth?.accessToken)
|
||||
writeStateKey("clineAccountId", patch.auth?.accountId)
|
||||
}
|
||||
|
||||
// Mirror the Ollama context window to the legacy state key so older
|
||||
// readers (proto ApiConfiguration, webview display fallback) stay in sync
|
||||
// with providers.json.
|
||||
if (provider === "ollama" && "contextWindow" in patch) {
|
||||
const contextWindow = patch.contextWindow
|
||||
writeStateKey(
|
||||
"ollamaApiOptionsCtxNum",
|
||||
typeof contextWindow === "number" && contextWindow > 0 ? String(contextWindow) : undefined,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function getProviderSettings(providerId: ProviderId): ProviderSettingsRecord {
|
||||
@@ -330,6 +572,15 @@ function writeProviderSettingsFields(providerId: ProviderId, patch: ProviderConf
|
||||
}
|
||||
}
|
||||
|
||||
if ("contextWindow" in patch) {
|
||||
const contextWindow = patch.contextWindow
|
||||
if (typeof contextWindow === "number" && Number.isFinite(contextWindow) && contextWindow > 0) {
|
||||
next.contextWindow = Math.floor(contextWindow)
|
||||
} else {
|
||||
delete next.contextWindow
|
||||
}
|
||||
}
|
||||
|
||||
if ("aws" in patch) {
|
||||
const awsPatch = patch.aws
|
||||
if (awsPatch === null || awsPatch === undefined) {
|
||||
@@ -389,13 +640,27 @@ function syncedModes(mode: Mode): Mode[] {
|
||||
return StateManager.get().getGlobalSettingsKey("planActSeparateModelsSetting") ? [mode] : ["plan", "act"]
|
||||
}
|
||||
|
||||
function writeSelectionToState(providerId: ProviderId, mode: Mode, selection: ModelSelection): void {
|
||||
function writeSelectionToState(providerId: ProviderId, mode: Mode, selection: ResolvedModelSelection): void {
|
||||
const updates: Partial<Record<SettingsKey, unknown>> = {}
|
||||
for (const targetMode of syncedModes(mode)) {
|
||||
updates[getModelIdKey(providerId, targetMode)] = selection.modelId
|
||||
const modelInfoKey = getModelInfoKey(providerId, targetMode)
|
||||
if (modelInfoKey) {
|
||||
updates[modelInfoKey] = selection.modelInfo
|
||||
// For hint-eligible providers the snapshot must stay genuine base
|
||||
// metadata: never persist fabricated fallback data (later reads
|
||||
// would treat it as authoritative "state" data and shadow live
|
||||
// catalog lookups), and persist the pre-override base rather than
|
||||
// the resolved value (a deleted override must not be resurrected
|
||||
// from a snapshot it was baked into). openai-compatible keeps the
|
||||
// legacy resolved write — its snapshot is never used as a
|
||||
// resolution base, and old extension versions still read it after
|
||||
// a rollback.
|
||||
if (usesStateModelInfoHint(providerId)) {
|
||||
updates[modelInfoKey] =
|
||||
selection.modelInfoSource === "fallback" ? undefined : (selection.baseModelInfo ?? selection.modelInfo)
|
||||
} else {
|
||||
updates[modelInfoKey] = selection.modelInfo
|
||||
}
|
||||
}
|
||||
selectionMemory.set(memoryKey(providerId, targetMode), { ...selection, providerId })
|
||||
}
|
||||
@@ -404,28 +669,164 @@ function writeSelectionToState(providerId: ProviderId, mode: Mode, selection: Mo
|
||||
|
||||
function writeSelectionToProviderSettings(providerId: ProviderId, selection: ModelSelection): void {
|
||||
const next: ProviderSettingsRecord = { ...getProviderSettings(providerId), model: selection.modelId }
|
||||
// Prune model metadata that earlier builds may have written to providers.json.
|
||||
delete next.contextWindow
|
||||
// Prune model metadata that earlier builds may have written to
|
||||
// providers.json — except for Ollama, whose contextWindow is a real
|
||||
// user setting (maps to num_ctx) written by the settings UI.
|
||||
if (providerKey(providerId) !== "ollama") {
|
||||
delete next.contextWindow
|
||||
}
|
||||
delete next.maxTokens
|
||||
|
||||
saveProviderSettings(providerId, next)
|
||||
}
|
||||
|
||||
function readSelectionFromState(providerId: ProviderId, mode: Mode): ModelSelection | undefined {
|
||||
type LegacyModelInfo = ModelInfo & { maxInputTokens?: number; isR1FormatRequired?: boolean }
|
||||
type MutableModelSelectionOverrides = { -readonly [Key in keyof ModelSelectionOverrides]: ModelSelectionOverrides[Key] }
|
||||
|
||||
function legacyModelInfoToOverrides(modelInfo: LegacyModelInfo, fallback: ModelInfo): ModelSelectionOverrides | undefined {
|
||||
const fallbackInfo = fallback as LegacyModelInfo
|
||||
const overrides: MutableModelSelectionOverrides = {}
|
||||
if (modelInfo.name !== undefined && modelInfo.name !== fallback.name) overrides.name = modelInfo.name
|
||||
if (modelInfo.maxTokens !== undefined && modelInfo.maxTokens !== fallback.maxTokens) overrides.maxTokens = modelInfo.maxTokens
|
||||
if (modelInfo.contextWindow !== undefined && modelInfo.contextWindow !== fallback.contextWindow)
|
||||
overrides.contextWindow = modelInfo.contextWindow
|
||||
if (modelInfo.maxInputTokens !== undefined && modelInfo.maxInputTokens !== fallbackInfo.maxInputTokens)
|
||||
overrides.maxInputTokens = modelInfo.maxInputTokens
|
||||
|
||||
const supportsVision = modelInfo.supportsImages ?? fallback.supportsImages
|
||||
if (Boolean(supportsVision) !== Boolean(fallback.supportsImages)) overrides.supportsVision = Boolean(supportsVision)
|
||||
if (Boolean(modelInfo.supportsReasoning) !== Boolean(fallback.supportsReasoning))
|
||||
overrides.supportsReasoning = Boolean(modelInfo.supportsReasoning)
|
||||
if (modelInfo.supportsPromptCache !== fallback.supportsPromptCache) {
|
||||
const capabilities: string[] = []
|
||||
if (supportsVision) capabilities.push("images")
|
||||
if (modelInfo.supportsPromptCache) capabilities.push("prompt-cache")
|
||||
overrides.capabilities = capabilities
|
||||
}
|
||||
|
||||
if (modelInfo.inputPrice !== undefined && modelInfo.inputPrice !== fallback.inputPrice)
|
||||
overrides.inputPrice = modelInfo.inputPrice
|
||||
if (modelInfo.outputPrice !== undefined && modelInfo.outputPrice !== fallback.outputPrice)
|
||||
overrides.outputPrice = modelInfo.outputPrice
|
||||
if (modelInfo.cacheReadsPrice !== undefined && modelInfo.cacheReadsPrice !== fallback.cacheReadsPrice)
|
||||
overrides.cacheReadsPrice = modelInfo.cacheReadsPrice
|
||||
if (modelInfo.cacheWritesPrice !== undefined && modelInfo.cacheWritesPrice !== fallback.cacheWritesPrice)
|
||||
overrides.cacheWritesPrice = modelInfo.cacheWritesPrice
|
||||
if (modelInfo.temperature !== undefined && modelInfo.temperature !== fallback.temperature)
|
||||
overrides.temperature = modelInfo.temperature
|
||||
if (modelInfo.apiFormat !== undefined && modelInfo.apiFormat !== fallback.apiFormat) overrides.apiFormat = modelInfo.apiFormat
|
||||
if (modelInfo.isR1FormatRequired === true && fallbackInfo.isR1FormatRequired !== true) overrides.isR1FormatRequired = true
|
||||
return normalizeModelSelectionOverrides(overrides)
|
||||
}
|
||||
|
||||
// Providers/models whose legacy-state migration has already been attempted in
|
||||
// this process. The migration runs from the read path, so it must be cheap on
|
||||
// repeat reads and must never run more than once per selection — including
|
||||
// when the legacy snapshot diffs to an empty override set and nothing is
|
||||
// written.
|
||||
const attemptedLegacyMigrations = new Set<string>()
|
||||
|
||||
function migrateLegacyModelOverridesIfNeeded(providerId: ProviderId, modelId: string, modelInfo: ModelInfo): void {
|
||||
if (providerSettingsProviderId(providerId) !== "openai-compatible") {
|
||||
return
|
||||
}
|
||||
const migrationKey = `${providerId}:${modelId}`
|
||||
if (attemptedLegacyMigrations.has(migrationKey)) {
|
||||
return
|
||||
}
|
||||
attemptedLegacyMigrations.add(migrationKey)
|
||||
if (readStoredModelEntry(providerId, modelId).exists) {
|
||||
return
|
||||
}
|
||||
if (readBaseModelInfoForProvider(providerId, modelId) !== undefined) {
|
||||
return
|
||||
}
|
||||
const overrides = legacyModelInfoToOverrides(modelInfo as LegacyModelInfo, fallbackModelInfo(modelId))
|
||||
if (overrides) {
|
||||
try {
|
||||
writeModelOverrides(providerId, modelId, overrides)
|
||||
} catch (error) {
|
||||
// The migration is best-effort and runs inside read paths; a
|
||||
// failed write (read-only fs, disk full) must not fail read RPCs.
|
||||
Logger.warn(
|
||||
`[ModelCatalog] Failed to migrate legacy overrides for provider=${providerId} model=${modelId}: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The picker writes the live model metadata to the mode-specific
|
||||
* `*ModeModelInfo` state key before committing. When the state still refers to
|
||||
* the model being resolved, that snapshot is the best available base for
|
||||
* dynamic-list models the static catalog does not know.
|
||||
*
|
||||
* openai-compatible is excluded: its legacy state snapshot is user-authored
|
||||
* metadata that {@link migrateLegacyModelOverridesIfNeeded} converts into
|
||||
* models.json overrides, which are the source of truth there. Feeding the
|
||||
* snapshot back as a base would resurrect overrides the user deleted.
|
||||
*/
|
||||
function usesStateModelInfoHint(providerId: ProviderId): boolean {
|
||||
return providerSettingsProviderId(providerId) !== "openai-compatible"
|
||||
}
|
||||
|
||||
/**
|
||||
* Pickers write `{ ...openAiModelInfoSafeDefaults, name: modelId }` to the
|
||||
* state key when the user selects an id the live model list does not (yet)
|
||||
* contain. Such a snapshot carries no real information and must not be
|
||||
* treated as authoritative "state" metadata.
|
||||
*/
|
||||
function isSafeDefaultsSnapshot(modelInfo: ModelInfo, modelId: string): boolean {
|
||||
const fabricated: Record<string, unknown> = { ...openAiModelInfoSafeDefaults, name: modelId }
|
||||
const snapshot = modelInfo as unknown as Record<string, unknown>
|
||||
for (const key of new Set([...Object.keys(fabricated), ...Object.keys(snapshot)])) {
|
||||
if (fabricated[key] !== snapshot[key]) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
function readStateModelInfoHint(providerId: ProviderId, mode: Mode, modelId: string): ModelInfo | undefined {
|
||||
if (!usesStateModelInfoHint(providerId)) {
|
||||
return undefined
|
||||
}
|
||||
const modelInfoKey = getModelInfoKey(providerId, mode)
|
||||
if (!modelInfoKey) {
|
||||
return undefined
|
||||
}
|
||||
const apiConfiguration = StateManager.get().getApiConfiguration()
|
||||
const stateModelId = apiConfiguration[getModelIdKey(providerId, mode)]
|
||||
const stateModelInfo = apiConfiguration[modelInfoKey]
|
||||
return stateModelId === modelId && isModelInfo(stateModelInfo) && !isSafeDefaultsSnapshot(stateModelInfo, modelId)
|
||||
? stateModelInfo
|
||||
: undefined
|
||||
}
|
||||
|
||||
function readSelectionFromState(providerId: ProviderId, mode: Mode): ResolvedModelSelection | undefined {
|
||||
const apiConfiguration = StateManager.get().getApiConfiguration()
|
||||
const modelId = apiConfiguration[getModelIdKey(providerId, mode)]
|
||||
const modelInfoKey = getModelInfoKey(providerId, mode)
|
||||
const rememberedSelection = selectionMemory.get(memoryKey(providerId, mode))
|
||||
const providerSettingsSelection = readSelectionFromProviderSettings(providerId)
|
||||
|
||||
if (modelInfoKey) {
|
||||
const modelInfo = apiConfiguration[modelInfoKey]
|
||||
if (typeof modelId !== "string" || modelId.length === 0 || !isModelInfo(modelInfo)) {
|
||||
return providerSettingsSelection
|
||||
if (typeof modelId !== "string" || modelId.length === 0) {
|
||||
return readSelectionFromProviderSettings(providerId)
|
||||
}
|
||||
return { providerId, modelId, modelInfo }
|
||||
// The mode-specific model id alone identifies the selection; the state
|
||||
// modelInfo snapshot is optional input for legacy migration and, for
|
||||
// dynamic-list providers, the base-metadata hint. Fallback-tier commits
|
||||
// intentionally leave it unset.
|
||||
if (isModelInfo(modelInfo)) {
|
||||
migrateLegacyModelOverridesIfNeeded(providerId, modelId, modelInfo)
|
||||
}
|
||||
return resolveSelection({ providerId, modelId }, readStateModelInfoHint(providerId, mode, modelId))
|
||||
}
|
||||
|
||||
const providerSettingsSelection = readSelectionFromProviderSettings(providerId)
|
||||
const activeProvider = mode === "plan" ? apiConfiguration.planModeApiProvider : apiConfiguration.actModeApiProvider
|
||||
const provider = providerForStorage(providerId)
|
||||
if (activeProvider !== provider) {
|
||||
@@ -464,7 +865,7 @@ export function createProviderConfigStore(): ProviderConfigStore {
|
||||
return { ...buildEffectiveProviderConfig(providerId) }
|
||||
},
|
||||
|
||||
readSelection(providerId: ProviderId, mode: Mode): ModelSelection | undefined {
|
||||
readSelection(providerId: ProviderId, mode: Mode): ResolvedModelSelection | undefined {
|
||||
return readSelectionFromState(providerId, mode)
|
||||
},
|
||||
|
||||
@@ -482,9 +883,17 @@ export function createProviderConfigStore(): ProviderConfigStore {
|
||||
},
|
||||
|
||||
commitSelection(providerId: ProviderId, mode: Mode, selection: ModelSelection): void {
|
||||
writeSelectionToState(providerId, mode, selection)
|
||||
writeSelectionToProviderSettings(providerId, selection)
|
||||
emit({ kind: "selection", providerId, mode, selection })
|
||||
if (selection.overrides !== undefined) {
|
||||
writeModelOverrides(providerId, selection.modelId, selection.overrides)
|
||||
}
|
||||
// Read the picker-written state snapshot before writeSelectionToState
|
||||
// replaces it, so dynamic-list models keep their live metadata instead
|
||||
// of being re-resolved to fallback defaults.
|
||||
const stateModelInfoHint = readStateModelInfoHint(providerId, mode, selection.modelId)
|
||||
const resolvedSelection = resolveSelection({ providerId, modelId: selection.modelId }, stateModelInfoHint)
|
||||
writeSelectionToState(providerId, mode, resolvedSelection)
|
||||
emit({ kind: "selection", providerId, mode, selection: resolvedSelection })
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { readModelsFileSync, writeModelsFileSync } from "@cline/core"
|
||||
import { describe, expect, it } from "vitest"
|
||||
|
||||
type StoredModelsFile = ReturnType<typeof readModelsFileSync>
|
||||
|
||||
const firstPath = "/tmp/first-models.json"
|
||||
const secondPath = "/tmp/second-models.json"
|
||||
|
||||
const storedModelsFile = (): StoredModelsFile => ({
|
||||
version: 1,
|
||||
providers: {
|
||||
"openai-compatible": {
|
||||
models: {
|
||||
custom: { name: "Custom", capabilities: ["tools"] },
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
describe("cline core model-file test stub", () => {
|
||||
it("isolates paths and returns defensive copies", () => {
|
||||
const input = storedModelsFile()
|
||||
writeModelsFileSync(firstPath, input)
|
||||
|
||||
input.providers["openai-compatible"].models!.custom.name = "mutated input"
|
||||
const firstRead = readModelsFileSync(firstPath)
|
||||
firstRead.providers["openai-compatible"].models!.custom.name = "mutated read"
|
||||
|
||||
expect(readModelsFileSync(firstPath)).toEqual(storedModelsFile())
|
||||
expect(readModelsFileSync(secondPath)).toEqual({ version: 1, providers: {} })
|
||||
})
|
||||
|
||||
it("cannot observe model writes from the preceding test", () => {
|
||||
expect(readModelsFileSync(firstPath)).toEqual({ version: 1, providers: {} })
|
||||
})
|
||||
})
|
||||
@@ -454,13 +454,35 @@ describe("SdkDiffEditCoordinator", () => {
|
||||
expect(callOrder).toEqual(["close", "apply"])
|
||||
})
|
||||
|
||||
it("applies patches without preview sessions directly", async () => {
|
||||
it("shows a brief preview around auto-approved patches", async () => {
|
||||
await writeFile("patched.ts", "line one\nline two\n")
|
||||
const patch = ["*** Begin Patch", "*** Update File: patched.ts", "@@", "-line one", "+line ONE", "*** End Patch"].join(
|
||||
"\n",
|
||||
)
|
||||
|
||||
const result = await coordinator.executeApplyPatchTool({ input: patch }, tempDir, makeContext("tc9"))
|
||||
|
||||
expect(result).toBe("fallback apply_patch result")
|
||||
expect(fallbackApplyPatch).toHaveBeenCalledOnce()
|
||||
expect(previews).toHaveLength(1)
|
||||
expect(previews[0].opened).toMatchObject({
|
||||
absolutePath: path.join(tempDir, "patched.ts"),
|
||||
leftContent: "line one\nline two\n",
|
||||
rightContent: "line ONE\nline two\n",
|
||||
})
|
||||
expect(previews[0].closed).toBe(1)
|
||||
})
|
||||
|
||||
it("applies auto-approved patches without a preview when background edit is enabled", async () => {
|
||||
backgroundEdit = true
|
||||
const result = await coordinator.executeApplyPatchTool(
|
||||
{ input: "*** Begin Patch\n*** End Patch" },
|
||||
tempDir,
|
||||
makeContext("tc9"),
|
||||
)
|
||||
|
||||
expect(result).toBe("fallback apply_patch result")
|
||||
expect(fallbackApplyPatch).toHaveBeenCalledOnce()
|
||||
expect(previews).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -122,12 +122,32 @@ export class SdkDiffEditCoordinator {
|
||||
}
|
||||
|
||||
/**
|
||||
* The `apply_patch` tool executor override: close the preview, then delegate the
|
||||
* whole patch application to the SDK's default executor.
|
||||
* The `apply_patch` tool executor override: manually-approved patches close their
|
||||
* approval preview before applying; auto-approved patches show a brief preview
|
||||
* around execution, matching the `editor` tool behavior.
|
||||
*/
|
||||
async executeApplyPatchTool(input: ApplyPatchInput, cwd: string, context: AgentToolContext): Promise<string> {
|
||||
await this.discardPreview(context.toolCallId ?? "")
|
||||
return this.fallbackApplyPatchExecutor(input, cwd, context)
|
||||
const toolCallId = context.toolCallId ?? ""
|
||||
const hadPreApprovalPreview = this.sessions.has(toolCallId)
|
||||
try {
|
||||
if (hadPreApprovalPreview) {
|
||||
await this.discardPreview(toolCallId)
|
||||
} else if (!this.options.isBackgroundEditEnabled()) {
|
||||
try {
|
||||
await this.openPatchPreview(toolCallId, input)
|
||||
} catch (error) {
|
||||
Logger.warn(`[SdkDiffEditCoordinator] Failed to show auto-approve patch preview: ${error}`)
|
||||
}
|
||||
}
|
||||
|
||||
const result = await this.fallbackApplyPatchExecutor(input, cwd, context)
|
||||
if (!hadPreApprovalPreview && this.sessions.get(toolCallId)?.preview) {
|
||||
await lingerDelay(this.autoApprovePreviewLingerMs, context.signal)
|
||||
}
|
||||
return result
|
||||
} finally {
|
||||
await this.discardPreview(toolCallId)
|
||||
}
|
||||
}
|
||||
|
||||
/** Closes one preview (reject / abort / edit applied). Never throws; unknown ids are a no-op. */
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { describe, expect, it, vi } from "vitest"
|
||||
import { SdkForegroundCommandCoordinator } from "./sdk-foreground-command-coordinator"
|
||||
|
||||
describe("SdkForegroundCommandCoordinator", () => {
|
||||
it("reports isRunning while a handle is registered and notifies on changes", () => {
|
||||
const onRunningChanged = vi.fn()
|
||||
const coordinator = new SdkForegroundCommandCoordinator({ onRunningChanged })
|
||||
|
||||
expect(coordinator.isRunning).toBe(false)
|
||||
|
||||
const unregister = coordinator.register({ detach: () => {} })
|
||||
expect(coordinator.isRunning).toBe(true)
|
||||
expect(onRunningChanged).toHaveBeenCalledWith(true)
|
||||
|
||||
unregister()
|
||||
expect(coordinator.isRunning).toBe(false)
|
||||
expect(onRunningChanged).toHaveBeenCalledWith(false)
|
||||
})
|
||||
|
||||
it("only notifies on actual transitions, not per handle", () => {
|
||||
const onRunningChanged = vi.fn()
|
||||
const coordinator = new SdkForegroundCommandCoordinator({ onRunningChanged })
|
||||
|
||||
const unregister1 = coordinator.register({ detach: () => {} })
|
||||
const unregister2 = coordinator.register({ detach: () => {} })
|
||||
expect(onRunningChanged).toHaveBeenCalledTimes(1)
|
||||
|
||||
unregister1()
|
||||
expect(onRunningChanged).toHaveBeenCalledTimes(1)
|
||||
unregister2()
|
||||
expect(onRunningChanged).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it("unregister is idempotent", () => {
|
||||
const onRunningChanged = vi.fn()
|
||||
const coordinator = new SdkForegroundCommandCoordinator({ onRunningChanged })
|
||||
|
||||
const unregister = coordinator.register({ detach: () => {} })
|
||||
unregister()
|
||||
unregister()
|
||||
expect(onRunningChanged).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it("proceedWhileRunning detaches every registered handle and reports the count", () => {
|
||||
const coordinator = new SdkForegroundCommandCoordinator()
|
||||
const detach1 = vi.fn()
|
||||
const detach2 = vi.fn()
|
||||
coordinator.register({ detach: detach1 })
|
||||
coordinator.register({ detach: detach2 })
|
||||
|
||||
expect(coordinator.proceedWhileRunning()).toBe(2)
|
||||
expect(detach1).toHaveBeenCalledTimes(1)
|
||||
expect(detach2).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it("proceedWhileRunning is a no-op returning 0 when nothing is running", () => {
|
||||
const coordinator = new SdkForegroundCommandCoordinator()
|
||||
expect(coordinator.proceedWhileRunning()).toBe(0)
|
||||
})
|
||||
|
||||
it("proceedWhileRunning survives a handle whose detach throws", () => {
|
||||
const coordinator = new SdkForegroundCommandCoordinator()
|
||||
const detach2 = vi.fn()
|
||||
coordinator.register({
|
||||
detach: () => {
|
||||
throw new Error("boom")
|
||||
},
|
||||
})
|
||||
coordinator.register({ detach: detach2 })
|
||||
|
||||
expect(coordinator.proceedWhileRunning()).toBe(2)
|
||||
expect(detach2).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* Tracks in-flight foreground (VS Code terminal) command executions so the
|
||||
* "Proceed While Running" button can detach them: each pending tool call
|
||||
* returns with its partial output while the command keeps running in the
|
||||
* user's terminal, streaming further output to a log file.
|
||||
*
|
||||
* Owned by SdkController so it outlives session rebuilds (which recreate the
|
||||
* tool set and its reused executor closure). Handles are registered per tool
|
||||
* invocation — never on the reused executor — so parallel commands in one
|
||||
* tool call each get their own handle and log file.
|
||||
*/
|
||||
|
||||
import { Logger } from "@/shared/services/Logger"
|
||||
|
||||
export interface ForegroundCommandHandle {
|
||||
/**
|
||||
* Stop waiting for the command: flush the output captured so far to a
|
||||
* log file, keep appending until the command completes, and resolve the
|
||||
* pending tool execution with the partial output. Idempotent.
|
||||
*/
|
||||
detach(): void
|
||||
}
|
||||
|
||||
export interface SdkForegroundCommandCoordinatorOptions {
|
||||
/** Called whenever isRunning flips; used to push the flag to the webview. */
|
||||
onRunningChanged?: (running: boolean) => void
|
||||
}
|
||||
|
||||
export class SdkForegroundCommandCoordinator {
|
||||
private readonly handles = new Set<ForegroundCommandHandle>()
|
||||
|
||||
constructor(private readonly options: SdkForegroundCommandCoordinatorOptions = {}) {}
|
||||
|
||||
/** Whether any foreground command is currently awaited by a tool call. */
|
||||
get isRunning(): boolean {
|
||||
return this.handles.size > 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Track one in-flight foreground execution. Returns an unregister
|
||||
* function the caller must invoke when the execution settles (completes,
|
||||
* fails, aborts, or detaches) — typically from a `finally` block.
|
||||
*/
|
||||
register(handle: ForegroundCommandHandle): () => void {
|
||||
const wasRunning = this.isRunning
|
||||
this.handles.add(handle)
|
||||
this.notifyIfChanged(wasRunning)
|
||||
return () => {
|
||||
const wasRunningBefore = this.isRunning
|
||||
if (this.handles.delete(handle)) {
|
||||
this.notifyIfChanged(wasRunningBefore)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Detach every in-flight foreground command ("Proceed While Running").
|
||||
* Each pending tool execution resolves with its partial output and log
|
||||
* file path; the commands keep running in their terminals.
|
||||
*
|
||||
* @returns the number of commands detached (0 when none were running).
|
||||
*/
|
||||
proceedWhileRunning(): number {
|
||||
const handles = [...this.handles]
|
||||
for (const handle of handles) {
|
||||
try {
|
||||
handle.detach()
|
||||
} catch (error) {
|
||||
Logger.error("[ForegroundCommands] Failed to detach foreground command:", error)
|
||||
}
|
||||
}
|
||||
return handles.length
|
||||
}
|
||||
|
||||
private notifyIfChanged(wasRunning: boolean): void {
|
||||
if (this.isRunning !== wasRunning) {
|
||||
this.options.onRunningChanged?.(this.isRunning)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import type { VscodeTerminalManager } from "@/hosts/vscode/terminal/VscodeTermin
|
||||
import { McpHub } from "@/services/mcp/McpHub"
|
||||
import { Logger } from "@/shared/services/Logger"
|
||||
import type { ActiveSession } from "./cline-session-factory"
|
||||
import type { SdkForegroundCommandCoordinator } from "./sdk-foreground-command-coordinator"
|
||||
import { buildToolPolicies } from "./sdk-tool-policies"
|
||||
import type { SdkSessionHost } from "./session-host"
|
||||
import { VscodeSessionHost } from "./vscode-session-host"
|
||||
@@ -32,6 +33,8 @@ export interface SdkSessionLifecycleOptions {
|
||||
onSessionEvent: (event: CoreSessionEvent) => void
|
||||
/** Lazy factory for the VscodeTerminalManager (foreground terminal support). */
|
||||
getTerminalManager?: () => VscodeTerminalManager
|
||||
/** Registry of in-flight foreground executions for "Proceed While Running". */
|
||||
foregroundCommands?: SdkForegroundCommandCoordinator
|
||||
/** Returns the latest prepared remote-config integration, if remote config is active. */
|
||||
getRemoteConfigIntegration?: () => PreparedRemoteConfigCoreIntegration | undefined
|
||||
/** Shared SDK telemetry service owned by SdkController. */
|
||||
@@ -322,6 +325,7 @@ export class SdkSessionLifecycle {
|
||||
editorExecutor: this.options.editorExecutor,
|
||||
applyPatchExecutor: this.options.applyPatchExecutor,
|
||||
getTerminalManager: this.options.getTerminalManager,
|
||||
foregroundCommands: this.options.foregroundCommands,
|
||||
getRemoteConfigIntegration: this.options.getRemoteConfigIntegration,
|
||||
telemetry: this.options.telemetry,
|
||||
})
|
||||
|
||||
@@ -33,8 +33,11 @@ export class SdkTerminalExecutionModeCoordinator {
|
||||
if (previous === next) {
|
||||
return
|
||||
}
|
||||
this.requestRebuild(`Terminal execution mode changed: ${previous} -> ${next}`)
|
||||
}
|
||||
|
||||
Logger.log(`[SdkController] Terminal execution mode changed: ${previous} -> ${next}`)
|
||||
private requestRebuild(reason: string): void {
|
||||
Logger.log(`[SdkController] ${reason}`)
|
||||
|
||||
const activeSession = this.options.sessions.getActiveSession()
|
||||
if (!activeSession) {
|
||||
|
||||
@@ -1,9 +1,111 @@
|
||||
import { CommandExitError } from "@cline/core"
|
||||
import { EventEmitter } from "events"
|
||||
import { describe, expect, it } from "vitest"
|
||||
import type { TerminalCompletionDetails } from "@/integrations/terminal/types"
|
||||
import * as fs from "fs"
|
||||
import { afterEach, describe, expect, it, vi } from "vitest"
|
||||
import * as vscode from "vscode"
|
||||
import type { VscodeTerminalManager } from "@/hosts/vscode/terminal/VscodeTerminalManager"
|
||||
import { executeForeground, formatCommandForTerminal } from "./vscode-run-commands-tool"
|
||||
import type { TerminalCompletionDetails } from "@/integrations/terminal/types"
|
||||
import { SdkForegroundCommandCoordinator } from "./sdk-foreground-command-coordinator"
|
||||
import {
|
||||
createVscodeRunCommandsTool,
|
||||
executeForeground,
|
||||
formatCommandForTerminal,
|
||||
PROCEED_LOG_MAX_BYTES,
|
||||
} from "./vscode-run-commands-tool"
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
existsSync: vi.fn<(path: fs.PathLike) => boolean>(),
|
||||
getGlobalSettingsKey: vi.fn(() => "default"),
|
||||
}))
|
||||
|
||||
vi.mock("fs", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("fs")>()),
|
||||
existsSync: mocks.existsSync,
|
||||
}))
|
||||
|
||||
vi.mock("@/core/storage/StateManager", () => ({
|
||||
StateManager: {
|
||||
get: () => ({ getGlobalSettingsKey: mocks.getGlobalSettingsKey }),
|
||||
},
|
||||
}))
|
||||
|
||||
// The real telemetry proxy lazily initializes TelemetryService, which requires
|
||||
// a HostProvider that unit tests don't set up.
|
||||
vi.mock("@services/telemetry", () => ({
|
||||
TerminalUserInterventionAction: { PROCESS_WHILE_RUNNING: "process_while_running" },
|
||||
telemetryService: {
|
||||
captureTerminalUserIntervention: () => {},
|
||||
captureTerminalExecution: () => {},
|
||||
},
|
||||
}))
|
||||
|
||||
const originalPlatform = process.platform
|
||||
const originalEnv = { ...process.env }
|
||||
const originalGetConfiguration = vscode.workspace.getConfiguration
|
||||
|
||||
afterEach(() => {
|
||||
Object.defineProperty(process, "platform", { value: originalPlatform })
|
||||
process.env = { ...originalEnv }
|
||||
vscode.workspace.getConfiguration = originalGetConfiguration
|
||||
mocks.existsSync.mockReset()
|
||||
mocks.getGlobalSettingsKey.mockReset()
|
||||
mocks.getGlobalSettingsKey.mockReturnValue("default")
|
||||
})
|
||||
|
||||
describe("createVscodeRunCommandsTool", () => {
|
||||
it("constructs a cmd tool from the stock array-valued Command Prompt profile", () => {
|
||||
Object.defineProperty(process, "platform", { value: "win32" })
|
||||
process.env.windir = "C:\\Windows"
|
||||
mocks.existsSync.mockImplementation((candidate) => candidate === "C:\\Windows\\System32\\cmd.exe")
|
||||
vscode.workspace.getConfiguration = () =>
|
||||
({
|
||||
get: (key: string) => {
|
||||
if (key === "defaultProfile.windows") {
|
||||
return "Command Prompt"
|
||||
}
|
||||
if (key === "profiles.windows") {
|
||||
return {
|
||||
"Command Prompt": {
|
||||
path: [`\${env:windir}\\Sysnative\\cmd.exe`, `\${env:windir}\\System32\\cmd.exe`],
|
||||
},
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
},
|
||||
}) as never
|
||||
|
||||
const tool = createVscodeRunCommandsTool({
|
||||
cwd: "C:\\workspace",
|
||||
getTerminalManager: () => {
|
||||
throw new Error("Terminal manager should not be created during tool construction")
|
||||
},
|
||||
vscodeTerminalExecutionMode: "vscodeTerminal",
|
||||
})
|
||||
|
||||
expect(tool.name).toBe("run_commands")
|
||||
expect(tool.description).toContain("Commands run through cmd.exe")
|
||||
})
|
||||
|
||||
it("re-derives the description from the current profile on each read", () => {
|
||||
Object.defineProperty(process, "platform", { value: "win32" })
|
||||
mocks.existsSync.mockReturnValue(true)
|
||||
mocks.getGlobalSettingsKey.mockReturnValue("cmd")
|
||||
|
||||
const tool = createVscodeRunCommandsTool({
|
||||
cwd: "C:\\workspace",
|
||||
getTerminalManager: () => {
|
||||
throw new Error("Terminal manager should not be created during tool construction")
|
||||
},
|
||||
vscodeTerminalExecutionMode: "vscodeTerminal",
|
||||
})
|
||||
expect(tool.description).toContain("Commands run through cmd.exe")
|
||||
|
||||
// A profile change takes effect at the next description read (the
|
||||
// model-request boundary), without a session rebuild.
|
||||
mocks.getGlobalSettingsKey.mockReturnValue("powershell-7")
|
||||
expect(tool.description).toContain("Commands run through PowerShell")
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* Minimal fake of the process object returned by VscodeTerminalManager.runCommand():
|
||||
@@ -12,11 +114,13 @@ import { executeForeground, formatCommandForTerminal } from "./vscode-run-comman
|
||||
*/
|
||||
function createFakeTerminalProcess(options: { lines?: string[]; completionDetails?: TerminalCompletionDetails } = {}) {
|
||||
const emitter = new EventEmitter()
|
||||
let resolvePromise!: () => void
|
||||
// Emit on a macrotask (not a microtask) so executeForeground's
|
||||
// `await terminalManager.getOrCreateTerminal(cwd)` and subsequent
|
||||
// `process.on("line", ...)` registration are guaranteed to run first,
|
||||
// matching the ordering a real terminal process provides.
|
||||
const promise = new Promise<void>((resolve) => {
|
||||
resolvePromise = resolve
|
||||
setTimeout(() => {
|
||||
for (const line of options.lines ?? []) {
|
||||
emitter.emit("line", line)
|
||||
@@ -31,6 +135,10 @@ function createFakeTerminalProcess(options: { lines?: string[]; completionDetail
|
||||
catch: promise.catch.bind(promise),
|
||||
finally: promise.finally.bind(promise),
|
||||
getCompletionDetails: () => options.completionDetails ?? {},
|
||||
detach: () => {
|
||||
emitter.emit("continue")
|
||||
resolvePromise()
|
||||
},
|
||||
})
|
||||
return fakeProcess as unknown as ReturnType<VscodeTerminalManager["runCommand"]>
|
||||
}
|
||||
@@ -42,6 +150,50 @@ function createFakeTerminalManager(process: ReturnType<VscodeTerminalManager["ru
|
||||
} as unknown as VscodeTerminalManager
|
||||
}
|
||||
|
||||
/**
|
||||
* A controllable fake terminal process for detach tests: the caller decides
|
||||
* when lines are emitted and when the command completes. Mirrors the real
|
||||
* VscodeTerminalProcess contract: detach() resolves the awaited promise while
|
||||
* 'line'/'completed' events keep flowing.
|
||||
*/
|
||||
function createControllableTerminalProcess() {
|
||||
const emitter = new EventEmitter()
|
||||
let resolvePromise!: () => void
|
||||
const promise = new Promise<void>((resolve) => {
|
||||
resolvePromise = resolve
|
||||
})
|
||||
const fakeProcess = Object.assign(emitter, {
|
||||
then: promise.then.bind(promise),
|
||||
catch: promise.catch.bind(promise),
|
||||
finally: promise.finally.bind(promise),
|
||||
getCompletionDetails: () => ({}),
|
||||
detach: () => {
|
||||
emitter.emit("continue")
|
||||
resolvePromise()
|
||||
},
|
||||
})
|
||||
return {
|
||||
process: fakeProcess as unknown as ReturnType<VscodeTerminalManager["runCommand"]>,
|
||||
emitLine: (line: string) => emitter.emit("line", line),
|
||||
complete: (details?: TerminalCompletionDetails) => {
|
||||
emitter.emit("completed", details)
|
||||
emitter.emit("continue")
|
||||
resolvePromise()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/** Poll until the predicate holds, for asserting on async log-file writes. */
|
||||
async function waitFor(predicate: () => boolean, timeoutMs = 2000): Promise<void> {
|
||||
const start = Date.now()
|
||||
while (!predicate()) {
|
||||
if (Date.now() - start > timeoutMs) {
|
||||
throw new Error("waitFor timed out")
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 10))
|
||||
}
|
||||
}
|
||||
|
||||
describe("formatCommandForTerminal", () => {
|
||||
it.each([
|
||||
{
|
||||
@@ -129,6 +281,19 @@ describe("executeForeground", () => {
|
||||
expect(result).toBe("hello")
|
||||
})
|
||||
|
||||
it("passes the caller's terminal profile through to getOrCreateTerminal", async () => {
|
||||
const process = createFakeTerminalProcess({ lines: ["ok"] })
|
||||
const getOrCreateTerminal = vi.fn(async () => ({ terminal: { show: () => {} } }) as never)
|
||||
const terminalManager = {
|
||||
getOrCreateTerminal,
|
||||
runCommand: () => process,
|
||||
} as unknown as VscodeTerminalManager
|
||||
|
||||
await executeForeground("echo ok", "/workspace", terminalManager, 1000, undefined, undefined, "wsl-bash")
|
||||
|
||||
expect(getOrCreateTerminal).toHaveBeenCalledWith("/workspace", "wsl-bash")
|
||||
})
|
||||
|
||||
it("throws CommandExitError with the exit code on non-zero exit", async () => {
|
||||
const terminalManager = createFakeTerminalManager(
|
||||
createFakeTerminalProcess({ lines: ["boom"], completionDetails: { exitCode: 127 } }),
|
||||
@@ -172,4 +337,207 @@ describe("executeForeground", () => {
|
||||
expect((error as InstanceType<typeof CommandExitError>).output).toContain("Terminal closed")
|
||||
}
|
||||
})
|
||||
|
||||
it("unregisters its foreground handle when the command completes normally", async () => {
|
||||
const coordinator = new SdkForegroundCommandCoordinator()
|
||||
const terminalManager = createFakeTerminalManager(createFakeTerminalProcess({ lines: ["hello"] }))
|
||||
|
||||
const result = await executeForeground("echo hello", "/workspace", terminalManager, 1000, undefined, coordinator)
|
||||
|
||||
expect(result).toBe("hello")
|
||||
expect(coordinator.isRunning).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("executeForeground — Proceed While Running", () => {
|
||||
it("detach returns the partial output with the log file path, and later output lands in the log", async () => {
|
||||
const coordinator = new SdkForegroundCommandCoordinator()
|
||||
const { process, emitLine, complete } = createControllableTerminalProcess()
|
||||
const terminalManager = createFakeTerminalManager(process)
|
||||
|
||||
const resultPromise = executeForeground("devserver", "/workspace", terminalManager, 100_000, undefined, coordinator)
|
||||
|
||||
await waitFor(() => coordinator.isRunning)
|
||||
emitLine("listening on :3000")
|
||||
|
||||
expect(coordinator.proceedWhileRunning()).toBe(1)
|
||||
const result = await resultPromise
|
||||
|
||||
expect(result).toContain("still running")
|
||||
expect(result).toContain("listening on :3000")
|
||||
const logFilePath = /redirected to this file[^:]*: (.+)$/m.exec(result)?.[1]?.trim()
|
||||
expect(logFilePath).toBeTruthy()
|
||||
|
||||
// The handle is unregistered once the tool call returns.
|
||||
expect(coordinator.isRunning).toBe(false)
|
||||
|
||||
// Output emitted after detach is appended to the log file, and
|
||||
// completion closes it out with a completion marker.
|
||||
emitLine("compiled successfully")
|
||||
complete({ exitCode: 0 })
|
||||
await waitFor(() => {
|
||||
try {
|
||||
return fs.readFileSync(logFilePath!, "utf8").includes("[Command completed with exit code 0]")
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
})
|
||||
const log = fs.readFileSync(logFilePath!, "utf8")
|
||||
expect(log).toContain("listening on :3000") // buffered lines flushed at detach
|
||||
expect(log).toContain("compiled successfully") // streamed after detach
|
||||
fs.rmSync(logFilePath!, { force: true })
|
||||
})
|
||||
|
||||
it("detaches each parallel command into its own log file", async () => {
|
||||
const coordinator = new SdkForegroundCommandCoordinator()
|
||||
const first = createControllableTerminalProcess()
|
||||
const second = createControllableTerminalProcess()
|
||||
|
||||
const firstPromise = executeForeground(
|
||||
"first-cmd",
|
||||
"/workspace",
|
||||
createFakeTerminalManager(first.process),
|
||||
100_000,
|
||||
undefined,
|
||||
coordinator,
|
||||
)
|
||||
const secondPromise = executeForeground(
|
||||
"second-cmd",
|
||||
"/workspace",
|
||||
createFakeTerminalManager(second.process),
|
||||
100_000,
|
||||
undefined,
|
||||
coordinator,
|
||||
)
|
||||
|
||||
await waitFor(() => coordinator.isRunning)
|
||||
first.emitLine("first output")
|
||||
second.emitLine("second output")
|
||||
|
||||
expect(coordinator.proceedWhileRunning()).toBe(2)
|
||||
const [firstResult, secondResult] = await Promise.all([firstPromise, secondPromise])
|
||||
|
||||
const firstLog = /redirected to this file[^:]*: (.+)$/m.exec(firstResult)?.[1]?.trim()
|
||||
const secondLog = /redirected to this file[^:]*: (.+)$/m.exec(secondResult)?.[1]?.trim()
|
||||
expect(firstLog).toBeTruthy()
|
||||
expect(secondLog).toBeTruthy()
|
||||
expect(firstLog).not.toBe(secondLog)
|
||||
expect(coordinator.isRunning).toBe(false)
|
||||
|
||||
first.complete()
|
||||
second.complete()
|
||||
await waitFor(() => {
|
||||
try {
|
||||
return (
|
||||
fs.readFileSync(firstLog!, "utf8").includes("[Command completed]") &&
|
||||
fs.readFileSync(secondLog!, "utf8").includes("[Command completed]")
|
||||
)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
})
|
||||
expect(fs.readFileSync(firstLog!, "utf8")).toContain("first output")
|
||||
expect(fs.readFileSync(secondLog!, "utf8")).toContain("second output")
|
||||
fs.rmSync(firstLog!, { force: true })
|
||||
fs.rmSync(secondLog!, { force: true })
|
||||
})
|
||||
|
||||
it("stops logging before a line that would exceed the size cap", async () => {
|
||||
const coordinator = new SdkForegroundCommandCoordinator()
|
||||
const { process, emitLine, complete } = createControllableTerminalProcess()
|
||||
const terminalManager = createFakeTerminalManager(process)
|
||||
|
||||
const resultPromise = executeForeground("devserver", "/workspace", terminalManager, 100_000, undefined, coordinator)
|
||||
await waitFor(() => coordinator.isRunning)
|
||||
|
||||
expect(coordinator.proceedWhileRunning()).toBe(1)
|
||||
const result = await resultPromise
|
||||
const logFilePath = /redirected to this file[^:]*: (.+)$/m.exec(result)?.[1]?.trim()
|
||||
expect(logFilePath).toBeTruthy()
|
||||
|
||||
// A single line larger than the whole cap must not be written at all —
|
||||
// the cap is checked before writing, so one huge line (e.g. a dumped
|
||||
// blob) cannot blow the log far past PROCEED_LOG_MAX_BYTES.
|
||||
emitLine("small line before the blob")
|
||||
emitLine("x".repeat(PROCEED_LOG_MAX_BYTES))
|
||||
emitLine("after the cap")
|
||||
complete({ exitCode: 0 })
|
||||
|
||||
await waitFor(() => {
|
||||
try {
|
||||
return fs.readFileSync(logFilePath!, "utf8").includes("[Command completed with exit code 0]")
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
})
|
||||
const log = fs.readFileSync(logFilePath!, "utf8")
|
||||
expect(log).toContain("small line before the blob")
|
||||
expect(log).toContain(`[Log size cap of ${PROCEED_LOG_MAX_BYTES} bytes reached`)
|
||||
expect(log).not.toContain("xxxx")
|
||||
expect(log).not.toContain("after the cap")
|
||||
expect(log.length).toBeLessThan(PROCEED_LOG_MAX_BYTES)
|
||||
fs.rmSync(logFilePath!, { force: true })
|
||||
})
|
||||
|
||||
it("applies the size cap to lines buffered before detach", async () => {
|
||||
const coordinator = new SdkForegroundCommandCoordinator()
|
||||
const { process, emitLine, complete } = createControllableTerminalProcess()
|
||||
const terminalManager = createFakeTerminalManager(process)
|
||||
|
||||
const resultPromise = executeForeground("devserver", "/workspace", terminalManager, 100_000, undefined, coordinator)
|
||||
await waitFor(() => coordinator.isRunning)
|
||||
emitLine("x".repeat(PROCEED_LOG_MAX_BYTES))
|
||||
|
||||
expect(coordinator.proceedWhileRunning()).toBe(1)
|
||||
const result = await resultPromise
|
||||
const logFilePath = /redirected to this file[^:]*: (.+)$/m.exec(result)?.[1]?.trim()
|
||||
expect(logFilePath).toBeTruthy()
|
||||
complete({ exitCode: 0 })
|
||||
|
||||
await waitFor(() => {
|
||||
try {
|
||||
return fs.readFileSync(logFilePath!, "utf8").includes("[Command completed with exit code 0]")
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
})
|
||||
const log = fs.readFileSync(logFilePath!, "utf8")
|
||||
expect(log).toContain(`[Log size cap of ${PROCEED_LOG_MAX_BYTES} bytes reached`)
|
||||
expect(log).not.toContain("xxxx")
|
||||
expect(Buffer.byteLength(log)).toBeLessThanOrEqual(PROCEED_LOG_MAX_BYTES)
|
||||
fs.rmSync(logFilePath!, { force: true })
|
||||
})
|
||||
|
||||
it("freezes the partial output at detach while later output still reaches the log", async () => {
|
||||
const coordinator = new SdkForegroundCommandCoordinator()
|
||||
const { process, emitLine, complete } = createControllableTerminalProcess()
|
||||
const terminalManager = createFakeTerminalManager(process)
|
||||
|
||||
const resultPromise = executeForeground("devserver", "/workspace", terminalManager, 100_000, undefined, coordinator)
|
||||
await waitFor(() => coordinator.isRunning)
|
||||
emitLine("before detach")
|
||||
|
||||
expect(coordinator.proceedWhileRunning()).toBe(1)
|
||||
// Emitted after detach but before the tool call's result is built:
|
||||
// must appear only in the log, never in the partial output.
|
||||
emitLine("after detach")
|
||||
const result = await resultPromise
|
||||
|
||||
expect(result).toContain("before detach")
|
||||
expect(result).not.toContain("after detach")
|
||||
|
||||
const logFilePath = /redirected to this file[^:]*: (.+)$/m.exec(result)?.[1]?.trim()
|
||||
complete({ exitCode: 0 })
|
||||
await waitFor(() => {
|
||||
try {
|
||||
return fs.readFileSync(logFilePath!, "utf8").includes("[Command completed with exit code 0]")
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
})
|
||||
const log = fs.readFileSync(logFilePath!, "utf8")
|
||||
expect(log).toContain("before detach")
|
||||
expect(log).toContain("after detach")
|
||||
fs.rmSync(logFilePath!, { force: true })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -21,12 +21,16 @@ import {
|
||||
truncateCommandOutput,
|
||||
} from "@cline/core"
|
||||
import type { AgentTool } from "@cline/shared"
|
||||
import { telemetryService } from "@services/telemetry"
|
||||
import { TerminalUserInterventionAction, telemetryService } from "@services/telemetry"
|
||||
import { ClineTempManager } from "@services/temp"
|
||||
import * as fs from "fs"
|
||||
import { StateManager } from "@/core/storage/StateManager"
|
||||
import type { VscodeTerminalManager } from "@/hosts/vscode/terminal/VscodeTerminalManager"
|
||||
import { MAX_UNRETRIEVED_LINES } from "@/integrations/terminal/constants"
|
||||
import type { ITerminalProcess } from "@/integrations/terminal/types"
|
||||
import { Logger } from "@/shared/services/Logger"
|
||||
import { getShellForProfile } from "@/utils/shell"
|
||||
import type { SdkForegroundCommandCoordinator } from "./sdk-foreground-command-coordinator"
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
@@ -38,6 +42,14 @@ type VscodeTerminalExecutionMode = "vscodeTerminal" | "backgroundExec"
|
||||
/** Foreground VS Code terminals cannot be forcibly terminated; give long-running commands room to finish. */
|
||||
export const VSCODE_FOREGROUND_RUN_COMMANDS_TIMEOUT_MS = 60 * 60 * 1000
|
||||
|
||||
/**
|
||||
* Cap on the "Proceed While Running" log file. A detached devserver can log
|
||||
* for days; once the cap is hit we stop appending and note the truncation.
|
||||
* ClineTempManager's periodic cleanup (age + total-size caps) is the backstop
|
||||
* for the files themselves.
|
||||
*/
|
||||
export const PROCEED_LOG_MAX_BYTES = 10 * 1024 * 1024
|
||||
|
||||
/** Options for creating the VSCode run_commands tool. */
|
||||
export interface VscodeRunCommandsToolOptions {
|
||||
/** Workspace root directory. */
|
||||
@@ -48,6 +60,14 @@ export interface VscodeRunCommandsToolOptions {
|
||||
bashTimeoutMs?: number
|
||||
/** Terminal execution mode captured when this session's tool set is built. */
|
||||
vscodeTerminalExecutionMode?: VscodeTerminalExecutionMode
|
||||
/**
|
||||
* Registry of in-flight foreground executions, owned by SdkController.
|
||||
* When provided, each foreground command can be detached via the
|
||||
* "Proceed While Running" button. Foreground-only: background (SDK
|
||||
* child_process) executions cannot be detached — their abort signal
|
||||
* kills the process tree.
|
||||
*/
|
||||
foregroundCommands?: SdkForegroundCommandCoordinator
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -74,6 +94,69 @@ export function formatCommandForTerminal(command: ShellCommand): string {
|
||||
return [command.command, ...(command.args ?? [])].map(quoteShellArg).join(" ")
|
||||
}
|
||||
|
||||
/**
|
||||
* Stream the rest of a detached command's output to a log file: write the
|
||||
* lines buffered so far, then append each further 'line' event until
|
||||
* 'completed'. The write volume is capped at PROCEED_LOG_MAX_BYTES; the
|
||||
* stream is always closed by the 'completed' event, which the terminal
|
||||
* process emits on every exit path (command end, Ctrl+C, terminal closed,
|
||||
* markerless fallback).
|
||||
*/
|
||||
function beginLogCapture(process: ITerminalProcess, terminalCommand: string, existingLines: string[]): string {
|
||||
const logFilePath = ClineTempManager.createTempFilePath("proceed-while-running")
|
||||
const stream = fs.createWriteStream(logFilePath, { flags: "a" })
|
||||
const sizeCapMessage = `[Log size cap of ${PROCEED_LOG_MAX_BYTES} bytes reached; further output is not logged.]`
|
||||
stream.on("error", (error) => {
|
||||
Logger.error(`[VscodeRunCommands] Failed writing proceed-while-running log ${logFilePath}:`, error)
|
||||
})
|
||||
|
||||
let bytesWritten = 0
|
||||
const tryWriteLine = (line: string): boolean => {
|
||||
const chunk = `${line}\n`
|
||||
const chunkBytes = Buffer.byteLength(chunk)
|
||||
if (bytesWritten + chunkBytes > PROCEED_LOG_MAX_BYTES) {
|
||||
return false
|
||||
}
|
||||
bytesWritten += chunkBytes
|
||||
stream.write(chunk)
|
||||
return true
|
||||
}
|
||||
|
||||
let sizeCapReached = !tryWriteLine(`[Running command: ${terminalCommand}]`)
|
||||
for (const line of existingLines) {
|
||||
if (!tryWriteLine(line)) {
|
||||
sizeCapReached = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
const onLine = (line: string): void => {
|
||||
// Check the cap before writing: a single huge line (e.g. a dumped
|
||||
// binary blob or minified bundle) must not blow past the cap.
|
||||
if (!tryWriteLine(line)) {
|
||||
tryWriteLine(sizeCapMessage)
|
||||
process.removeListener("line", onLine)
|
||||
}
|
||||
}
|
||||
if (sizeCapReached) {
|
||||
tryWriteLine(sizeCapMessage)
|
||||
} else {
|
||||
process.on("line", onLine)
|
||||
}
|
||||
process.once("completed", (details) => {
|
||||
process.removeListener("line", onLine)
|
||||
const exitCode = details?.exitCode
|
||||
tryWriteLine(
|
||||
exitCode !== undefined && exitCode !== null
|
||||
? `[Command completed with exit code ${exitCode}]`
|
||||
: "[Command completed]",
|
||||
)
|
||||
stream.end()
|
||||
})
|
||||
|
||||
return logFilePath
|
||||
}
|
||||
|
||||
/** Exported for direct unit testing of the CommandExitError/terminalClosed mapping. */
|
||||
export async function executeForeground(
|
||||
command: ShellCommand,
|
||||
@@ -81,9 +164,11 @@ export async function executeForeground(
|
||||
terminalManager: VscodeTerminalManager,
|
||||
maxOutputChars: number,
|
||||
abortSignal?: AbortSignal,
|
||||
foregroundCommands?: SdkForegroundCommandCoordinator,
|
||||
terminalProfileId?: string,
|
||||
): Promise<string> {
|
||||
const terminalCommand = formatCommandForTerminal(command)
|
||||
const terminalInfo = await terminalManager.getOrCreateTerminal(cwd)
|
||||
const terminalInfo = await terminalManager.getOrCreateTerminal(cwd, terminalProfileId)
|
||||
terminalInfo.terminal.show()
|
||||
|
||||
const process = terminalManager.runCommand(terminalInfo, terminalCommand)
|
||||
@@ -100,7 +185,7 @@ export async function executeForeground(
|
||||
// truncateCommandOutput's own head/tail strategy below — since build/test
|
||||
// failures usually appear at the end of output.
|
||||
const maxBufferedLines = MAX_UNRETRIEVED_LINES
|
||||
process.on("line", (line: string) => {
|
||||
const bufferLine = (line: string): void => {
|
||||
if (outputLines.length < maxBufferedLines) {
|
||||
outputLines.push(line)
|
||||
} else {
|
||||
@@ -108,7 +193,8 @@ export async function executeForeground(
|
||||
outputLines.push(line)
|
||||
droppedLines++
|
||||
}
|
||||
})
|
||||
}
|
||||
process.on("line", bufferLine)
|
||||
|
||||
// Handle abort signal
|
||||
if (abortSignal) {
|
||||
@@ -121,8 +207,33 @@ export async function executeForeground(
|
||||
process.once("continue", cleanupAbortListener)
|
||||
}
|
||||
|
||||
// Wait for completion
|
||||
await process
|
||||
// "Proceed While Running": register a per-invocation handle so the user
|
||||
// can detach this command. Detaching redirects the remaining output to a
|
||||
// log file and resolves the awaited promise; the command keeps running in
|
||||
// the user's terminal (and the terminal stays busy until it completes).
|
||||
let detachedLogFilePath: string | undefined
|
||||
const unregister = foregroundCommands?.register({
|
||||
detach: () => {
|
||||
if (detachedLogFilePath !== undefined) {
|
||||
return
|
||||
}
|
||||
detachedLogFilePath = beginLogCapture(process, terminalCommand, outputLines)
|
||||
telemetryService.captureTerminalUserIntervention(TerminalUserInterventionAction.PROCESS_WHILE_RUNNING, "vscode")
|
||||
// detach() flushes any partial line (reaching both bufferLine and
|
||||
// the log) before resolving the awaited promise. After that the
|
||||
// partial output is final: stop buffering so the remaining
|
||||
// (log-only) output doesn't mutate outputLines while it's read.
|
||||
process.detach()
|
||||
process.removeListener("line", bufferLine)
|
||||
},
|
||||
})
|
||||
|
||||
try {
|
||||
// Wait for completion (or detach, which also resolves the promise)
|
||||
await process
|
||||
} finally {
|
||||
unregister?.()
|
||||
}
|
||||
if (abortSignal?.aborted) {
|
||||
throw new Error("Command execution aborted")
|
||||
}
|
||||
@@ -135,6 +246,14 @@ export async function executeForeground(
|
||||
maxChars: maxOutputChars,
|
||||
})
|
||||
|
||||
if (detachedLogFilePath !== undefined) {
|
||||
return [
|
||||
"The user chose to proceed while the command is still running in their terminal.",
|
||||
`This is partial output; further output is being redirected to this file, which you can read to check progress: ${detachedLogFilePath}`,
|
||||
output.length > 0 ? `Output so far:\n${output}` : "No output so far.",
|
||||
].join("\n")
|
||||
}
|
||||
|
||||
const completionDetails = process.getCompletionDetails?.()
|
||||
|
||||
// A terminal closed mid-command has no exit code and no reliable output —
|
||||
@@ -171,26 +290,56 @@ export async function executeForeground(
|
||||
// Tool factory
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* The shell selected by the user's terminal profile setting at one moment:
|
||||
* the profile ID for foreground terminal creation and the shell executable
|
||||
* it resolves to for background spawning and description building.
|
||||
*/
|
||||
interface ShellSnapshot {
|
||||
profileId: string
|
||||
shell: string
|
||||
}
|
||||
|
||||
/** Resolves the shell the user's terminal profile setting selects right now. */
|
||||
function takeShellSnapshot(): ShellSnapshot {
|
||||
// The setting is typed string, but guard empty values the same way the
|
||||
// settings handlers do (they skip persisting "" but older stores may hold one).
|
||||
const profileId = StateManager.get().getGlobalSettingsKey("defaultTerminalProfile") || "default"
|
||||
return { profileId, shell: getShellForProfile(profileId) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the custom `run_commands` tool for the VSCode extension.
|
||||
*
|
||||
* This tool suppresses and replaces the SDK's built-in `run_commands` tool.
|
||||
* The terminal execution mode is captured when the session's tool set is built.
|
||||
* Switching modes rebuilds the active SDK session so the tool timeout and
|
||||
* execution mode stay aligned.
|
||||
* The terminal execution mode is captured when the session's tool set is
|
||||
* built; switching modes rebuilds the active SDK session so the tool timeout
|
||||
* and execution path follow it.
|
||||
*
|
||||
* The shell is snapshotted each time the runtime reads the tool description,
|
||||
* which happens when a model request is built. Tool calls produced by that
|
||||
* request execute with the same snapshot, so changing the terminal profile
|
||||
* while the model is generating does not change the shell under commands the
|
||||
* model has already planned: the new shell is named in the next request (the
|
||||
* one carrying these tool results) and used by the commands it produces.
|
||||
*/
|
||||
export function createVscodeRunCommandsTool(options: VscodeRunCommandsToolOptions): AgentTool {
|
||||
return createShellTool(createVscodeShellExecutor(options), {
|
||||
const state = { snapshot: takeShellSnapshot() }
|
||||
return createShellTool(createVscodeShellExecutor(options, state), {
|
||||
cwd: options.cwd,
|
||||
bashTimeoutMs: options.bashTimeoutMs,
|
||||
shell: () => {
|
||||
state.snapshot = takeShellSnapshot()
|
||||
return state.snapshot.shell
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function createVscodeShellExecutor(options: VscodeRunCommandsToolOptions): ShellExecutor {
|
||||
function createVscodeShellExecutor(options: VscodeRunCommandsToolOptions, state: { snapshot: ShellSnapshot }): ShellExecutor {
|
||||
const { cwd, getTerminalManager } = options
|
||||
const executionMode = options.vscodeTerminalExecutionMode ?? "backgroundExec"
|
||||
|
||||
// Lazy-init background executor — recreated when the user's shell profile changes.
|
||||
// Lazy-init background executor — recreated when the snapshotted shell changes.
|
||||
let bgExecutor: ShellExecutor | undefined
|
||||
let bgExecutorShell: string | undefined
|
||||
|
||||
@@ -200,12 +349,12 @@ function createVscodeShellExecutor(options: VscodeRunCommandsToolOptions): Shell
|
||||
return async (command, commandCwd, context): Promise<string> => {
|
||||
Logger.log(`[VscodeRunCommands] Executing command in ${executionMode} mode`)
|
||||
|
||||
if (executionMode === "backgroundExec") {
|
||||
// Background path — use SDK's createShellExecutor
|
||||
// Resolve shell from the user's terminal profile setting
|
||||
const profileId = (StateManager.get().getGlobalSettingsKey("defaultTerminalProfile") as string) || "default"
|
||||
const shell = getShellForProfile(profileId)
|
||||
// Execute with the shell named in the model request that produced this
|
||||
// tool call, not the setting's current value (see createVscodeRunCommandsTool).
|
||||
const { profileId, shell } = state.snapshot
|
||||
|
||||
if (executionMode === "backgroundExec") {
|
||||
// Background path — use SDK's createShellExecutor.
|
||||
// Recreate the executor if the shell has changed
|
||||
if (!bgExecutor || bgExecutorShell !== shell) {
|
||||
bgExecutorShell = shell
|
||||
@@ -240,6 +389,14 @@ function createVscodeShellExecutor(options: VscodeRunCommandsToolOptions): Shell
|
||||
if (!terminalManager) {
|
||||
terminalManager = getTerminalManager()
|
||||
}
|
||||
return await executeForeground(command, commandCwd || cwd, terminalManager, MAX_COMMAND_OUTPUT_CHARS, context.signal)
|
||||
return await executeForeground(
|
||||
command,
|
||||
commandCwd || cwd,
|
||||
terminalManager,
|
||||
MAX_COMMAND_OUTPUT_CHARS,
|
||||
context.signal,
|
||||
options.foregroundCommands,
|
||||
profileId,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { type AgentTool, type AgentToolContext, createTool } from "@cline/shared
|
||||
import type { VscodeTerminalManager } from "@/hosts/vscode/terminal/VscodeTerminalManager"
|
||||
import type { McpHub } from "@/services/mcp/McpHub"
|
||||
import { Logger } from "@/shared/services/Logger"
|
||||
import type { SdkForegroundCommandCoordinator } from "./sdk-foreground-command-coordinator"
|
||||
import { createVscodeRunCommandsTool, VSCODE_FOREGROUND_RUN_COMMANDS_TIMEOUT_MS } from "./vscode-run-commands-tool"
|
||||
|
||||
interface McpToolDescriptor {
|
||||
@@ -124,6 +125,8 @@ export interface VscodeExtraToolsOptions {
|
||||
getTerminalManager?: () => VscodeTerminalManager
|
||||
/** Current VS Code terminal execution mode, captured when the session tools are built. */
|
||||
vscodeTerminalExecutionMode?: "vscodeTerminal" | "backgroundExec"
|
||||
/** Registry of in-flight foreground executions for "Proceed While Running". */
|
||||
foregroundCommands?: SdkForegroundCommandCoordinator
|
||||
}
|
||||
|
||||
export async function createVscodeExtraTools(mcpHub: McpHub, options?: VscodeExtraToolsOptions): Promise<AgentTool[]> {
|
||||
@@ -159,6 +162,7 @@ export async function createVscodeExtraTools(mcpHub: McpHub, options?: VscodeExt
|
||||
getTerminalManager: options.getTerminalManager,
|
||||
bashTimeoutMs: executionMode === "vscodeTerminal" ? VSCODE_FOREGROUND_RUN_COMMANDS_TIMEOUT_MS : undefined,
|
||||
vscodeTerminalExecutionMode: executionMode,
|
||||
foregroundCommands: options.foregroundCommands,
|
||||
}),
|
||||
)
|
||||
Logger.log(
|
||||
|
||||
@@ -36,9 +36,10 @@ import type { VscodeTerminalManager } from "@/hosts/vscode/terminal/VscodeTermin
|
||||
import { getDistinctId } from "@/services/logging/distinctId"
|
||||
import type { McpHub } from "@/services/mcp/McpHub"
|
||||
import { Logger } from "@/shared/services/Logger"
|
||||
import type { SdkForegroundCommandCoordinator } from "./sdk-foreground-command-coordinator"
|
||||
import type { SdkSessionHost } from "./session-host"
|
||||
import { getEffectiveTerminalExecutionMode } from "./vscode-terminal-execution-mode"
|
||||
import { createVscodeExtraTools } from "./vscode-runtime-builder"
|
||||
import { getEffectiveTerminalExecutionMode } from "./vscode-terminal-execution-mode"
|
||||
|
||||
export interface VscodeSessionHostOptions {
|
||||
mcpHub: McpHub
|
||||
@@ -75,6 +76,8 @@ export interface VscodeSessionHostOptions {
|
||||
* with a custom tool that supports foreground/background terminal execution.
|
||||
*/
|
||||
getTerminalManager?: () => VscodeTerminalManager
|
||||
/** Registry of in-flight foreground executions for "Proceed While Running". */
|
||||
foregroundCommands?: SdkForegroundCommandCoordinator
|
||||
}
|
||||
|
||||
export class VscodeSessionHost implements SdkSessionHost {
|
||||
@@ -133,6 +136,7 @@ export class VscodeSessionHost implements SdkSessionHost {
|
||||
cwd: inputWithRemoteConfig.config.cwd,
|
||||
getTerminalManager: options.getTerminalManager,
|
||||
vscodeTerminalExecutionMode: getEffectiveTerminalExecutionMode(requestedTerminalExecutionMode),
|
||||
foregroundCommands: options.foregroundCommands,
|
||||
})
|
||||
return {
|
||||
...inputWithRemoteConfig,
|
||||
|
||||
@@ -124,6 +124,7 @@ export class WebviewGrpcBridge {
|
||||
stateManager,
|
||||
mcpHub: undefined,
|
||||
backgroundCommandRunning: false,
|
||||
foregroundCommandRunning: false,
|
||||
backgroundCommandTaskId: undefined,
|
||||
})
|
||||
await sendStateUpdate(state)
|
||||
|
||||
@@ -93,6 +93,11 @@ export interface ExtensionState {
|
||||
vscodeTerminalExecutionMode: string
|
||||
backgroundCommandRunning?: boolean
|
||||
backgroundCommandTaskId?: string
|
||||
/**
|
||||
* True while a foreground (VS Code terminal) command is awaited by a
|
||||
* run_commands tool call. Drives the "Proceed While Running" button.
|
||||
*/
|
||||
foregroundCommandRunning?: boolean
|
||||
lastCompletedCommandTs?: number
|
||||
userInfo?: UserInfo
|
||||
version: string
|
||||
|
||||
@@ -61,8 +61,10 @@
|
||||
* JetBrains exports trusted certificates from the OS and writes them to a
|
||||
* temporary file, then configures node TLS by setting NODE_EXTRA_CA_CERTS.
|
||||
*
|
||||
* CLI users should set the NODE_EXTRA_CA_CERTS environment variable if
|
||||
* necessary, because node does not automatically use the OS' trusted certs.
|
||||
* The CLI's npm wrapper (bin/cline) does the same automatically: it harvests
|
||||
* the OS trust store and points the child's NODE_EXTRA_CA_CERTS at a managed
|
||||
* bundle, because the Bun runtime does not read the OS store on its own. A
|
||||
* user-set NODE_EXTRA_CA_CERTS is merged in rather than replaced.
|
||||
*
|
||||
* ## Limitations in JetBrains & CLI
|
||||
*
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import type { ModelInfo } from "@shared/api"
|
||||
import { ModelOverrides } from "@shared/proto/cline/models"
|
||||
|
||||
/**
|
||||
* Domain shape of user-authored per-model metadata overrides, shared by the
|
||||
* webview (commit path) and the host (read/commit handlers). Mirrors the
|
||||
* `ModelOverrides` proto message.
|
||||
*
|
||||
* Semantics (enforced host-side in the provider config store):
|
||||
* - `capabilities` accepts only SDK `ModelCapability` values; unknown
|
||||
* strings are silently dropped. The array is additive over the base
|
||||
* metadata; the explicit `supports*` booleans win when both are present.
|
||||
* - `isR1FormatRequired` is a legacy alias that forces the R1 chat format
|
||||
* only when true; `apiFormat` is canonical.
|
||||
* - Invalid numbers (non-positive token limits, negative prices or
|
||||
* temperature, non-finite values) are silently discarded, not rejected.
|
||||
*
|
||||
* When committing a selection, the overrides value is tri-state: `undefined`
|
||||
* preserves the model's stored overrides, an explicitly empty object clears
|
||||
* them, and a non-empty object replaces them wholesale (no per-field merge).
|
||||
*/
|
||||
export interface ProviderModelOverrides {
|
||||
name?: string
|
||||
maxTokens?: number
|
||||
contextWindow?: number
|
||||
maxInputTokens?: number
|
||||
capabilities?: readonly string[]
|
||||
supportsVision?: boolean
|
||||
supportsAttachments?: boolean
|
||||
supportsReasoning?: boolean
|
||||
inputPrice?: number
|
||||
outputPrice?: number
|
||||
cacheReadsPrice?: number
|
||||
cacheWritesPrice?: number
|
||||
temperature?: number
|
||||
apiFormat?: ModelInfo["apiFormat"]
|
||||
isR1FormatRequired?: boolean
|
||||
}
|
||||
|
||||
export function toProtobufModelOverrides(overrides: ProviderModelOverrides): ModelOverrides {
|
||||
return ModelOverrides.create({
|
||||
name: overrides.name,
|
||||
maxTokens: overrides.maxTokens,
|
||||
contextWindow: overrides.contextWindow,
|
||||
maxInputTokens: overrides.maxInputTokens,
|
||||
capabilities: overrides.capabilities ? [...overrides.capabilities] : [],
|
||||
supportsVision: overrides.supportsVision,
|
||||
supportsAttachments: overrides.supportsAttachments,
|
||||
supportsReasoning: overrides.supportsReasoning,
|
||||
inputPrice: overrides.inputPrice,
|
||||
outputPrice: overrides.outputPrice,
|
||||
cacheReadsPrice: overrides.cacheReadsPrice,
|
||||
cacheWritesPrice: overrides.cacheWritesPrice,
|
||||
temperature: overrides.temperature,
|
||||
apiFormat: overrides.apiFormat,
|
||||
isR1FormatRequired: overrides.isR1FormatRequired,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Preserves the proto tri-state: `undefined` stays `undefined` (no override
|
||||
* payload), and an empty message becomes an empty object (explicit clear).
|
||||
*/
|
||||
export function fromProtobufModelOverrides(overrides: ModelOverrides | undefined): ProviderModelOverrides | undefined {
|
||||
if (!overrides) {
|
||||
return undefined
|
||||
}
|
||||
return {
|
||||
...(overrides.name !== undefined ? { name: overrides.name } : {}),
|
||||
...(overrides.maxTokens !== undefined ? { maxTokens: overrides.maxTokens } : {}),
|
||||
...(overrides.contextWindow !== undefined ? { contextWindow: overrides.contextWindow } : {}),
|
||||
...(overrides.maxInputTokens !== undefined ? { maxInputTokens: overrides.maxInputTokens } : {}),
|
||||
...(overrides.capabilities.length > 0 ? { capabilities: [...overrides.capabilities] } : {}),
|
||||
...(overrides.supportsVision !== undefined ? { supportsVision: overrides.supportsVision } : {}),
|
||||
...(overrides.supportsAttachments !== undefined ? { supportsAttachments: overrides.supportsAttachments } : {}),
|
||||
...(overrides.supportsReasoning !== undefined ? { supportsReasoning: overrides.supportsReasoning } : {}),
|
||||
...(overrides.inputPrice !== undefined ? { inputPrice: overrides.inputPrice } : {}),
|
||||
...(overrides.outputPrice !== undefined ? { outputPrice: overrides.outputPrice } : {}),
|
||||
...(overrides.cacheReadsPrice !== undefined ? { cacheReadsPrice: overrides.cacheReadsPrice } : {}),
|
||||
...(overrides.cacheWritesPrice !== undefined ? { cacheWritesPrice: overrides.cacheWritesPrice } : {}),
|
||||
...(overrides.temperature !== undefined ? { temperature: overrides.temperature } : {}),
|
||||
...(overrides.apiFormat !== undefined ? { apiFormat: overrides.apiFormat } : {}),
|
||||
...(overrides.isR1FormatRequired !== undefined ? { isR1FormatRequired: overrides.isR1FormatRequired } : {}),
|
||||
}
|
||||
}
|
||||
@@ -32,8 +32,9 @@
|
||||
// Importing the real package here is safe: the preload runs before any test
|
||||
// file, so this is the only point the real module is linked, and we only read
|
||||
// its export *names*, never its behavior (the mock shadows it everywhere tests look).
|
||||
import { vi as bunVi, mock } from "bun:test"
|
||||
import { beforeEach as bunBeforeEach, vi as bunVi, mock } from "bun:test"
|
||||
import * as realClineCore from "@cline/core"
|
||||
import * as LlmsModels from "@cline/llms"
|
||||
import * as clineCoreStub from "./cline-core-vitest-stub"
|
||||
import * as vscodeStub from "./vscode-vitest-stub"
|
||||
|
||||
@@ -43,6 +44,13 @@ for (const name of Object.keys(realClineCore)) {
|
||||
}
|
||||
Object.assign(clineCoreNamespace, clineCoreStub)
|
||||
|
||||
bunBeforeEach(() => {
|
||||
clineCoreStub.resetModelsFileState()
|
||||
// The stub's syncStoredProviderRegistration mutates the real shared
|
||||
// @cline/llms registry; reset it so registrations never leak across tests.
|
||||
LlmsModels.resetRegistry()
|
||||
})
|
||||
|
||||
mock.module("@cline/core", () => clineCoreNamespace)
|
||||
|
||||
// `vscode`: the stub provides both named exports (Position, Uri, …) and a
|
||||
|
||||
@@ -13,6 +13,54 @@ export interface StartSessionResult {
|
||||
|
||||
export const MAX_COMMAND_OUTPUT_CHARS = 200_000
|
||||
|
||||
export interface StoredModelEntry {
|
||||
id?: string
|
||||
name?: string
|
||||
maxTokens?: number
|
||||
contextWindow?: number
|
||||
maxInputTokens?: number
|
||||
capabilities?: string[]
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export interface StoredModelsFile {
|
||||
version: 1
|
||||
providers: Record<string, { models?: Record<string, StoredModelEntry> }>
|
||||
}
|
||||
|
||||
const modelsFiles = new Map<string, StoredModelsFile>()
|
||||
|
||||
function cloneModelsFile(modelsFile: StoredModelsFile): StoredModelsFile {
|
||||
return structuredClone(modelsFile)
|
||||
}
|
||||
|
||||
export function resetModelsFileState(): void {
|
||||
modelsFiles.clear()
|
||||
}
|
||||
|
||||
export function readModelsFileSync(filePath: string): StoredModelsFile {
|
||||
return cloneModelsFile(modelsFiles.get(filePath) ?? { version: 1, providers: {} })
|
||||
}
|
||||
|
||||
export function writeModelsFileSync(filePath: string, next: StoredModelsFile): void {
|
||||
modelsFiles.set(filePath, cloneModelsFile(next))
|
||||
}
|
||||
|
||||
export function resolveModelsRegistryPath(): string {
|
||||
return "/tmp/models.json"
|
||||
}
|
||||
|
||||
export function ensureCustomProvidersLoadedSync(): void {}
|
||||
|
||||
// Real implementation re-exported from the sdk source (same pattern as the
|
||||
// apply-patch executors below) so store writes are reflected in the live
|
||||
// @cline/llms registry exactly as in production. Tests that touch it must
|
||||
// reset the registry (LlmsModels.resetRegistry()) between tests.
|
||||
export {
|
||||
StoredModelEntrySchema,
|
||||
syncStoredProviderRegistration,
|
||||
} from "../../../../sdk/packages/core/src/services/providers/local-provider-registry"
|
||||
|
||||
export type GlobalCompactionStrategy = "basic" | "agentic"
|
||||
|
||||
export function readCompactionStrategyGlobally(): GlobalCompactionStrategy {
|
||||
@@ -53,6 +101,10 @@ export function createShellExecutor() {
|
||||
return async () => ""
|
||||
}
|
||||
|
||||
// The real createShellTool, so tests exercise the actual description
|
||||
// building and shell classification (getShellKind) rather than a stub that
|
||||
// would have to duplicate those invariants.
|
||||
export { createShellTool } from "../../../../sdk/packages/core/src/extensions/tools/definitions"
|
||||
// Real (dependency-light) edit-executor implementations, re-exported from the sdk source so
|
||||
// the diff-edit coordinator and its tests exercise the actual content/parse semantics. These
|
||||
// modules only pull in node:fs/node:path and the patch parser — not the heavy core runtime.
|
||||
@@ -66,13 +118,6 @@ export { createEditorExecutor } from "../../../../sdk/packages/core/src/extensio
|
||||
export type { EditFileInput } from "../../../../sdk/packages/core/src/extensions/tools/schemas"
|
||||
export type { ApplyPatchExecutor, EditorExecutor } from "../../../../sdk/packages/core/src/extensions/tools/types"
|
||||
|
||||
export function createShellTool(execute: unknown) {
|
||||
return {
|
||||
name: "run_commands",
|
||||
execute,
|
||||
}
|
||||
}
|
||||
|
||||
export interface SessionHistoryRecord {
|
||||
id: string
|
||||
metadata?: Record<string, unknown>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { afterEach, beforeEach, describe, it, mock } from "bun:test"
|
||||
import { expect } from "chai"
|
||||
import * as actualFs from "fs"
|
||||
import * as actualOs from "os"
|
||||
import * as vscode from "vscode"
|
||||
|
||||
@@ -15,6 +16,16 @@ const osMock = () => ({ ...osMockNamespace, default: osMockNamespace })
|
||||
mock.module("os", osMock)
|
||||
mock.module("node:os", osMock)
|
||||
|
||||
// getShell() probes the filesystem for PowerShell 7 when no Windows terminal
|
||||
// profile is configured. Route existsSync through a mutable delegate so tests
|
||||
// control which PowerShell installs "exist" regardless of the host machine.
|
||||
let existsSyncImpl: typeof actualFs.existsSync = actualFs.existsSync
|
||||
const existsSyncDelegate = ((path: unknown) => existsSyncImpl(path as string)) as typeof actualFs.existsSync
|
||||
const fsMockNamespace = { ...actualFs, existsSync: existsSyncDelegate }
|
||||
const fsMock = () => ({ ...fsMockNamespace, default: fsMockNamespace })
|
||||
mock.module("fs", fsMock)
|
||||
mock.module("node:fs", fsMock)
|
||||
|
||||
import { getShell } from "@utils/shell"
|
||||
|
||||
describe("Shell Detection Tests", () => {
|
||||
@@ -22,6 +33,7 @@ describe("Shell Detection Tests", () => {
|
||||
let originalEnv: NodeJS.ProcessEnv
|
||||
let originalGetConfig: typeof vscode.workspace.getConfiguration
|
||||
let originalUserInfo: typeof actualOs.userInfo
|
||||
let originalExistsSync: typeof actualFs.existsSync
|
||||
|
||||
// Helper to mock VS Code configuration
|
||||
function mockVsCodeConfig(platformKey: string, defaultProfileName: string | null, profiles: Record<string, any>) {
|
||||
@@ -45,6 +57,7 @@ describe("Shell Detection Tests", () => {
|
||||
originalEnv = { ...process.env }
|
||||
originalGetConfig = vscode.workspace.getConfiguration
|
||||
originalUserInfo = userInfoImpl
|
||||
originalExistsSync = existsSyncImpl
|
||||
|
||||
// Clear environment variables for a clean test
|
||||
delete process.env.SHELL
|
||||
@@ -52,6 +65,9 @@ describe("Shell Detection Tests", () => {
|
||||
|
||||
// Default userInfo() mock
|
||||
userInfoImpl = (() => ({ shell: null })) as any
|
||||
// Default: PowerShell 7 is not installed, so the Windows default
|
||||
// resolves to legacy Windows PowerShell.
|
||||
existsSyncImpl = (() => false) as any
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
@@ -60,6 +76,7 @@ describe("Shell Detection Tests", () => {
|
||||
process.env = originalEnv
|
||||
vscode.workspace.getConfiguration = originalGetConfig
|
||||
userInfoImpl = originalUserInfo
|
||||
existsSyncImpl = originalExistsSync
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
@@ -71,12 +88,63 @@ describe("Shell Detection Tests", () => {
|
||||
})
|
||||
|
||||
it("uses explicit PowerShell 7 path from VS Code config (profile path)", () => {
|
||||
existsSyncImpl = ((candidate: actualFs.PathLike) =>
|
||||
candidate === "C:\\Program Files\\PowerShell\\7\\pwsh.exe") as typeof actualFs.existsSync
|
||||
mockVsCodeConfig("windows", "PowerShell", {
|
||||
PowerShell: { path: "C:\\Program Files\\PowerShell\\7\\pwsh.exe" },
|
||||
})
|
||||
expect(getShell()).to.equal("C:\\Program Files\\PowerShell\\7\\pwsh.exe")
|
||||
})
|
||||
|
||||
it("expands and selects the first configured profile path when it exists", () => {
|
||||
process.env.windir = "C:\\Windows"
|
||||
existsSyncImpl = (() => true) as typeof actualFs.existsSync
|
||||
mockVsCodeConfig("windows", "Command Prompt", {
|
||||
"Command Prompt": {
|
||||
path: [`\${env:windir}\\Sysnative\\cmd.exe`, `\${env:windir}\\System32\\cmd.exe`],
|
||||
},
|
||||
})
|
||||
|
||||
expect(getShell()).to.equal("C:\\Windows\\Sysnative\\cmd.exe")
|
||||
})
|
||||
|
||||
it("falls through configured profile paths in order", () => {
|
||||
process.env.windir = "C:\\Windows"
|
||||
existsSyncImpl = ((candidate: actualFs.PathLike) =>
|
||||
candidate === "C:\\Windows\\System32\\cmd.exe") as typeof actualFs.existsSync
|
||||
mockVsCodeConfig("windows", "Command Prompt", {
|
||||
"Command Prompt": {
|
||||
path: [`\${env:windir}\\Sysnative\\cmd.exe`, `\${env:windir}\\System32\\cmd.exe`],
|
||||
},
|
||||
})
|
||||
|
||||
expect(getShell()).to.equal("C:\\Windows\\System32\\cmd.exe")
|
||||
})
|
||||
|
||||
it("skips profile paths with variable references it cannot expand", () => {
|
||||
existsSyncImpl = ((candidate: actualFs.PathLike) =>
|
||||
candidate === "C:\\Windows\\System32\\cmd.exe") as typeof actualFs.existsSync
|
||||
process.env.windir = "C:\\Windows"
|
||||
mockVsCodeConfig("windows", "Command Prompt", {
|
||||
"Command Prompt": {
|
||||
path: [`\${workspaceFolder}\\tools\\cmd.exe`, `\${env:windir}\\System32\\cmd.exe`],
|
||||
},
|
||||
})
|
||||
|
||||
expect(getShell()).to.equal("C:\\Windows\\System32\\cmd.exe")
|
||||
})
|
||||
|
||||
it("resolves a configured executable name from PATH", () => {
|
||||
process.env.PATH = "C:\\Tools;C:\\Windows\\System32"
|
||||
existsSyncImpl = ((candidate: actualFs.PathLike) =>
|
||||
candidate === "C:\\Windows\\System32\\cmd.exe") as typeof actualFs.existsSync
|
||||
mockVsCodeConfig("windows", "Command Prompt", {
|
||||
"Command Prompt": { path: "cmd.exe" },
|
||||
})
|
||||
|
||||
expect(getShell()).to.equal("C:\\Windows\\System32\\cmd.exe")
|
||||
})
|
||||
|
||||
it("uses PowerShell 7 path if source is 'PowerShell' but no explicit path", () => {
|
||||
mockVsCodeConfig("windows", "PowerShell", {
|
||||
PowerShell: { source: "PowerShell" },
|
||||
@@ -117,18 +185,36 @@ describe("Shell Detection Tests", () => {
|
||||
expect(getShell()).to.equal("C:\\Windows\\System32\\cmd.exe")
|
||||
})
|
||||
|
||||
it("respects userInfo() if no VS Code config is available", () => {
|
||||
it("defaults to PowerShell 7 when no profile is configured and pwsh is installed", () => {
|
||||
vscode.workspace.getConfiguration = () => ({ get: () => undefined }) as any
|
||||
userInfoImpl = () => ({ shell: "C:\\Custom\\PowerShell.exe" }) as any
|
||||
process.env.ProgramW6432 = "C:\\Program Files"
|
||||
existsSyncImpl = (() => true) as any
|
||||
|
||||
expect(getShell()).to.equal("C:\\Custom\\PowerShell.exe")
|
||||
expect(getShell()).to.equal("C:\\Program Files\\PowerShell\\7\\pwsh.exe")
|
||||
})
|
||||
|
||||
it("respects an odd COMSPEC if no userInfo shell is available", () => {
|
||||
it("defaults to Store-installed pwsh when that is the only pwsh present", () => {
|
||||
vscode.workspace.getConfiguration = () => ({ get: () => undefined }) as any
|
||||
process.env.LOCALAPPDATA = "C:\\Users\\Test\\AppData\\Local"
|
||||
const storePwsh = "C:\\Users\\Test\\AppData\\Local\\Microsoft\\WindowsApps\\pwsh.exe"
|
||||
existsSyncImpl = ((path: string) => path === storePwsh) as any
|
||||
|
||||
expect(getShell()).to.equal(storePwsh)
|
||||
})
|
||||
|
||||
it("defaults to legacy Windows PowerShell when no profile is configured and pwsh is absent", () => {
|
||||
vscode.workspace.getConfiguration = () => ({ get: () => undefined }) as any
|
||||
existsSyncImpl = (() => false) as any
|
||||
|
||||
expect(getShell()).to.equal("C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe")
|
||||
})
|
||||
|
||||
it("ignores userInfo() and COMSPEC — VS Code's default terminal ignores them too", () => {
|
||||
vscode.workspace.getConfiguration = () => ({ get: () => undefined }) as any
|
||||
userInfoImpl = () => ({ shell: "C:\\Custom\\OtherShell.exe" }) as any
|
||||
process.env.COMSPEC = "D:\\CustomCmd\\cmd.exe"
|
||||
|
||||
expect(getShell()).to.equal("D:\\CustomCmd\\cmd.exe")
|
||||
expect(getShell()).to.equal("C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -141,12 +227,31 @@ describe("Shell Detection Tests", () => {
|
||||
})
|
||||
|
||||
it("uses VS Code profile path if available", () => {
|
||||
existsSyncImpl = ((candidate: actualFs.PathLike) => candidate === "/usr/local/bin/fish") as typeof actualFs.existsSync
|
||||
mockVsCodeConfig("osx", "MyCustomShell", {
|
||||
MyCustomShell: { path: "/usr/local/bin/fish" },
|
||||
})
|
||||
expect(getShell()).to.equal("/usr/local/bin/fish")
|
||||
})
|
||||
|
||||
it("expands and selects the first existing path in an array-valued profile", () => {
|
||||
existsSyncImpl = ((candidate: actualFs.PathLike) => candidate === "/bin/zsh") as typeof actualFs.existsSync
|
||||
mockVsCodeConfig("osx", "MyCustomShell", {
|
||||
MyCustomShell: { path: ["/opt/homebrew/bin/zsh", "/bin/zsh"] },
|
||||
})
|
||||
expect(getShell()).to.equal("/bin/zsh")
|
||||
})
|
||||
|
||||
it("falls back past a configured path that does not exist", () => {
|
||||
existsSyncImpl = (() => false) as typeof actualFs.existsSync
|
||||
mockVsCodeConfig("osx", "MyCustomShell", {
|
||||
MyCustomShell: { path: "/missing/shell" },
|
||||
})
|
||||
userInfoImpl = () => ({ shell: "/opt/homebrew/bin/zsh" }) as any
|
||||
|
||||
expect(getShell()).to.equal("/opt/homebrew/bin/zsh")
|
||||
})
|
||||
|
||||
it("falls back to userInfo().shell if no VS Code config is available", () => {
|
||||
vscode.workspace.getConfiguration = () => ({ get: () => undefined }) as any
|
||||
userInfoImpl = () => ({ shell: "/opt/homebrew/bin/zsh" }) as any
|
||||
@@ -177,12 +282,30 @@ describe("Shell Detection Tests", () => {
|
||||
})
|
||||
|
||||
it("uses VS Code profile path if available", () => {
|
||||
existsSyncImpl = ((candidate: actualFs.PathLike) => candidate === "/usr/bin/fish") as typeof actualFs.existsSync
|
||||
mockVsCodeConfig("linux", "CustomProfile", {
|
||||
CustomProfile: { path: "/usr/bin/fish" },
|
||||
})
|
||||
expect(getShell()).to.equal("/usr/bin/fish")
|
||||
})
|
||||
|
||||
it("expands and selects the first existing path in an array-valued profile", () => {
|
||||
existsSyncImpl = ((candidate: actualFs.PathLike) => candidate === "/bin/bash") as typeof actualFs.existsSync
|
||||
mockVsCodeConfig("linux", "CustomProfile", {
|
||||
CustomProfile: { path: ["/usr/bin/fish", "/bin/bash"] },
|
||||
})
|
||||
expect(getShell()).to.equal("/bin/bash")
|
||||
})
|
||||
|
||||
it("resolves a bare executable name from PATH without PATHEXT probing", () => {
|
||||
process.env.PATH = "/opt/tools:/usr/bin"
|
||||
existsSyncImpl = ((candidate: actualFs.PathLike) => candidate === "/usr/bin/fish") as typeof actualFs.existsSync
|
||||
mockVsCodeConfig("linux", "CustomProfile", {
|
||||
CustomProfile: { path: "fish" },
|
||||
})
|
||||
expect(getShell()).to.equal("/usr/bin/fish")
|
||||
})
|
||||
|
||||
it("falls back to userInfo().shell if no VS Code config is available", () => {
|
||||
vscode.workspace.getConfiguration = () => ({ get: () => undefined }) as any
|
||||
userInfoImpl = () => ({ shell: "/usr/bin/zsh" }) as any
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
// Under vitest, `@cline/core` is aliased to src/test/cline-core-vitest-stub.ts
|
||||
// (see vitest.config.ts), which holds models.json state in memory and exposes
|
||||
// the stub-only `resetModelsFileState` — hence the cast below.
|
||||
import * as ClineCore from "@cline/core"
|
||||
import { resetRegistry } from "@cline/llms"
|
||||
import { beforeEach } from "vitest"
|
||||
|
||||
const { resetModelsFileState } = ClineCore as typeof ClineCore & { resetModelsFileState(): void }
|
||||
|
||||
beforeEach(() => {
|
||||
resetModelsFileState()
|
||||
// The stub's syncStoredProviderRegistration mutates the real shared
|
||||
// @cline/llms registry; reset it so registrations never leak across tests.
|
||||
resetRegistry()
|
||||
})
|
||||
@@ -1,6 +1,6 @@
|
||||
import * as childProcess from "child_process"
|
||||
import { Logger } from "@/shared/services/Logger"
|
||||
import { WINDOWS_POWERSHELL_7_PATH, WINDOWS_POWERSHELL_LEGACY_PATH } from "./shell"
|
||||
import { getWindowsPwshInstallPaths, WINDOWS_POWERSHELL_LEGACY_PATH } from "./shell"
|
||||
|
||||
const POWERSHELL_PROBE_TIMEOUT_MS = 1200
|
||||
|
||||
@@ -16,14 +16,7 @@ export function getFallbackWindowsPowerShellPath(): string {
|
||||
}
|
||||
|
||||
export function getWindowsPowerShellCandidates(): string[] {
|
||||
const programFiles = process.env.ProgramW6432 || process.env.ProgramFiles || "C:\\Program Files"
|
||||
|
||||
const envAbsoluteCandidates = [
|
||||
`${programFiles}\\PowerShell\\7\\pwsh.exe`,
|
||||
`${programFiles}\\PowerShell\\6\\pwsh.exe`,
|
||||
WINDOWS_POWERSHELL_7_PATH,
|
||||
WINDOWS_POWERSHELL_LEGACY_PATH,
|
||||
]
|
||||
const envAbsoluteCandidates = [...getWindowsPwshInstallPaths(), WINDOWS_POWERSHELL_LEGACY_PATH]
|
||||
|
||||
const commandNameFallbacks = ["pwsh.exe", "pwsh", "powershell.exe", "powershell"]
|
||||
|
||||
|
||||
+129
-24
@@ -1,5 +1,8 @@
|
||||
import { existsSync } from "fs"
|
||||
import { userInfo } from "os"
|
||||
import * as nodePath from "path"
|
||||
import * as vscode from "vscode"
|
||||
import { Logger } from "@/shared/services/Logger"
|
||||
|
||||
export const WINDOWS_POWERSHELL_7_PATH = "C:\\Program Files\\PowerShell\\7\\pwsh.exe"
|
||||
export const WINDOWS_POWERSHELL_LEGACY_PATH = "C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe"
|
||||
@@ -29,21 +32,24 @@ const SHELL_PATHS = {
|
||||
FALLBACK: "/bin/sh",
|
||||
} as const
|
||||
|
||||
// VS Code permits `path: string | string[]` in terminal profiles on every
|
||||
// platform (the stock Windows "Command Prompt" profile is an array), so all
|
||||
// three profile shapes model both forms.
|
||||
interface MacTerminalProfile {
|
||||
path?: string
|
||||
path?: string | string[]
|
||||
}
|
||||
|
||||
type MacTerminalProfiles = Record<string, MacTerminalProfile>
|
||||
|
||||
interface WindowsTerminalProfile {
|
||||
path?: string
|
||||
path?: string | string[]
|
||||
source?: "PowerShell" | "WSL"
|
||||
}
|
||||
|
||||
type WindowsTerminalProfiles = Record<string, WindowsTerminalProfile>
|
||||
|
||||
interface LinuxTerminalProfile {
|
||||
path?: string
|
||||
path?: string | string[]
|
||||
}
|
||||
|
||||
type LinuxTerminalProfiles = Record<string, LinuxTerminalProfile>
|
||||
@@ -89,6 +95,83 @@ function getLinuxTerminalConfig() {
|
||||
// 2) Platform-Specific VS Code Shell Retrieval
|
||||
// -----------------------------------------------------
|
||||
|
||||
function isWindows(): boolean {
|
||||
return process.platform === "win32"
|
||||
}
|
||||
|
||||
/** The path module matching the host platform's separators and semantics. */
|
||||
function hostPath(): nodePath.PlatformPath {
|
||||
return isWindows() ? nodePath.win32 : nodePath.posix
|
||||
}
|
||||
|
||||
function getEnvironmentVariable(name: string): string | undefined {
|
||||
// Windows environment variable names are case-insensitive; POSIX names
|
||||
// are case-sensitive.
|
||||
if (!isWindows()) {
|
||||
return process.env[name]
|
||||
}
|
||||
const entry = Object.entries(process.env).find(([key]) => key.toLowerCase() === name.toLowerCase())
|
||||
return entry?.[1]
|
||||
}
|
||||
|
||||
/** Expands VS Code's `${env:NAME}` references in a profile path. */
|
||||
function expandShellPath(candidate: string): string {
|
||||
return candidate.replace(/\$\{env:([^}]+)\}/gi, (reference, name: string) => {
|
||||
return getEnvironmentVariable(name.trim()) ?? reference
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a profile path candidate to an existing executable: absolute or
|
||||
* relative paths are checked directly; bare names are searched on PATH
|
||||
* (with PATHEXT on Windows, mirroring how VS Code launches profiles).
|
||||
*/
|
||||
function findExecutable(candidate: string): string | null {
|
||||
const path = hostPath()
|
||||
if (path.basename(candidate) !== candidate) {
|
||||
const normalized = path.normalize(candidate)
|
||||
return existsSync(normalized) ? normalized : null
|
||||
}
|
||||
|
||||
const pathValue = getEnvironmentVariable("PATH")
|
||||
if (!pathValue) {
|
||||
return null
|
||||
}
|
||||
|
||||
const extensions =
|
||||
!isWindows() || path.extname(candidate) ? [""] : (getEnvironmentVariable("PATHEXT") ?? ".COM;.EXE;.BAT;.CMD").split(";")
|
||||
for (const directory of pathValue.split(path.delimiter)) {
|
||||
for (const extension of extensions) {
|
||||
const executable = path.join(directory, `${candidate}${extension}`)
|
||||
if (existsSync(executable)) {
|
||||
return executable
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/** Resolves the first usable path in a VS Code terminal profile. */
|
||||
function resolveShellPath(configuredPath: string | string[] | undefined): string | null {
|
||||
const candidates = typeof configuredPath === "string" ? [configuredPath] : configuredPath
|
||||
for (const candidate of candidates ?? []) {
|
||||
const expandedPath = expandShellPath(candidate)
|
||||
if (expandedPath.includes("${")) {
|
||||
// Only ${env:NAME} references are expanded here. VS Code resolves
|
||||
// more variable kinds (e.g. ${workspaceFolder}); surface the gap
|
||||
// instead of silently skipping the user's configured shell.
|
||||
Logger.warn(`[shell] Skipping terminal profile path with unresolved variable reference: ${candidate}`)
|
||||
continue
|
||||
}
|
||||
const executable = findExecutable(expandedPath)
|
||||
if (executable) {
|
||||
return executable
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/** Attempts to retrieve a shell path from VS Code config on Windows. */
|
||||
function getWindowsShellFromVSCode(): string | null {
|
||||
const { defaultProfileName, profiles } = getWindowsTerminalConfig()
|
||||
@@ -97,14 +180,15 @@ function getWindowsShellFromVSCode(): string | null {
|
||||
}
|
||||
|
||||
const profile = profiles[defaultProfileName]
|
||||
const configuredShell = resolveShellPath(profile?.path)
|
||||
|
||||
// If the profile name indicates PowerShell, do version-based detection.
|
||||
// In testing it was found these typically do not have a path, and this
|
||||
// implementation manages to deductively get the correct version of PowerShell
|
||||
if (defaultProfileName.toLowerCase().includes("powershell")) {
|
||||
if (profile?.path) {
|
||||
if (configuredShell) {
|
||||
// If there's an explicit PowerShell path, return that
|
||||
return profile.path
|
||||
return configuredShell
|
||||
}
|
||||
if (profile?.source === "PowerShell") {
|
||||
// If the profile is sourced from PowerShell, assume the newest
|
||||
@@ -115,8 +199,8 @@ function getWindowsShellFromVSCode(): string | null {
|
||||
}
|
||||
|
||||
// If there's a specific path, return that immediately
|
||||
if (profile?.path) {
|
||||
return profile.path
|
||||
if (configuredShell) {
|
||||
return configuredShell
|
||||
}
|
||||
|
||||
// If the profile indicates WSL
|
||||
@@ -135,8 +219,7 @@ function getMacShellFromVSCode(): string | null {
|
||||
return null
|
||||
}
|
||||
|
||||
const profile = profiles[defaultProfileName]
|
||||
return profile?.path || null
|
||||
return resolveShellPath(profiles[defaultProfileName]?.path)
|
||||
}
|
||||
|
||||
/** Attempts to retrieve a shell path from VS Code config on Linux. */
|
||||
@@ -146,8 +229,7 @@ function getLinuxShellFromVSCode(): string | null {
|
||||
return null
|
||||
}
|
||||
|
||||
const profile = profiles[defaultProfileName]
|
||||
return profile?.path || null
|
||||
return resolveShellPath(profiles[defaultProfileName]?.path)
|
||||
}
|
||||
|
||||
// -----------------------------------------------------
|
||||
@@ -171,11 +253,6 @@ function getShellFromUserInfo(): string | null {
|
||||
function getShellFromEnv(): string | null {
|
||||
const { env } = process
|
||||
|
||||
if (process.platform === "win32") {
|
||||
// On Windows, COMSPEC typically holds cmd.exe
|
||||
return env.COMSPEC || "C:\\Windows\\System32\\cmd.exe"
|
||||
}
|
||||
|
||||
if (process.platform === "darwin") {
|
||||
// On macOS/Linux, SHELL is commonly the environment variable
|
||||
return env.SHELL || "/bin/zsh"
|
||||
@@ -304,6 +381,35 @@ export function getShellForProfile(profileId: string): string {
|
||||
// 5) Publicly Exposed Shell Getter
|
||||
// -----------------------------------------------------
|
||||
|
||||
/**
|
||||
* Absolute paths where a modern PowerShell (pwsh) may be installed, most
|
||||
* preferred first: MSI/ZIP installs under Program Files (either architecture),
|
||||
* then the Microsoft Store install under LOCALAPPDATA. This is the single
|
||||
* candidate list shared with the async prober in utils/powershell.ts.
|
||||
*/
|
||||
export function getWindowsPwshInstallPaths(): string[] {
|
||||
const programFiles = process.env.ProgramW6432 || process.env.ProgramFiles || "C:\\Program Files"
|
||||
const localAppData = process.env.LOCALAPPDATA
|
||||
return [
|
||||
`${programFiles}\\PowerShell\\7\\pwsh.exe`,
|
||||
`${programFiles}\\PowerShell\\6\\pwsh.exe`,
|
||||
SHELL_PATHS.POWERSHELL_7,
|
||||
...(localAppData ? [`${localAppData}\\Microsoft\\WindowsApps\\pwsh.exe`] : []),
|
||||
]
|
||||
}
|
||||
|
||||
/**
|
||||
* The shell VS Code launches on Windows when the user has not configured a
|
||||
* default terminal profile: its built-in default is PowerShell (pwsh when
|
||||
* installed, Windows PowerShell otherwise) — never cmd.exe. Mirroring that
|
||||
* here keeps the "default" profile meaning the same shell whether commands
|
||||
* run in a visible VS Code terminal or a background child process.
|
||||
*/
|
||||
function getWindowsDefaultShell(): string {
|
||||
const pwsh = getWindowsPwshInstallPaths().find((candidate) => existsSync(candidate))
|
||||
return pwsh ?? SHELL_PATHS.POWERSHELL_LEGACY
|
||||
}
|
||||
|
||||
export function getShell(): string {
|
||||
// 1. Check VS Code config first.
|
||||
if (process.platform === "win32") {
|
||||
@@ -312,7 +418,12 @@ export function getShell(): string {
|
||||
if (windowsShell) {
|
||||
return windowsShell
|
||||
}
|
||||
} else if (process.platform === "darwin") {
|
||||
// No profile configured — match the shell VS Code's default terminal
|
||||
// would launch. userInfo()/COMSPEC are not consulted: VS Code's own
|
||||
// terminal ignores them too, and they would resolve to cmd.exe.
|
||||
return getWindowsDefaultShell()
|
||||
}
|
||||
if (process.platform === "darwin") {
|
||||
// macOS from VS Code
|
||||
const macShell = getMacShellFromVSCode()
|
||||
if (macShell) {
|
||||
@@ -338,12 +449,6 @@ export function getShell(): string {
|
||||
return envShell
|
||||
}
|
||||
|
||||
// 4. Finally, fall back to a default
|
||||
if (process.platform === "win32") {
|
||||
// On Windows, if we got here, we have no config, no COMSPEC, and one very messed up operating system.
|
||||
// Use CMD as a last resort
|
||||
return SHELL_PATHS.CMD
|
||||
}
|
||||
// On macOS/Linux, fallback to a POSIX shell - This is the behavior of our old shell detection method.
|
||||
// 4. Fall back to a POSIX shell - This is the behavior of our old shell detection method.
|
||||
return SHELL_PATHS.FALLBACK
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ export default defineConfig({
|
||||
"src/core/controller/models/__tests__/refreshGroqModels.test.ts",
|
||||
],
|
||||
environment: "node",
|
||||
setupFiles: ["./src/test/vitest-setup.ts"],
|
||||
// Several suites lazily `await import()` their subject inside the first test
|
||||
// (needed so vi.mock factories apply first). That import pulls in heavy
|
||||
// workspace packages (@cline/core/@cline/llms/@cline/shared), and on loaded
|
||||
|
||||
+3
-3
@@ -20,7 +20,7 @@ interface ActionButtonsProps {
|
||||
*/
|
||||
export const ActionButtons: React.FC<ActionButtonsProps> = ({ task, messages, chatState, mode, messageHandlers }) => {
|
||||
const { inputValue, selectedImages, selectedFiles, setSendingDisabled } = chatState
|
||||
const { turnState } = useExtensionState()
|
||||
const { turnState, foregroundCommandRunning } = useExtensionState()
|
||||
|
||||
// Tracks the ask the user last acted on. Clicking a footer button latches this so the
|
||||
// buttons disable immediately (and survive the trailing bookkeeping re-renders before the
|
||||
@@ -41,8 +41,8 @@ export const ActionButtons: React.FC<ActionButtonsProps> = ({ task, messages, ch
|
||||
// buttons immune to trailing bookkeeping messages and never disagree with the thinking
|
||||
// indicator (RC1).
|
||||
const buttonConfig = useMemo(() => {
|
||||
return getButtonConfigFromState(messages, turnState, mode)
|
||||
}, [messages, turnState, mode])
|
||||
return getButtonConfigFromState(messages, turnState, mode, foregroundCommandRunning)
|
||||
}, [messages, turnState, mode, foregroundCommandRunning])
|
||||
|
||||
// Identity of the ask that currently owns the footer buttons. The button config objects are
|
||||
// shared singletons (e.g. BUTTON_CONFIGS.tool_approve), so two consecutive identical asks
|
||||
|
||||
@@ -367,6 +367,15 @@ export function useMessageHandlers(messages: ClineMessage[], chatState: ChatStat
|
||||
clearInputState()
|
||||
break
|
||||
|
||||
case "proceed_while_running":
|
||||
// Detach the running foreground terminal command: the agent
|
||||
// receives the partial output plus a log file path for the
|
||||
// rest, and the command keeps running in the terminal.
|
||||
await TaskServiceClient.proceedWhileRunningCommand(EmptyRequest.create({})).catch((err) =>
|
||||
console.error("Failed to proceed while running:", err),
|
||||
)
|
||||
break
|
||||
|
||||
case "new_task":
|
||||
if (clineAsk === "new_task") {
|
||||
await TaskServiceClient.newTask(
|
||||
|
||||
@@ -252,4 +252,19 @@ describe("getButtonConfigFromState (dispatch + legacy fallback)", () => {
|
||||
const turnState: TurnState = { phase: "completed", seq: 3 }
|
||||
expect(getButtonConfigFromState(messages, turnState, "act")).toEqual(BUTTON_CONFIGS.completion_result)
|
||||
})
|
||||
|
||||
it("streaming phase shows Proceed While Running when a foreground command is running", () => {
|
||||
const messages: ClineMessage[] = [{ ts: 1, type: "say", say: "command", text: "npm run dev", partial: true }]
|
||||
const turnState: TurnState = { phase: "streaming", seq: 4 }
|
||||
expect(getButtonConfigFromState(messages, turnState, "act", true)).toEqual(BUTTON_CONFIGS.foreground_command_running)
|
||||
expect(getButtonConfigFromState(messages, turnState, "act", false)).toEqual(BUTTON_CONFIGS.partial)
|
||||
})
|
||||
|
||||
it("foreground command flag only affects the streaming phase", () => {
|
||||
const messages: ClineMessage[] = []
|
||||
expect(getButtonConfigFromState(messages, { phase: "completed", seq: 5 }, "act", true)).toEqual(
|
||||
BUTTON_CONFIGS.completion_result,
|
||||
)
|
||||
expect(getButtonConfigFromState(messages, { phase: "idle", seq: 6 }, "act", true)).toEqual(BUTTON_CONFIGS.default)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -8,6 +8,7 @@ export type ButtonActionType =
|
||||
| "approve" // Send yesButtonClicked
|
||||
| "reject" // Send noButtonClicked
|
||||
| "proceed" // Send messageResponse or yesButtonClicked
|
||||
| "proceed_while_running" // Detach the running foreground terminal command
|
||||
| "new_task" // Start a new task
|
||||
| "cancel" // Cancel streaming
|
||||
| "utility" // Execute utility function (condense, report_bug)
|
||||
@@ -188,6 +189,17 @@ export const BUTTON_CONFIGS: Record<string, ButtonConfig> = {
|
||||
secondaryAction: "cancel",
|
||||
},
|
||||
|
||||
// A foreground terminal command is running (SDK path): the user can detach
|
||||
// it and let the agent proceed with the partial output, or cancel the task.
|
||||
foreground_command_running: {
|
||||
sendingDisabled: true,
|
||||
enableButtons: true,
|
||||
primaryText: "Proceed While Running",
|
||||
secondaryText: "Cancel",
|
||||
primaryAction: "proceed_while_running",
|
||||
secondaryAction: "cancel",
|
||||
},
|
||||
|
||||
// Default states
|
||||
default: {
|
||||
sendingDisabled: false,
|
||||
@@ -360,12 +372,19 @@ export function getButtonConfigForMessages(messages: ClineMessage[], mode: Mode
|
||||
* The button SET is chosen by phase; the LABEL/variant for approvals (Save vs Approve, command
|
||||
* vs tool vs MCP vs subagents) comes from the anchored message (turnState.anchorTs).
|
||||
*/
|
||||
export function buttonsForPhase(turnState: TurnState, anchoredMessage: ClineMessage | undefined): ButtonConfig {
|
||||
export function buttonsForPhase(
|
||||
turnState: TurnState,
|
||||
anchoredMessage: ClineMessage | undefined,
|
||||
foregroundCommandRunning = false,
|
||||
): ButtonConfig {
|
||||
switch (turnState.phase) {
|
||||
case "idle":
|
||||
return BUTTON_CONFIGS.default
|
||||
case "streaming":
|
||||
return BUTTON_CONFIGS.partial
|
||||
// A running foreground terminal command offers "Proceed While Running":
|
||||
// detach the command (output continues to a log file) and let the
|
||||
// agent continue with the partial output.
|
||||
return foregroundCommandRunning ? BUTTON_CONFIGS.foreground_command_running : BUTTON_CONFIGS.partial
|
||||
case "completed":
|
||||
return BUTTON_CONFIGS.completion_result
|
||||
case "resumable":
|
||||
@@ -398,10 +417,11 @@ export function getButtonConfigFromState(
|
||||
messages: ClineMessage[],
|
||||
turnState: TurnState | undefined,
|
||||
mode: Mode = "act",
|
||||
foregroundCommandRunning = false,
|
||||
): ButtonConfig {
|
||||
if (turnState) {
|
||||
const anchored = turnState.anchorTs !== undefined ? messages.find((m) => m.ts === turnState.anchorTs) : undefined
|
||||
return buttonsForPhase(turnState, anchored)
|
||||
return buttonsForPhase(turnState, anchored, foregroundCommandRunning)
|
||||
}
|
||||
return getButtonConfigForMessages(messages, mode)
|
||||
}
|
||||
|
||||
@@ -471,12 +471,10 @@ const OnboardingViewContent = ({ onboardingModels }: { onboardingModels: Onboard
|
||||
commitSelection("plan", {
|
||||
providerId: "cline",
|
||||
modelId: selectedModelId,
|
||||
modelInfo: selectedModelInfo,
|
||||
}),
|
||||
commitSelection("act", {
|
||||
providerId: "cline",
|
||||
modelId: selectedModelId,
|
||||
modelInfo: selectedModelInfo,
|
||||
}),
|
||||
])
|
||||
|
||||
|
||||
@@ -510,7 +510,7 @@ const ApiOptions = ({
|
||||
<AIhubmixProvider currentMode={currentMode} isPopup={isPopup} showModelOptions={showModelOptions} />
|
||||
)}
|
||||
|
||||
{apiConfiguration && (selectedProvider.includes("openai") || isCustomProvider) && (
|
||||
{apiConfiguration && (selectedProvider === "openai" || isCustomProvider) && (
|
||||
<OpenAICompatibleProvider
|
||||
currentMode={currentMode}
|
||||
isPopup={isPopup}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user