mirror of
https://github.com/cline/cline.git
synced 2026-09-14 02:29:17 +08:00
Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fdcc5367dc | ||
|
|
901fdbc5cb | ||
|
|
69e3149f5f | ||
|
|
3a3d0c1bc3 | ||
|
|
0746ea72bf | ||
|
|
f0d5ede555 | ||
|
|
ed821a6456 |
@@ -107,15 +107,17 @@ gh api repos/cline/cline/actions/runs/<run-id>/pending_deployments
|
||||
|
||||
### Post-publish
|
||||
|
||||
1. Verify the marketplace serves the new version (query from rule 1).
|
||||
2. This workflow does **not** tag or create a GitHub release — do it manually:
|
||||
1. Verify the marketplace serves the new version (query from rule 1) — expect minutes-to-an-hour of validation lag after "Published" appears in the logs.
|
||||
2. Tag, GitHub Release (with the .vsix attached), and the Slack release-bot post happen **automatically** after a real publish (all `continue-on-error` — the publish itself already succeeded, so bookkeeping failures leave the run green). Verify they landed; the known failure is the tag push when the built commit touches `.github/workflows/**` (default token cannot create such refs — no grantable permission fixes it). Manual fallback:
|
||||
|
||||
```bash
|
||||
git tag v<VERSION> <main-sha-built> # ask before pushing
|
||||
git push origin v<VERSION>
|
||||
gh release create v<VERSION> --title "v<VERSION>" --notes "<curated notes from CHANGELOG>"
|
||||
gh release create v<VERSION> --title "v<VERSION>" --notes "<changelog section>" <path-to.vsix>
|
||||
```
|
||||
|
||||
A real publish also **hard-fails early** if root `CHANGELOG.md` on the built main revision doesn't start with `## [<VERSION>]` — the release prep PR must be merged before dispatching.
|
||||
|
||||
3. Thorough artifact check (`gh run download <run-id>`): union `package.json` is `saoudrizwan.claude-dev@<VERSION>`, `next/package.json` and `legacy/package.json` carry the SAME version, `grep -c 'phc_' extension/extension.js` ≥ 1 (loader key inlined), no leftover `process.env.TELEMETRY_SERVICE_API_KEY` / `process.env.CLINE_ROLLOUT_VARIANT` literals in either bundle's dist (leftovers = a build ran without its env and telemetry is silently dead).
|
||||
4. Monitor: `extension.rollout.bundle_activated` in `otel.otel_logs` filtered to `extension_version = '<VERSION>'` (stable cohort is cleanly separable — nightly versions are timestamps). Watch the next/legacy ratio and the crash-fallback rate; Metabase dashboards 17 (rollout + task error rate) and 19 (error deep dive). `extension.rollout.loader_decision` (incl. `double_failure`) is PostHog-only, not in ClickHouse.
|
||||
5. Dial the flag per the rollout plan (e.g. 0% at publish → 1% → up), verifying each change with the probe from rule 2. Announce demotions ahead of time — dialing down also demotes nightly dogfooders unless they set `"cline-nightly.rollout.bundleOverride": "next"`.
|
||||
|
||||
@@ -126,6 +126,10 @@ jobs:
|
||||
needs: [test-next, test-legacy]
|
||||
runs-on: ubuntu-latest
|
||||
environment: publish
|
||||
# contents: write is required by the post-publish bookkeeping (tag +
|
||||
# GitHub Release), mirroring the standalone publish workflows.
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
# Refuse to publish a next bundle the test-next gate did not cover.
|
||||
# The reusable suite tests the dispatch revision (main), so publishing
|
||||
@@ -148,6 +152,21 @@ jobs:
|
||||
path: next-src
|
||||
lfs: true
|
||||
|
||||
# Fail fast (before the ~20-min build) if a real publish is missing
|
||||
# its changelog entry — same contract the standalone publish
|
||||
# workflows enforce. Build-only rehearsals are exempt.
|
||||
- name: Verify changelog entry
|
||||
if: ${{ github.event.inputs.publish == 'true' }}
|
||||
working-directory: next-src
|
||||
run: |
|
||||
EXPECTED_HEADING="## [${{ github.event.inputs.version }}]"
|
||||
FIRST_HEADING=$(grep -m 1 '^## \[' CHANGELOG.md || true)
|
||||
if [[ "$FIRST_HEADING" != "$EXPECTED_HEADING" ]]; then
|
||||
echo "Error: CHANGELOG.md must start with '$EXPECTED_HEADING' before publishing (found '$FIRST_HEADING')."
|
||||
exit 1
|
||||
fi
|
||||
echo "Found changelog entry for ${{ github.event.inputs.version }}"
|
||||
|
||||
# Pin to the revision test-legacy actually tested (see that job's
|
||||
# outputs comment) — never re-resolve the mutable branch name here.
|
||||
- name: Checkout legacy source
|
||||
@@ -304,3 +323,88 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
vsce publish --no-dependencies --packagePath "claude-dev-${{ github.event.inputs.version }}.vsix"
|
||||
|
||||
# ---- Post-publish bookkeeping (tag / GitHub Release / Slack) ----
|
||||
# Mirrors the standalone publish workflows. Every step here is
|
||||
# continue-on-error: the Marketplace publish above already happened,
|
||||
# and a red run after a successful publish is exactly the confusion
|
||||
# the nightly workflow taught us to avoid (tag pushes fail whenever
|
||||
# the built commit touches .github/workflows/** — no grantable
|
||||
# permission fixes that; push the tag manually in that case, see
|
||||
# the publish-extension skill).
|
||||
|
||||
- name: Extract changelog entry
|
||||
id: changelog
|
||||
if: ${{ github.event.inputs.publish == 'true' }}
|
||||
continue-on-error: true
|
||||
working-directory: next-src
|
||||
run: |
|
||||
CONTENT=$(awk '/^## \[/{if(found) exit; found=1; next} found{print}' CHANGELOG.md)
|
||||
{
|
||||
echo "content<<CHANGELOG_EOF"
|
||||
echo "$CONTENT"
|
||||
echo "CHANGELOG_EOF"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Resolve previous release tag
|
||||
id: prev_tag
|
||||
if: ${{ github.event.inputs.publish == 'true' }}
|
||||
continue-on-error: true
|
||||
working-directory: next-src
|
||||
run: |
|
||||
# ls-remote needs no local tag objects; take the highest v* tag
|
||||
# below the one being released.
|
||||
PREV=$(git ls-remote --tags origin 'v*' \
|
||||
| awk -F/ '{print $NF}' | grep -v '\^{}' \
|
||||
| grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' \
|
||||
| grep -vx "v${{ github.event.inputs.version }}" \
|
||||
| sort -V | tail -1)
|
||||
echo "prev_tag=$PREV" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Create and push release tag
|
||||
if: ${{ github.event.inputs.publish == 'true' }}
|
||||
continue-on-error: true
|
||||
working-directory: next-src
|
||||
run: |
|
||||
TAG="v${{ github.event.inputs.version }}"
|
||||
git tag "$TAG" HEAD
|
||||
git push origin "refs/tags/$TAG"
|
||||
echo "Pushed $TAG at $(git rev-parse HEAD)"
|
||||
|
||||
- name: Create GitHub Release
|
||||
if: ${{ github.event.inputs.publish == 'true' }}
|
||||
continue-on-error: true
|
||||
uses: softprops/action-gh-release@v1
|
||||
with:
|
||||
tag_name: v${{ github.event.inputs.version }}
|
||||
files: staging/claude-dev-${{ github.event.inputs.version }}.vsix
|
||||
body: |
|
||||
${{ steps.changelog.outputs.content }}
|
||||
|
||||
**Full Changelog**: https://github.com/${{ github.repository }}/compare/${{ steps.prev_tag.outputs.prev_tag }}...v${{ github.event.inputs.version }}
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Post release to Slack
|
||||
if: ${{ github.event.inputs.publish == 'true' }}
|
||||
continue-on-error: true
|
||||
uses: slackapi/slack-github-action@v3.0.1
|
||||
with:
|
||||
method: chat.postMessage
|
||||
token: ${{ secrets.SLACK_RELEASE_BOT_TOKEN }}
|
||||
payload: |
|
||||
channel: "C0APVKGGZFC"
|
||||
text: "Cline v${{ github.event.inputs.version }}"
|
||||
blocks:
|
||||
- type: "section"
|
||||
text:
|
||||
type: "mrkdwn"
|
||||
text: "*Cline v${{ github.event.inputs.version }}*"
|
||||
- type: "section"
|
||||
text:
|
||||
type: "mrkdwn"
|
||||
text: ${{ toJSON(steps.changelog.outputs.content) }}
|
||||
- type: "context"
|
||||
elements:
|
||||
- type: "mrkdwn"
|
||||
text: "Full Changelog: https://github.com/${{ github.repository }}/compare/${{ steps.prev_tag.outputs.prev_tag }}...v${{ github.event.inputs.version }}"
|
||||
|
||||
+122
@@ -1,5 +1,127 @@
|
||||
# Changelog
|
||||
|
||||
## [4.1.1]
|
||||
|
||||
### Changed
|
||||
|
||||
- Remove vestigial MCP server-key machinery from McpHub — native MCP tool calls now route by server name instead of a random in-memory uid, so routing survives restarts and server list changes.
|
||||
|
||||
## [4.1.0]
|
||||
|
||||
### Changed
|
||||
|
||||
- Convert the stable extension to a combined A/B package: one VSIX containing both the current (legacy) extension and the new SDK-based extension, plus a loader that activates exactly one per window via a staged remote rollout. For nearly all users nothing changes — the loader activates the same extension as 4.0.12; a small percentage (starting at 1%) is gradually opted into the SDK-based extension. If the new extension fails to activate, the loader falls back to the current one in the same window. Settings and credentials are shared between the two.
|
||||
|
||||
## [4.0.12]
|
||||
|
||||
### Added
|
||||
|
||||
- Add support for free Cline models, shown as "(free)" in the model picker, with a dedicated error card that includes the reset time when the free limit is reached.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Keep Claude Code responses that were already streamed when the CLI exits with a max-turns error, instead of discarding a valid response.
|
||||
|
||||
## [4.0.11]
|
||||
|
||||
### Added
|
||||
|
||||
- Add Claude Opus 5 across the Anthropic, Claude Code, Bedrock, Vertex, Cline, and OpenRouter providers, including 1M context window variants.
|
||||
- Add Moonshot Kimi K3 support.
|
||||
- Include the host plugin version in telemetry events.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Correct pricing for the Claude Opus 1M context variants, which overstated costs for requests above 200k tokens.
|
||||
- Enable native tool calling for Kimi K3 models, fixing empty responses.
|
||||
|
||||
## [4.0.10]
|
||||
|
||||
### Added
|
||||
|
||||
- Add telemetry to track when Cline reaches the consecutive mistake limit.
|
||||
|
||||
## [4.0.9]
|
||||
|
||||
### Added
|
||||
|
||||
- Add GPT-5.6 ChatGPT subscription models.
|
||||
|
||||
### Changed
|
||||
|
||||
- Soften and shorten the message shown when Cline hits the consecutive mistake limit.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Handle cumulative usage snapshots from OpenAI-compatible providers so token counts are no longer over-reported.
|
||||
- Load skills from files saved as UTF-8 with a byte-order mark (BOM).
|
||||
|
||||
## [4.0.8]
|
||||
|
||||
### Added
|
||||
|
||||
- Add more models to the GCP Vertex provider, plus a free-form entry option in the model dropdown for specifying custom Vertex models.
|
||||
|
||||
## [4.0.7]
|
||||
|
||||
### Added
|
||||
|
||||
- Add a ClinePass limit-reached error with a one-click option to switch to Cline usage-based billing.
|
||||
- Allow selecting Cline free models on the ClinePass provider, organized into Subscribed and Free tabs with model descriptions.
|
||||
|
||||
### Changed
|
||||
|
||||
- Refine ClinePass onboarding and provider settings copy, and open the "learn more" link via the in-app URL handler.
|
||||
- Remove the Cline model picker recommendation copy.
|
||||
|
||||
### Removed
|
||||
|
||||
- Remove all references to GLM 5.1.
|
||||
|
||||
## [4.0.6]
|
||||
|
||||
### Fixed
|
||||
|
||||
- Generalize the model capability warning so it applies more broadly.
|
||||
|
||||
## [4.0.5]
|
||||
|
||||
### Added
|
||||
|
||||
- Add support for Claude Sonnet 5 across the Anthropic, Bedrock, Vertex, Claude Code, SAP AI Core, OpenRouter, and Vercel AI Gateway providers, including model picker and recommended-model updates.
|
||||
|
||||
## [4.0.4]
|
||||
|
||||
### Changed
|
||||
|
||||
- Fully remove the ClinePass feature flag so ClinePass is available everywhere in the UI — onboarding, settings, the welcome promo banner, and the credit-limit "Switch to ClinePass" action.
|
||||
|
||||
## [4.0.3]
|
||||
|
||||
### Changed
|
||||
|
||||
- Enable the ClinePass provider for all users by removing the feature-flag gate that previously fell back to the standard Cline provider.
|
||||
|
||||
## [4.0.2]
|
||||
|
||||
### Added
|
||||
|
||||
- Add reasoning effort support (including `xhigh`) for DeepSeek thinking models.
|
||||
- Improve the ClinePass provider experience with clearer reasoning controls and model selection.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Show reasoning effort controls for ClinePass models and align ClinePass model resolution with the rest of the provider.
|
||||
- Prefer canonical Cline Z.ai model ids and polish ClinePass and Z.ai model metadata.
|
||||
- Fix environment variable replacement in the webview.
|
||||
- Default focus chain settings in webview state so the toggle reflects the correct value on load.
|
||||
|
||||
## [4.0.1]
|
||||
|
||||
### Changed
|
||||
|
||||
- Roll the stable VS Code extension back to the pre-SDK-migration codebase to resolve regressions reported in 4.0.0. This release ships the 3.89.2 extension code under a higher version number so existing 4.0.0 users receive the update. SDK-migration work continues separately on `main`.
|
||||
|
||||
## [4.0.0]
|
||||
|
||||
### Added
|
||||
|
||||
@@ -1,5 +1,22 @@
|
||||
# Cline CLI Changelog
|
||||
|
||||
## 3.0.48
|
||||
|
||||
- `cline history` now opens inside the existing TUI, with resume and delete actions, instead of rendering a second view in the same process
|
||||
- Connector threads (Slack, Discord, Telegram, Linear, Google Chat, WhatsApp) now recover when the session they were bound to is gone — the stale binding is dropped and the turn replays against a new session, instead of failing with "session not found" until `threads.json` is edited by hand
|
||||
- `cline --help` now reports the real default `--config` and `--data-dir` paths
|
||||
- The per-server `timeout` in `cline_mcp_settings.json` is now honored for `initialize`, `tools/list`, and `tools/call`, so slow MCP servers no longer fail against a hardcoded 5s limit (from SDK v0.0.67)
|
||||
- Reasoning controls are now routed from the models.dev catalog across providers, with clamped budgets and correct per-provider encoding (from SDK v0.0.67)
|
||||
- OpenRouter now defaults to `anthropic/claude-sonnet-5` (from SDK v0.0.67)
|
||||
- Fixed the China and international endpoint toggles being ignored for Qwen, Moonshot, and Z AI (from SDK v0.0.67)
|
||||
- Legacy API keys are now migrated for every secret-backed provider (from SDK v0.0.67)
|
||||
- Legacy OpenAI Compatible model-info overrides now survive into the seeded `models.json` (from SDK v0.0.67)
|
||||
- Fixed auto-compaction state being rejected as stale, which added a redundant summarizer call on every turn past the compaction trigger (from SDK v0.0.67)
|
||||
- Fixed checkpoint restores across session resumes (from SDK v0.0.67)
|
||||
- Tool calls that pass line numbers as strings (`insert_line`, `read_files` bounds) are now accepted instead of erroring (from SDK v0.0.67)
|
||||
- A legacy single-file `.clinerules` no longer aborts the config scan (from SDK v0.0.67)
|
||||
- Plugins can now emit telemetry through `ctx.telemetry` (from SDK v0.0.67)
|
||||
|
||||
## 3.0.47
|
||||
|
||||
- Free Cline models are now supported end to end: free models show as "(free)", and hitting the free limit renders a dedicated card with the reset time (from SDK v0.0.66)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@cline/cli",
|
||||
"displayName": "cline",
|
||||
"version": "3.0.47",
|
||||
"version": "3.0.48",
|
||||
"description": "Autonomous coding agent CLI - capable of creating/editing files, running commands, using the browser, and more",
|
||||
"type": "module",
|
||||
"publishConfig": {
|
||||
|
||||
@@ -28,11 +28,15 @@ import {
|
||||
ProviderSettingsManager,
|
||||
SessionSource,
|
||||
} from "@cline/core";
|
||||
import type { Message } from "@cline/shared";
|
||||
import { isLikelyAuthError, type Message } from "@cline/shared";
|
||||
import { getPersistedProviderApiKey } from "../commands/auth";
|
||||
import { resolveSystemPrompt } from "../runtime/prompt";
|
||||
import { subscribeToAgentEvents } from "../runtime/session-events";
|
||||
import { createCliCore } from "../session/session";
|
||||
import {
|
||||
isClineOrgIndividualInferenceSubscriptionErrorMessage,
|
||||
isClinePassSubscriptionError,
|
||||
} from "../utils/cline-pass-errors";
|
||||
import { getCliBuildInfo } from "../utils/common";
|
||||
import { randomSessionId, resolveWorkspaceRoot } from "../utils/helpers";
|
||||
import type { Config } from "../utils/types";
|
||||
@@ -45,6 +49,7 @@ import {
|
||||
} from "./auth";
|
||||
import { requestAcpToolApproval } from "./permissions";
|
||||
import {
|
||||
describeAgentError,
|
||||
forwardAgentEvent,
|
||||
sendConfigOptionUpdate,
|
||||
sendCurrentModeUpdate,
|
||||
@@ -69,6 +74,15 @@ interface SessionState {
|
||||
abortController?: AbortController;
|
||||
/** Unsubscribe function for the agent event listener. */
|
||||
unsubscribe?: () => void;
|
||||
/**
|
||||
* Most recent unrecoverable agent error for the in-flight turn.
|
||||
*
|
||||
* The runtime reports fatal failures (bad credentials, subscription
|
||||
* restrictions, provider outages) as an `error` event and still resolves
|
||||
* `send()` normally, so the message has to be stashed here for `prompt()` to
|
||||
* turn into an error response.
|
||||
*/
|
||||
fatalError?: Error;
|
||||
/** Messages to inject into the next session manager for conversation continuity. */
|
||||
pendingInitialMessages?: Message[];
|
||||
}
|
||||
@@ -193,6 +207,7 @@ export class AcpAgent implements Agent {
|
||||
|
||||
const abortController = new AbortController();
|
||||
session.abortController = abortController;
|
||||
session.fatalError = undefined;
|
||||
|
||||
// If cancel() was already called before prompt() started, bail early.
|
||||
if (abortController.signal.aborted) {
|
||||
@@ -242,6 +257,17 @@ export class AcpAgent implements Agent {
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
// A cancelled turn always reports `cancelled`: the ACP spec
|
||||
// requires agents to convert abort failures into the cancelled stop reason
|
||||
// so clients don't show cancellations as errors.
|
||||
if (stopReason !== "cancelled") {
|
||||
const fatalError = session.fatalError;
|
||||
session.fatalError = undefined;
|
||||
if (fatalError) {
|
||||
throw toAcpPromptError(fatalError);
|
||||
}
|
||||
}
|
||||
|
||||
return { stopReason };
|
||||
}
|
||||
|
||||
@@ -491,6 +517,13 @@ export class AcpAgent implements Agent {
|
||||
session.unsubscribe = subscribeToAgentEvents(
|
||||
sessionManager,
|
||||
(event: AgentEvent) => {
|
||||
// Remember unrecoverable failures so prompt() can fail the turn.
|
||||
if (event.type === "error" && !event.recoverable) {
|
||||
session.fatalError =
|
||||
event.error instanceof Error
|
||||
? event.error
|
||||
: new Error(describeAgentError(event.error));
|
||||
}
|
||||
forwardAgentEvent(this.conn, acpSessionId, event);
|
||||
},
|
||||
);
|
||||
@@ -560,6 +593,29 @@ export class AcpAgent implements Agent {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a fatal agent error into a JSON-RPC error for the prompt response.
|
||||
*
|
||||
* Credential/subscription problems map to `auth_required` (-32000) so clients
|
||||
* can offer a re-auth affordance rather than just printing text; everything
|
||||
* else is an internal error.
|
||||
*
|
||||
* Classification goes through the shared CLI helpers, which check the error's
|
||||
* type *and* its name/message. That matters because the runtime re-wraps errors
|
||||
* as it forwards them across the event boundary, so `instanceof` alone fails on
|
||||
* the object ACP actually receives.
|
||||
*/
|
||||
function toAcpPromptError(error: Error): RequestError {
|
||||
const message = describeAgentError(error);
|
||||
const isAuthProblem =
|
||||
isLikelyAuthError(error) ||
|
||||
isClinePassSubscriptionError(error) ||
|
||||
isClineOrgIndividualInferenceSubscriptionErrorMessage(error);
|
||||
return isAuthProblem
|
||||
? RequestError.authRequired({ message }, message)
|
||||
: RequestError.internalError({ message }, message);
|
||||
}
|
||||
|
||||
async function buildProviderConfigOption(
|
||||
currentProviderId: string,
|
||||
): Promise<SessionConfigOption> {
|
||||
|
||||
@@ -4,6 +4,7 @@ import type {
|
||||
SessionUpdate,
|
||||
} from "@agentclientprotocol/sdk";
|
||||
import type { AgentEvent } from "@cline/core";
|
||||
import { getErrorMessage } from "@cline/shared";
|
||||
import { buildToolTitle, mapToolKind } from "./tool-utils";
|
||||
|
||||
/**
|
||||
@@ -81,6 +82,11 @@ function translateContentStart(
|
||||
}
|
||||
}
|
||||
|
||||
export function describeAgentError(error: unknown): string {
|
||||
const message = getErrorMessage(error).trim();
|
||||
return message || "The agent reported an unknown error.";
|
||||
}
|
||||
|
||||
function translateContentEnd(
|
||||
event: AgentEvent & { type: "content_end" },
|
||||
): SessionUpdate[] {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "claude-dev",
|
||||
"displayName": "Cline",
|
||||
"description": "Autonomous coding agent right in your IDE, capable of creating/editing files, running commands, using the browser, and more with your permission every step of the way.",
|
||||
"version": "4.0.0",
|
||||
"version": "4.1.1",
|
||||
"icon": "assets/icons/icon.png",
|
||||
"engines": {
|
||||
"vscode": "^1.101.0"
|
||||
|
||||
@@ -162,7 +162,7 @@ describe("BannerService", () => {
|
||||
bannerService.getActiveBanners() // Triggers background fetch
|
||||
|
||||
// Wait for background fetch to complete
|
||||
await new Promise((resolve) => setTimeout(resolve, 10))
|
||||
await bannerService.drainForTesting()
|
||||
|
||||
expect(mockFetch.calledOnce).to.be.true
|
||||
const banners = bannerService.getActiveBanners() // Get banners after fetch completes
|
||||
@@ -181,7 +181,7 @@ describe("BannerService", () => {
|
||||
const banners = bannerService.getActiveBanners()
|
||||
|
||||
// Wait for background fetch to complete
|
||||
await new Promise((resolve) => setTimeout(resolve, 10))
|
||||
await bannerService.drainForTesting()
|
||||
|
||||
expect(banners).to.have.lengthOf(0)
|
||||
})
|
||||
@@ -318,7 +318,7 @@ describe("BannerService", () => {
|
||||
bannerService.getActiveBanners()
|
||||
|
||||
// Wait for background fetch to complete
|
||||
await new Promise((resolve) => setTimeout(resolve, 10))
|
||||
await bannerService.drainForTesting()
|
||||
|
||||
const banners = bannerService.getActiveBanners()
|
||||
expect(banners).to.have.lengthOf(1)
|
||||
@@ -352,7 +352,7 @@ describe("BannerService", () => {
|
||||
bannerService.getActiveBanners()
|
||||
|
||||
// Wait for background fetch to complete
|
||||
await new Promise((resolve) => setTimeout(resolve, 10))
|
||||
await bannerService.drainForTesting()
|
||||
|
||||
const banners = bannerService.getActiveBanners()
|
||||
expect(banners).to.have.lengthOf(1)
|
||||
@@ -386,7 +386,7 @@ describe("BannerService", () => {
|
||||
bannerService.getActiveBanners()
|
||||
|
||||
// Wait for background fetch to complete
|
||||
await new Promise((resolve) => setTimeout(resolve, 10))
|
||||
await bannerService.drainForTesting()
|
||||
|
||||
const banners = bannerService.getActiveBanners()
|
||||
expect(banners).to.have.lengthOf(0)
|
||||
@@ -419,7 +419,7 @@ describe("BannerService", () => {
|
||||
bannerService.getActiveBanners()
|
||||
|
||||
// Wait for background fetch to complete
|
||||
await new Promise((resolve) => setTimeout(resolve, 10))
|
||||
await bannerService.drainForTesting()
|
||||
|
||||
const banners = bannerService.getActiveBanners()
|
||||
expect(banners).to.have.lengthOf(1)
|
||||
@@ -453,7 +453,7 @@ describe("BannerService", () => {
|
||||
bannerService.getActiveBanners()
|
||||
|
||||
// Wait for background fetch to complete
|
||||
await new Promise((resolve) => setTimeout(resolve, 10))
|
||||
await bannerService.drainForTesting()
|
||||
|
||||
const banners = bannerService.getActiveBanners()
|
||||
expect(banners).to.have.lengthOf(0)
|
||||
@@ -485,7 +485,7 @@ describe("BannerService", () => {
|
||||
bannerService.getActiveBanners()
|
||||
|
||||
// Wait for background fetch to complete
|
||||
await new Promise((resolve) => setTimeout(resolve, 10))
|
||||
await bannerService.drainForTesting()
|
||||
|
||||
const banners = bannerService.getActiveBanners()
|
||||
expect(banners).to.have.lengthOf(1)
|
||||
@@ -515,7 +515,7 @@ describe("BannerService", () => {
|
||||
bannerService.getActiveBanners()
|
||||
|
||||
// Wait for background fetch to complete
|
||||
await new Promise((resolve) => setTimeout(resolve, 10))
|
||||
await bannerService.drainForTesting()
|
||||
|
||||
const banners = bannerService.getActiveBanners()
|
||||
expect(banners).to.have.lengthOf(1)
|
||||
@@ -548,14 +548,14 @@ describe("BannerService", () => {
|
||||
bannerService.getActiveBanners()
|
||||
|
||||
// Wait for background fetch to complete
|
||||
await new Promise((resolve) => setTimeout(resolve, 10))
|
||||
await bannerService.drainForTesting()
|
||||
expect(mockFetch.calledOnce).to.be.true
|
||||
|
||||
bannerService.clearCache()
|
||||
|
||||
bannerService.getActiveBanners()
|
||||
// Wait for background fetch to complete
|
||||
await new Promise((resolve) => setTimeout(resolve, 10))
|
||||
await bannerService.drainForTesting()
|
||||
expect(mockFetch.calledTwice).to.be.true
|
||||
})
|
||||
})
|
||||
@@ -585,7 +585,7 @@ describe("BannerService", () => {
|
||||
bannerService.getActiveBanners()
|
||||
|
||||
// Wait for background fetch to complete
|
||||
await new Promise((resolve) => setTimeout(resolve, 10))
|
||||
await bannerService.drainForTesting()
|
||||
|
||||
expect(mockFetch.calledOnce).to.be.true
|
||||
const call = mockFetch.getCall(0)
|
||||
@@ -624,7 +624,7 @@ describe("BannerService", () => {
|
||||
bannerService.getActiveBanners()
|
||||
|
||||
// Wait for background fetch to complete
|
||||
await new Promise((resolve) => setTimeout(resolve, 10))
|
||||
await bannerService.drainForTesting()
|
||||
|
||||
const banners = bannerService.getActiveBanners()
|
||||
expect(banners).to.have.lengthOf(1)
|
||||
@@ -665,7 +665,7 @@ describe("BannerService", () => {
|
||||
bannerService.getActiveBanners()
|
||||
|
||||
// Wait for background fetch to complete
|
||||
await new Promise((resolve) => setTimeout(resolve, 10))
|
||||
await bannerService.drainForTesting()
|
||||
|
||||
const banners = bannerService.getActiveBanners()
|
||||
expect(banners).to.have.lengthOf(0)
|
||||
@@ -713,7 +713,7 @@ describe("BannerService", () => {
|
||||
bannerService.getActiveBanners()
|
||||
|
||||
// Wait for background fetch to complete
|
||||
await new Promise((resolve) => setTimeout(resolve, 10))
|
||||
await bannerService.drainForTesting()
|
||||
|
||||
const banners = bannerService.getActiveBanners()
|
||||
expect(banners).to.have.lengthOf(2)
|
||||
@@ -745,7 +745,7 @@ describe("BannerService", () => {
|
||||
bannerService.getActiveBanners()
|
||||
|
||||
// Wait for background fetch to complete
|
||||
await new Promise((resolve) => setTimeout(resolve, 10))
|
||||
await bannerService.drainForTesting()
|
||||
|
||||
const banners = bannerService.getActiveBanners()
|
||||
expect(banners).to.have.lengthOf(1)
|
||||
@@ -778,7 +778,7 @@ describe("BannerService", () => {
|
||||
bannerService.getActiveBanners()
|
||||
|
||||
// Wait for background fetch to complete
|
||||
await new Promise((resolve) => setTimeout(resolve, 10))
|
||||
await bannerService.drainForTesting()
|
||||
|
||||
const banners = bannerService.getActiveBanners()
|
||||
expect(banners).to.have.lengthOf(1)
|
||||
@@ -811,7 +811,7 @@ describe("BannerService", () => {
|
||||
bannerService.getActiveBanners()
|
||||
|
||||
// Wait for background fetch to complete
|
||||
await new Promise((resolve) => setTimeout(resolve, 10))
|
||||
await bannerService.drainForTesting()
|
||||
|
||||
const banners = bannerService.getActiveBanners()
|
||||
expect(banners).to.have.lengthOf(0)
|
||||
@@ -854,7 +854,7 @@ describe("BannerService", () => {
|
||||
bannerService.getActiveBanners()
|
||||
|
||||
// Wait for background fetch to complete
|
||||
await new Promise((resolve) => setTimeout(resolve, 10))
|
||||
await bannerService.drainForTesting()
|
||||
|
||||
const banners = bannerService.getActiveBanners()
|
||||
expect(mockedPostStateToWebview.called).to.be.true
|
||||
@@ -892,7 +892,7 @@ describe("BannerService", () => {
|
||||
await mockFetchForTesting(mockFetch, async () => {
|
||||
const bannerService = BannerService.initialize(mockController)
|
||||
bannerService.getActiveBanners()
|
||||
await new Promise((resolve) => setTimeout(resolve, 10))
|
||||
await bannerService.drainForTesting()
|
||||
|
||||
expect(await getIdeParam(mockFetch)).to.equal("vscode")
|
||||
})
|
||||
@@ -905,7 +905,7 @@ describe("BannerService", () => {
|
||||
await mockFetchForTesting(mockFetch, async () => {
|
||||
const bannerService = BannerService.initialize(mockController)
|
||||
bannerService.getActiveBanners()
|
||||
await new Promise((resolve) => setTimeout(resolve, 10))
|
||||
await bannerService.drainForTesting()
|
||||
|
||||
expect(await getIdeParam(mockFetch)).to.equal("vscode")
|
||||
})
|
||||
@@ -918,7 +918,7 @@ describe("BannerService", () => {
|
||||
await mockFetchForTesting(mockFetch, async () => {
|
||||
const bannerService = BannerService.initialize(mockController)
|
||||
bannerService.getActiveBanners()
|
||||
await new Promise((resolve) => setTimeout(resolve, 10))
|
||||
await bannerService.drainForTesting()
|
||||
|
||||
expect(await getIdeParam(mockFetch)).to.equal("jetbrains")
|
||||
})
|
||||
@@ -931,7 +931,7 @@ describe("BannerService", () => {
|
||||
await mockFetchForTesting(mockFetch, async () => {
|
||||
const bannerService = BannerService.initialize(mockController)
|
||||
bannerService.getActiveBanners()
|
||||
await new Promise((resolve) => setTimeout(resolve, 10))
|
||||
await bannerService.drainForTesting()
|
||||
|
||||
expect(await getIdeParam(mockFetch)).to.equal("jetbrains")
|
||||
})
|
||||
@@ -944,7 +944,7 @@ describe("BannerService", () => {
|
||||
await mockFetchForTesting(mockFetch, async () => {
|
||||
const bannerService = BannerService.initialize(mockController)
|
||||
bannerService.getActiveBanners()
|
||||
await new Promise((resolve) => setTimeout(resolve, 10))
|
||||
await bannerService.drainForTesting()
|
||||
|
||||
expect(await getIdeParam(mockFetch)).to.equal("cli")
|
||||
})
|
||||
@@ -957,7 +957,7 @@ describe("BannerService", () => {
|
||||
await mockFetchForTesting(mockFetch, async () => {
|
||||
const bannerService = BannerService.initialize(mockController)
|
||||
bannerService.getActiveBanners()
|
||||
await new Promise((resolve) => setTimeout(resolve, 10))
|
||||
await bannerService.drainForTesting()
|
||||
|
||||
expect(await getIdeParam(mockFetch)).to.equal("vscode")
|
||||
})
|
||||
@@ -970,7 +970,7 @@ describe("BannerService", () => {
|
||||
await mockFetchForTesting(mockFetch, async () => {
|
||||
const bannerService = BannerService.initialize(mockController)
|
||||
bannerService.getActiveBanners()
|
||||
await new Promise((resolve) => setTimeout(resolve, 10))
|
||||
await bannerService.drainForTesting()
|
||||
|
||||
expect(await getIdeParam(mockFetch)).to.equal("vscode")
|
||||
})
|
||||
@@ -983,7 +983,7 @@ describe("BannerService", () => {
|
||||
await mockFetchForTesting(mockFetch, async () => {
|
||||
const bannerService = BannerService.initialize(mockController)
|
||||
bannerService.getActiveBanners()
|
||||
await new Promise((resolve) => setTimeout(resolve, 10))
|
||||
await bannerService.drainForTesting()
|
||||
|
||||
expect(await getIdeParam(mockFetch)).to.equal("unknown")
|
||||
})
|
||||
@@ -996,7 +996,7 @@ describe("BannerService", () => {
|
||||
await mockFetchForTesting(mockFetch, async () => {
|
||||
const bannerService = BannerService.initialize(mockController)
|
||||
bannerService.getActiveBanners()
|
||||
await new Promise((resolve) => setTimeout(resolve, 10))
|
||||
await bannerService.drainForTesting()
|
||||
|
||||
expect(await getIdeParam(mockFetch)).to.equal("unknown")
|
||||
})
|
||||
@@ -1009,7 +1009,7 @@ describe("BannerService", () => {
|
||||
await mockFetchForTesting(mockFetch, async () => {
|
||||
const bannerService = BannerService.initialize(mockController)
|
||||
bannerService.getActiveBanners()
|
||||
await new Promise((resolve) => setTimeout(resolve, 10))
|
||||
await bannerService.drainForTesting()
|
||||
|
||||
expect(await getIdeParam(mockFetch)).to.equal("jetbrains")
|
||||
})
|
||||
|
||||
@@ -28,7 +28,6 @@ import { convertMcpServersToProtoMcpServers } from "@shared/proto-conversions/mc
|
||||
import chokidar, { type FSWatcher } from "chokidar"
|
||||
import deepEqual from "fast-deep-equal"
|
||||
import * as fs from "fs/promises"
|
||||
import { nanoid } from "nanoid"
|
||||
import ReconnectingEventSource from "reconnecting-eventsource"
|
||||
import { z } from "zod"
|
||||
import { HostProvider } from "@/hosts/host-provider"
|
||||
@@ -94,11 +93,6 @@ export class McpHub {
|
||||
*/
|
||||
private lastConnectionFingerprint?: string
|
||||
|
||||
/**
|
||||
* Map of unique keys to each connected server names
|
||||
*/
|
||||
private static mcpServerKeys = new Map<string, string>()
|
||||
|
||||
// Store notifications for display in chat
|
||||
private pendingNotifications: Array<{
|
||||
serverName: string
|
||||
@@ -143,33 +137,6 @@ export class McpHub {
|
||||
return this.connections.filter((conn) => !conn.server.disabled).map((conn) => conn.server)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the MCP server name from its unique key.
|
||||
* If the key is not found, return the key itself.
|
||||
*/
|
||||
public static getMcpServerByKey(key: string): string {
|
||||
return McpHub.mcpServerKeys.get(key) || key
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a unique key for an MCP server based on its name.
|
||||
* This avoids making a tool name too long while still ensuring uniqueness.
|
||||
*/
|
||||
private getMcpServerKey(server: string): string {
|
||||
// Reuse existing key if server is already registered
|
||||
for (const [existingKey, existingServer] of McpHub.mcpServerKeys.entries()) {
|
||||
if (existingServer === server) {
|
||||
return existingKey
|
||||
}
|
||||
}
|
||||
// Generate a short 6-character unique ID for the server
|
||||
// Add c prefix to ensure it starts with a letter (for compatibility with Gemini)
|
||||
// Only use the first 5 characters of nanoid to keep it short
|
||||
const uid = "c" + nanoid(5)
|
||||
McpHub.mcpServerKeys.set(uid, server)
|
||||
return uid
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the path to the MCP settings file
|
||||
* @returns Path to the MCP settings file
|
||||
@@ -497,7 +464,6 @@ export class McpHub {
|
||||
const connection = this.findConnection(name, source)
|
||||
if (connection) {
|
||||
connection.server.status = "disconnected"
|
||||
McpHub.mcpServerKeys.delete(connection.server.uid || name)
|
||||
this.appendErrorMessage(connection, error instanceof Error ? error.message : `${error}`)
|
||||
}
|
||||
await this.notifyWebviewOfServerChanges()
|
||||
@@ -507,7 +473,6 @@ export class McpHub {
|
||||
const connection = this.findConnection(name, source)
|
||||
if (connection) {
|
||||
connection.server.status = "disconnected"
|
||||
McpHub.mcpServerKeys.delete(connection.server.uid || name)
|
||||
}
|
||||
await this.notifyWebviewOfServerChanges()
|
||||
}
|
||||
@@ -579,7 +544,6 @@ export class McpHub {
|
||||
const connection = this.findConnection(name, source)
|
||||
if (connection) {
|
||||
connection.server.status = "disconnected"
|
||||
McpHub.mcpServerKeys.delete(connection.server.uid || name)
|
||||
this.appendErrorMessage(connection, error instanceof Error ? error.message : `${error}`)
|
||||
}
|
||||
await this.notifyWebviewOfServerChanges()
|
||||
@@ -622,7 +586,6 @@ export class McpHub {
|
||||
connectToServer: () => this.connectToServer(name, config, source),
|
||||
notifyWebviewOfServerChanges: () => this.notifyWebviewOfServerChanges(),
|
||||
appendErrorMessage: (conn, msg) => this.appendErrorMessage(conn as McpConnection, msg),
|
||||
deleteServerKey: (uid) => McpHub.mcpServerKeys.delete(uid),
|
||||
delay: (ms) => setTimeoutPromise(ms),
|
||||
})
|
||||
|
||||
@@ -639,7 +602,6 @@ export class McpHub {
|
||||
config: configForStorage,
|
||||
status: "connecting",
|
||||
disabled: config.disabled,
|
||||
uid: this.getMcpServerKey(name),
|
||||
oauthRequired: false,
|
||||
oauthAuthStatus: "authenticated",
|
||||
},
|
||||
@@ -666,7 +628,6 @@ export class McpHub {
|
||||
oauthRequired: true,
|
||||
oauthAuthStatus: "unauthenticated",
|
||||
error: "This MCP server requires authentication to get started.",
|
||||
uid: this.getMcpServerKey(name),
|
||||
},
|
||||
client,
|
||||
transport,
|
||||
@@ -771,7 +732,6 @@ export class McpHub {
|
||||
config: JSON.stringify(config),
|
||||
status: "disconnected",
|
||||
disabled: config.disabled,
|
||||
uid: this.getMcpServerKey(name),
|
||||
},
|
||||
client: null as unknown as Client,
|
||||
transport: null as unknown as Transport,
|
||||
|
||||
@@ -6,7 +6,7 @@ import { Logger } from "@/shared/services/Logger"
|
||||
*/
|
||||
export interface ReconnectCallbacks {
|
||||
/** Returns the current connection object, or undefined if it no longer exists */
|
||||
findConnection: () => { server: { status: string; disabled?: boolean; uid?: string } } | undefined
|
||||
findConnection: () => { server: { status: string; disabled?: boolean } } | undefined
|
||||
/** Tears down the existing connection */
|
||||
deleteConnection: () => Promise<void>
|
||||
/** Establishes a new connection */
|
||||
@@ -15,8 +15,6 @@ export interface ReconnectCallbacks {
|
||||
notifyWebviewOfServerChanges: () => Promise<void>
|
||||
/** Appends an error message to the connection's server object */
|
||||
appendErrorMessage: (connection: { server: { status: string } }, message: string) => void
|
||||
/** Removes the server key from the global registry */
|
||||
deleteServerKey: (uid: string) => void
|
||||
/** Awaitable delay — injected so tests can substitute a zero-delay or fake timer */
|
||||
delay: (ms: number) => Promise<void>
|
||||
}
|
||||
@@ -94,7 +92,6 @@ export class StreamableHttpReconnectHandler {
|
||||
`exhausted for "${this.serverName}". Server marked as disconnected.`,
|
||||
)
|
||||
connection.server.status = "disconnected"
|
||||
this.callbacks.deleteServerKey(connection.server.uid || this.serverName)
|
||||
this.callbacks.appendErrorMessage(connection, error instanceof Error ? error.message : `${error}`)
|
||||
await this.callbacks.notifyWebviewOfServerChanges()
|
||||
return
|
||||
@@ -157,7 +154,6 @@ export class StreamableHttpReconnectHandler {
|
||||
const exhaustedConnection = this.callbacks.findConnection()
|
||||
if (exhaustedConnection) {
|
||||
exhaustedConnection.server.status = "disconnected"
|
||||
this.callbacks.deleteServerKey(exhaustedConnection.server.uid || this.serverName)
|
||||
this.callbacks.appendErrorMessage(exhaustedConnection, error instanceof Error ? error.message : `${error}`)
|
||||
}
|
||||
await this.callbacks.notifyWebviewOfServerChanges()
|
||||
|
||||
@@ -9,12 +9,11 @@ import {
|
||||
} from "../StreamableHttpReconnectHandler"
|
||||
|
||||
/** Build a mock connection object whose status can be inspected. */
|
||||
function makeConnection(overrides: Partial<{ status: string; disabled: boolean; uid: string }> = {}) {
|
||||
function makeConnection(overrides: Partial<{ status: string; disabled: boolean }> = {}) {
|
||||
return {
|
||||
server: {
|
||||
status: overrides.status ?? "connected",
|
||||
disabled: overrides.disabled ?? false,
|
||||
uid: overrides.uid ?? "uid-123",
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -31,7 +30,6 @@ function makeCallbacks(connection?: ReturnType<typeof makeConnection>): Reconnec
|
||||
connectToServer: sinon.stub().resolves(),
|
||||
notifyWebviewOfServerChanges: sinon.stub().resolves(),
|
||||
appendErrorMessage: sinon.stub(),
|
||||
deleteServerKey: sinon.stub(),
|
||||
delay: sinon.stub().resolves(), // instant — no real waiting in tests
|
||||
}
|
||||
return { ...(stubs as unknown as ReconnectCallbacks), stubs }
|
||||
@@ -148,7 +146,7 @@ describe("StreamableHttpReconnectHandler", () => {
|
||||
|
||||
// After deleteConnection, findConnection returns undefined (old conn deleted)
|
||||
// but connectToServer may leave a partial connection, so simulate that
|
||||
const partialConn = makeConnection({ uid: "uid-partial" })
|
||||
const partialConn = makeConnection()
|
||||
let deleted = false
|
||||
cbs.stubs.findConnection.callsFake(() => {
|
||||
if (!deleted) return conn
|
||||
@@ -170,7 +168,6 @@ describe("StreamableHttpReconnectHandler", () => {
|
||||
|
||||
// The partial connection should be marked disconnected
|
||||
partialConn.server.status.should.equal("disconnected")
|
||||
cbs.stubs.deleteServerKey.calledWith("uid-partial").should.be.true()
|
||||
cbs.stubs.appendErrorMessage.calledOnce.should.be.true()
|
||||
cbs.stubs.appendErrorMessage.firstCall.args[1].should.equal("transport error")
|
||||
})
|
||||
@@ -207,7 +204,6 @@ describe("StreamableHttpReconnectHandler", () => {
|
||||
await handler.handleError(new Error("final error"))
|
||||
|
||||
conn.server.status.should.equal("disconnected")
|
||||
cbs.stubs.deleteServerKey.calledWith("uid-123").should.be.true()
|
||||
cbs.stubs.appendErrorMessage.calledOnce.should.be.true()
|
||||
cbs.stubs.connectToServer.called.should.be.false()
|
||||
})
|
||||
@@ -240,7 +236,7 @@ describe("StreamableHttpReconnectHandler", () => {
|
||||
|
||||
it("should abort reconnect if connection was replaced during delay", async () => {
|
||||
const conn = makeConnection()
|
||||
const differentConn = makeConnection({ uid: "uid-replaced" })
|
||||
const differentConn = makeConnection()
|
||||
const cbs = makeCallbacks(conn)
|
||||
// After the delay, findConnection returns a different object
|
||||
cbs.stubs.findConnection.onFirstCall().returns(conn)
|
||||
|
||||
@@ -24,7 +24,6 @@ export type McpServer = {
|
||||
prompts?: McpPrompt[]
|
||||
disabled?: boolean
|
||||
timeout?: number
|
||||
uid?: string
|
||||
oauthRequired?: boolean
|
||||
oauthAuthStatus?: McpOAuthAuthStatus
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
},
|
||||
"apps/cli": {
|
||||
"name": "@cline/cli",
|
||||
"version": "3.0.47",
|
||||
"version": "3.0.48",
|
||||
"bin": {
|
||||
"cline": "src/index.ts",
|
||||
},
|
||||
|
||||
@@ -222,6 +222,7 @@ export {
|
||||
noopBasicLogger,
|
||||
} from "./logging/logger";
|
||||
export * from "./mcp";
|
||||
export { getErrorCode, getErrorMessage } from "./parse/error";
|
||||
export {
|
||||
normalizeJsonLikeStringsForSchema,
|
||||
parseJsonStream,
|
||||
|
||||
@@ -238,6 +238,7 @@ export {
|
||||
noopBasicLogger,
|
||||
} from "./logging/logger";
|
||||
export * from "./mcp";
|
||||
export { getErrorCode, getErrorMessage } from "./parse/error";
|
||||
export {
|
||||
normalizeJsonLikeStringsForSchema,
|
||||
parseJsonStream,
|
||||
|
||||
Reference in New Issue
Block a user