Merge remote-tracking branch 'origin/main' into harrison/simplify-pr-15095-dcf169

This commit is contained in:
Harrison
2026-10-15 14:16:55 -07:00
22 changed files with 399 additions and 446 deletions
@@ -21,7 +21,6 @@ sidecar/
├── chat-session.ts # Shared-Hub chat session adapter (local + cloud routing)
├── cloud-sessions.ts # Cloud session REST client + Hub-proxy manager
├── cline-auth.ts # Refresh-aware Cline auth token resolution
├── desktop-settings.ts # Desktop-owned settings (cloud sessions opt-in)
├── feature-flags.ts # Cloud sessions gate (env override + settings toggle)
├── session-data/ # Shared discovery, messages, artifacts, search helpers
├── paths.ts # Path resolution
@@ -212,9 +211,7 @@ Supported commands:
| `read_session_hooks` | Session data readers |
| `delete_chat_session` | `SqliteSessionStore.delete` + file cleanup; cloud sessions also delete the sandbox |
| `update_chat_session_title` | `resolveSessionBackend().updateSession`; cloud sessions PATCH the cloud API |
| `get_feature_flags` | `isCloudAgentsEnabled()` (env override + settings toggle) |
| `get_desktop_settings` | `readDesktopSettings()` |
| `set_cloud_sessions_enabled` | `setCloudSessionsEnabled()` + `feature_flags_changed` broadcast |
| `get_feature_flags` | PostHog flag snapshot + `isCloudAgentsEnabled()` (env override + `code-cloud-agents` rollout flag) |
| `list_cloud_repositories` | `CloudSessionManager.listRepositories()` (GitHub integration) |
| `list_cloud_models` | `CloudSessionManager.listModels()` (account-scoped Usage-Billing, ClinePass, and ClineFree catalogs; model IDs use the Cline cloud transport) |
| `list_cloud_branches` | `CloudSessionManager.listBranches()` (paginated) |
@@ -9,7 +9,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { getCloudSessionManager } from "./cloud-sessions";
import { handleCommand } from "./commands";
import { createSidecarContext } from "./context";
import { setCloudSessionsEnabled } from "./desktop-settings";
import {
getDesktopFeatureFlagsService,
resetDesktopFeatureFlagsForTesting,
@@ -79,7 +78,7 @@ describe("desktop settings commands", () => {
"list_chat_sessions",
"list_discovered_sessions",
"search_sessions",
])("hides cloud rows from %s unless rollout and user toggle are enabled", async (command) => {
])("hides cloud rows from %s unless the rollout flag is enabled", async (command) => {
const { ctx } = createContext();
for (const target of ["local", "cloud"] as const) {
ctx.liveSessions.set(target, {
@@ -113,16 +112,13 @@ describe("desktop settings commands", () => {
.map((row) => row.sessionId)
.sort();
expect(await rows()).toEqual(["local"]);
expect(discovery).not.toHaveBeenCalled();
setCloudSessionsEnabled(true);
expect(await rows()).toEqual(["cloud", "local"]);
discovery.mockImplementationOnce(async () => {
setCloudSessionsEnabled(false);
process.env.CLINE_CODE_CLOUD_AGENTS = "0";
return cloudRows;
});
expect(await rows()).toEqual(["local"]);
setCloudSessionsEnabled(true);
delete process.env.CLINE_CODE_CLOUD_AGENTS;
discovery.mockClear();
flags.hydrateCache({
userId: null,
@@ -172,45 +168,4 @@ describe("desktop settings commands", () => {
expect.objectContaining({ id: "cline-cloud/cloud-only" }),
);
});
it("rejects a non-boolean cloud sessions toggle value", async () => {
const { ctx, events } = createContext();
await expect(
handleCommand(ctx, "set_cloud_sessions_enabled", {
cloud_sessions_enabled: "yes",
}),
).rejects.toThrow("cloud_sessions_enabled must be a boolean");
expect(events).toEqual([]);
});
it.each([
true,
false,
])("persists toggle %s and refreshes cloud history immediately", async (enabled) => {
const { ctx, events } = createContext();
await expect(
handleCommand(ctx, "set_cloud_sessions_enabled", {
cloud_sessions_enabled: enabled,
}),
).resolves.toEqual({ cloudSessionsEnabled: enabled });
expect(events).toEqual([
{ name: "cloud_sessions_changed", payload: { environmentId: "local" } },
{
name: "feature_flags_changed",
payload: {
cloudAgents: false,
cloudAgentsAvailable: false,
environmentId: "local",
},
},
]);
await expect(
handleCommand(ctx, "get_feature_flags", {}),
).resolves.toMatchObject({ cloudAgents: false });
await expect(
handleCommand(ctx, "get_desktop_settings", {}),
).resolves.toEqual({ cloudSessionsEnabled: enabled });
});
});
@@ -126,14 +126,9 @@ import {
resolveSidecarAskQuestion,
sendEventToClient,
} from "./context";
import {
readDesktopSettings,
setCloudSessionsEnabled,
} from "./desktop-settings";
import { writeDiagnosticsReport } from "./diagnostics";
import {
identifyDesktopFeatureFlagsAccount,
isCloudAgentsAvailable,
isCloudAgentsEnabled,
refreshDesktopFeatureFlags,
} from "./feature-flags";
@@ -2208,8 +2203,6 @@ export async function handleCommand(
};
}
if (command === "get_feature_flags") {
// Resolve flags in the sidecar: available shows the opt-in;
// enabled requires both the rollout flag and the user's opt-in.
const snapshot = await refreshDesktopFeatureFlags({
logger: ctx.logger,
telemetry: ctx.telemetry,
@@ -2220,10 +2213,6 @@ export async function handleCommand(
logger: ctx.logger,
telemetry: ctx.telemetry,
}),
cloudAgentsAvailable: isCloudAgentsAvailable({
logger: ctx.logger,
telemetry: ctx.telemetry,
}),
};
}
if (command === "list_cloud_models") {
@@ -3222,9 +3211,6 @@ export async function handleCommand(
setAutoUpdateEnabledGlobally(args.auto_update_enabled);
return readGlobalSettings();
}
if (command === "get_desktop_settings") {
return readDesktopSettings();
}
if (command === "export_diagnostics") {
const sessionIds = Array.isArray(args?.sessionIds)
? args.sessionIds.filter(
@@ -3235,18 +3221,6 @@ export async function handleCommand(
openFileInEditor(dirname(result.path));
return result;
}
if (command === "set_cloud_sessions_enabled") {
if (typeof args?.cloud_sessions_enabled !== "boolean") {
throw new Error("cloud_sessions_enabled must be a boolean");
}
const settings = setCloudSessionsEnabled(args.cloud_sessions_enabled);
broadcastEvent(ctx, "cloud_sessions_changed", {});
broadcastEvent(ctx, "feature_flags_changed", {
cloudAgents: isCloudAgentsEnabled(),
cloudAgentsAvailable: isCloudAgentsAvailable(),
});
return settings;
}
// ── Connector channels ─────────────────────────────────────────────
if (command === "list_connector_channels") {
@@ -1,62 +0,0 @@
import {
mkdirSync,
mkdtempSync,
readFileSync,
rmSync,
writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import {
readDesktopSettings,
resolveDesktopSettingsPath,
setCloudSessionsEnabled,
} from "./desktop-settings";
let dataDir: string;
beforeEach(() => {
dataDir = mkdtempSync(join(tmpdir(), "cline-desktop-settings-"));
process.env.CLINE_DATA_DIR = dataDir;
});
afterEach(() => {
delete process.env.CLINE_DATA_DIR;
rmSync(dataDir, { recursive: true, force: true });
});
describe("desktop settings", () => {
it("defaults cloud sessions to off when no settings file exists", () => {
expect(readDesktopSettings()).toEqual({ cloudSessionsEnabled: false });
});
it("persists the cloud sessions opt-in and reads it back", () => {
expect(setCloudSessionsEnabled(true)).toEqual({
cloudSessionsEnabled: true,
});
expect(readDesktopSettings()).toEqual({ cloudSessionsEnabled: true });
expect(resolveDesktopSettingsPath().endsWith("code-settings.json")).toBe(
true,
);
expect(
JSON.parse(readFileSync(resolveDesktopSettingsPath(), "utf8")),
).toMatchObject({ cloudSessionsEnabled: true });
expect(setCloudSessionsEnabled(false)).toEqual({
cloudSessionsEnabled: false,
});
expect(readDesktopSettings()).toEqual({ cloudSessionsEnabled: false });
});
it("treats malformed files and non-boolean values as off", () => {
mkdirSync(dirname(resolveDesktopSettingsPath()), { recursive: true });
writeFileSync(resolveDesktopSettingsPath(), "{not json", "utf8");
expect(readDesktopSettings()).toEqual({ cloudSessionsEnabled: false });
writeFileSync(
resolveDesktopSettingsPath(),
JSON.stringify({ cloudSessionsEnabled: "yes" }),
"utf8",
);
expect(readDesktopSettings()).toEqual({ cloudSessionsEnabled: false });
});
});
@@ -1,49 +0,0 @@
import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { resolveClineDataDir } from "@cline/shared/storage";
/** Desktop-only preferences kept separate from strict shared global settings. */
export type DesktopSettings = {
/** Opt-in gate for cloud sessions while the feature is in preview. */
cloudSessionsEnabled: boolean;
};
const DEFAULT_SETTINGS: DesktopSettings = {
cloudSessionsEnabled: false,
};
export function resolveDesktopSettingsPath(): string {
return join(resolveClineDataDir(), "settings", "code-settings.json");
}
export function readDesktopSettings(): DesktopSettings {
let raw: string;
try {
raw = readFileSync(resolveDesktopSettingsPath(), "utf8");
} catch {
return { ...DEFAULT_SETTINGS };
}
try {
const parsed = JSON.parse(raw) as Record<string, unknown>;
return {
cloudSessionsEnabled: parsed.cloudSessionsEnabled === true,
};
} catch {
return { ...DEFAULT_SETTINGS };
}
}
export function writeDesktopSettings(settings: DesktopSettings): void {
const filePath = resolveDesktopSettingsPath();
mkdirSync(dirname(filePath), { recursive: true });
// Avoid leaving torn settings if the process exits mid-write.
const tempPath = `${filePath}.${process.pid}.tmp`;
writeFileSync(tempPath, `${JSON.stringify(settings, null, 2)}\n`, "utf8");
renameSync(tempPath, filePath);
}
export function setCloudSessionsEnabled(enabled: boolean): DesktopSettings {
const next = { ...readDesktopSettings(), cloudSessionsEnabled: enabled };
writeDesktopSettings(next);
return next;
}
@@ -11,7 +11,6 @@ import { arch, homedir, platform, release } from "node:os";
import { join } from "node:path";
import { readGlobalSettings, resolveClineDataDir } from "@cline/core";
import packageJson from "../package.json";
import { readDesktopSettings } from "./desktop-settings";
import { readSessionManifest } from "./paths";
/** How much of each log file (from the end) is included in the report. */
@@ -88,7 +87,6 @@ export function buildDiagnosticsReport(
},
dataDir,
globalSettings: readGlobalSettings(),
desktopSettings: readDesktopSettings(),
};
let text = `${JSON.stringify(report, null, 2)}\n`;
@@ -337,45 +337,32 @@ describe("cloud agents gate", () => {
delete process.env.CLINE_CODE_CLOUD_AGENTS;
});
it("is unavailable and disabled while the rollout flag is off", async () => {
const { isCloudAgentsAvailable, isCloudAgentsEnabled } = await import(
"./feature-flags"
);
expect(isCloudAgentsAvailable()).toBe(false);
it("is disabled while the rollout flag is off", async () => {
const { isCloudAgentsEnabled } = await import("./feature-flags");
expect(isCloudAgentsEnabled()).toBe(false);
});
it("does not enable a boolean rollout from a truthy variant payload", async () => {
const { isCloudAgentsAvailable } = await import("./feature-flags");
const { isCloudAgentsEnabled } = await import("./feature-flags");
mocks.getFlagPayload.mockReturnValue("control");
expect(isCloudAgentsAvailable()).toBe(false);
expect(isCloudAgentsEnabled()).toBe(false);
expect(mocks.getBooleanFlagEnabled).toHaveBeenCalledWith(
"code-cloud-agents",
);
});
it("needs both the rollout flag and the user's opt-in to enable", async () => {
const { isCloudAgentsEnabled, isCloudAgentsAvailable } = await import(
"./feature-flags"
);
const { setCloudSessionsEnabled } = await import("./desktop-settings");
it("is enabled by the rollout flag alone", async () => {
const { isCloudAgentsEnabled } = await import("./feature-flags");
mocks.getBooleanFlagEnabled.mockImplementation(
(flag: unknown) => flag === "code-cloud-agents",
);
expect(isCloudAgentsAvailable()).toBe(true);
setCloudSessionsEnabled(false);
expect(isCloudAgentsEnabled()).toBe(false);
setCloudSessionsEnabled(true);
expect(isCloudAgentsEnabled()).toBe(true);
});
it("lets the env override force the gate in both directions", async () => {
const { isCloudAgentsEnabled, isCloudAgentsAvailable } = await import(
"./feature-flags"
);
const { isCloudAgentsEnabled } = await import("./feature-flags");
mocks.getFlagPayload.mockReturnValue(undefined);
process.env.CLINE_CODE_CLOUD_AGENTS = "1";
expect(isCloudAgentsAvailable()).toBe(true);
expect(isCloudAgentsEnabled()).toBe(true);
process.env.CLINE_CODE_CLOUD_AGENTS = "0";
expect(isCloudAgentsEnabled()).toBe(false);
@@ -22,7 +22,6 @@ import {
} from "@cline/core/services/feature-flags/posthog";
import { FeatureFlag as SharedFeatureFlag } from "@cline/shared";
import { resolveClineDataDir } from "@cline/shared/storage";
import { readDesktopSettings } from "./desktop-settings";
const FEATURE_FLAG_CODE_CLOUD_AGENTS = SharedFeatureFlag.CODE_CLOUD_AGENTS;
@@ -282,8 +281,8 @@ export function readCloudAgentsEnvOverride(): boolean | undefined {
return undefined;
}
/** Whether the rollout makes cloud sessions available to this install. */
export function isCloudAgentsAvailable(options?: {
/** Whether the rollout enables cloud sessions for this install. */
export function isCloudAgentsEnabled(options?: {
logger?: BasicLogger;
telemetry?: ITelemetryService;
}): boolean {
@@ -293,14 +292,3 @@ export function isCloudAgentsAvailable(options?: {
FEATURE_FLAG_CODE_CLOUD_AGENTS,
);
}
/** Whether cloud sessions are both available and enabled by the user. */
export function isCloudAgentsEnabled(options?: {
logger?: BasicLogger;
telemetry?: ITelemetryService;
}): boolean {
const override = readCloudAgentsEnvOverride();
if (override !== undefined) return override;
if (!isCloudAgentsAvailable(options)) return false;
return readDesktopSettings().cloudSessionsEnabled;
}
@@ -1277,20 +1277,8 @@ function ChatThreadPane({
});
};
fetchFlags();
// Reflect Settings changes without restarting or switching accounts.
const unsubscribe = desktopClient.subscribe(
"feature_flags_changed",
(payload) => {
if (!cancelled) {
setCloudAgentsFlagEnabled(
Boolean((payload as { cloudAgents?: boolean })?.cloudAgents),
);
}
},
);
return () => {
cancelled = true;
unsubscribe();
if (retryTimer !== undefined) window.clearTimeout(retryTimer);
};
}, [accountUserId]);
@@ -112,52 +112,3 @@ describe("SettingsView font size", () => {
expect(increaseButton?.disabled).toBe(true);
});
});
describe("SettingsView cloud sessions rollout", () => {
it.each([
{
caseName: "the rollout explicitly enables it",
featureFlags: { cloudAgents: false, cloudAgentsAvailable: true },
visible: true,
},
{
caseName: "feature flags are unavailable",
featureFlags: new Error("feature flags unavailable"),
visible: false,
},
])("shows the preview setting only when $caseName", async ({
featureFlags,
visible,
}) => {
invoke.mockImplementation(async (command: string) => {
if (command === "get_feature_flags") {
if (featureFlags instanceof Error) throw featureFlags;
return featureFlags;
}
if (command === "get_desktop_settings") {
return { cloudSessionsEnabled: false };
}
return {
telemetryOptOut: false,
autoUpdateEnabled: true,
};
});
await act(async () => {
root.render(
<SettingsView
onExportDiagnostics={vi.fn()}
onNavigateSection={vi.fn()}
section="General"
/>,
);
});
await vi.waitFor(() =>
expect(
container.querySelector(
'[role="switch"][aria-label="Cloud sessions"]',
) !== null,
).toBe(visible),
);
});
});
@@ -699,32 +699,6 @@ function GeneralSettingsContent({
const [autoUpdateLoading, setAutoUpdateLoading] = useState(true);
const [autoUpdateSaving, setAutoUpdateSaving] = useState(false);
const [autoUpdateError, setAutoUpdateError] = useState<string | null>(null);
const [cloudSessionsEnabled, setCloudSessionsEnabled] = useState(false);
const [cloudSessionsLoading, setCloudSessionsLoading] = useState(true);
const [cloudSessionsSaving, setCloudSessionsSaving] = useState(false);
const [cloudSessionsError, setCloudSessionsError] = useState<string | null>(
null,
);
// The environment override can differ from the stored opt-in.
const [cloudSessionsEffective, setCloudSessionsEffective] = useState<
boolean | null
>(null);
// Keep the preview hidden until the rollout service explicitly enables it.
const [cloudSessionsAvailable, setCloudSessionsAvailable] = useState(false);
const refreshCloudSessionsEffective = useCallback(async () => {
try {
const flags = await desktopClient.invoke<{
cloudAgents?: boolean;
cloudAgentsAvailable?: boolean;
}>("get_feature_flags");
setCloudSessionsEffective(Boolean(flags.cloudAgents));
setCloudSessionsAvailable(flags.cloudAgentsAvailable === true);
} catch {
setCloudSessionsEffective(null);
setCloudSessionsAvailable(false);
}
}, []);
useEffect(() => setAppIconLocation(appIconSurface(navigator.userAgent)), []);
useEffect(() => subscribeToAppFontSize(setFontSize), []);
@@ -734,45 +708,21 @@ function GeneralSettingsContent({
setTelemetryError(null);
setAutoUpdateLoading(true);
setAutoUpdateError(null);
setCloudSessionsLoading(true);
setCloudSessionsError(null);
await Promise.all([
(async () => {
try {
const settings = await desktopClient.invoke<GlobalSettingsResponse>(
"get_global_settings",
);
setTelemetryOptOut(settings.telemetryOptOut);
setAutoUpdateEnabled(settings.autoUpdateEnabled);
} catch (error) {
const message =
error instanceof Error ? error.message : String(error);
setTelemetryError(message);
setAutoUpdateError(message);
} finally {
setTelemetryLoading(false);
setAutoUpdateLoading(false);
}
})(),
(async () => {
try {
const desktopSettings = await desktopClient.invoke<{
cloudSessionsEnabled: boolean;
}>("get_desktop_settings");
setCloudSessionsEnabled(
Boolean(desktopSettings.cloudSessionsEnabled),
);
} catch (error) {
setCloudSessionsError(
error instanceof Error ? error.message : String(error),
);
} finally {
setCloudSessionsLoading(false);
}
})(),
refreshCloudSessionsEffective(),
]);
}, [refreshCloudSessionsEffective]);
try {
const settings = await desktopClient.invoke<GlobalSettingsResponse>(
"get_global_settings",
);
setTelemetryOptOut(settings.telemetryOptOut);
setAutoUpdateEnabled(settings.autoUpdateEnabled);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
setTelemetryError(message);
setAutoUpdateError(message);
} finally {
setTelemetryLoading(false);
setAutoUpdateLoading(false);
}
}, []);
useEffect(() => {
const timeoutId = window.setTimeout(() => {
@@ -821,26 +771,6 @@ function GeneralSettingsContent({
}
};
const updateCloudSessionsEnabled = async (nextValue: boolean) => {
const previousValue = cloudSessionsEnabled;
setCloudSessionsEnabled(nextValue);
setCloudSessionsSaving(true);
setCloudSessionsError(null);
try {
const settings = await desktopClient.invoke<{
cloudSessionsEnabled: boolean;
}>("set_cloud_sessions_enabled", { cloud_sessions_enabled: nextValue });
setCloudSessionsEnabled(Boolean(settings.cloudSessionsEnabled));
await refreshCloudSessionsEffective();
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
setCloudSessionsEnabled(previousValue);
setCloudSessionsError(message);
} finally {
setCloudSessionsSaving(false);
}
};
const updateTheme = (darkModeEnabled: boolean) => {
const nextTheme = darkModeEnabled ? "dark" : "light";
setTheme(setStoredHubTheme(nextTheme));
@@ -1051,46 +981,6 @@ function GeneralSettingsContent({
onCheckedChange={(checked) => void updateAutoUpdateEnabled(checked)}
/>
</div>
{cloudSessionsAvailable ? (
<div className="flex py-4 items-center justify-between gap-5 border-b max-[720px]:flex-col max-[720px]:items-stretch max-[720px]:py-4">
<div className="flex flex-col gap-1">
<p className="flex items-center gap-2 text-base font-semibold text-foreground">
Cloud sessions
<span className="rounded-full bg-primary/10 px-2 py-0.5 text-[11px] font-medium uppercase tracking-wide text-primary">
Preview
</span>
</p>
<p className="text-sm text-muted-foreground">
Run Cline on your GitHub repositories in secure cloud sandboxes.
Adds a Cloud option to the new-session composer. Requires a
Cline account with GitHub connected.
</p>
{cloudSessionsError ? (
<p className="mt-2 text-xs text-destructive" role="alert">
Failed to update cloud sessions setting: {cloudSessionsError}
</p>
) : null}
{cloudSessionsEffective !== null &&
!cloudSessionsLoading &&
cloudSessionsEffective !== cloudSessionsEnabled ? (
<p className="mt-2 text-xs text-muted-foreground">
Cloud sessions are currently{" "}
{cloudSessionsEffective ? "enabled" : "disabled"} by the
CLINE_CODE_CLOUD_AGENTS environment override, which takes
precedence over this setting.
</p>
) : null}
</div>
<Switch
aria-label="Cloud sessions"
checked={cloudSessionsEnabled}
disabled={cloudSessionsLoading || cloudSessionsSaving}
onCheckedChange={(checked) =>
void updateCloudSessionsEnabled(checked)
}
/>
</div>
) : null}
<div className="flex py-4 items-center justify-between gap-5 border-b max-[720px]:flex-col max-[720px]:items-stretch max-[720px]:py-4">
<div className="flex flex-col gap-1">
<p className="text-base font-semibold text-foreground">Telemetry</p>
@@ -0,0 +1,73 @@
import { afterEach, beforeEach, describe, expect, it } from "bun:test"
import fs from "node:fs"
import os from "node:os"
import path from "node:path"
import { createStorageContext, type StorageContext } from "@/shared/storage/storage-context"
import { migrateOpenAiCompatibleBaseUrlToV1 } from "../openai-base-url-migration"
describe("migrateOpenAiCompatibleBaseUrlToV1", () => {
let tempDir: string
let storage: StorageContext
beforeEach(() => {
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "openai-base-url-migration-"))
storage = createStorageContext({ clineDir: tempDir, workspacePath: tempDir })
})
afterEach(() => {
fs.rmSync(tempDir, { recursive: true, force: true })
})
for (const [baseUrl, expected] of [
["https://example.com", "https://example.com/v1"],
["http://localhost:8000/", "http://localhost:8000/v1"],
]) {
it(`adds /v1 to the host-only base URL ${baseUrl}`, () => {
storage.globalState.update("openAiBaseUrl", baseUrl)
migrateOpenAiCompatibleBaseUrlToV1(storage)
expect(storage.globalState.get<string>("openAiBaseUrl")).toBe(expected)
})
}
for (const baseUrl of ["https://example.com/v1", "https://example.com/api/foo", "not a url"]) {
it(`leaves the base URL ${baseUrl} unchanged`, () => {
storage.globalState.update("openAiBaseUrl", baseUrl)
migrateOpenAiCompatibleBaseUrlToV1(storage)
expect(storage.globalState.get<string>("openAiBaseUrl")).toBe(baseUrl)
})
}
it("runs only once, so a host-only URL saved afterwards stays as entered", () => {
storage.globalState.update("openAiBaseUrl", "https://example.com")
migrateOpenAiCompatibleBaseUrlToV1(storage)
storage.globalState.update("openAiBaseUrl", "https://example.com")
migrateOpenAiCompatibleBaseUrlToV1(storage)
expect(storage.globalState.get<string>("openAiBaseUrl")).toBe("https://example.com")
})
it("marks itself done when there is no base URL, so a later host-only URL stays as entered", () => {
migrateOpenAiCompatibleBaseUrlToV1(storage)
storage.globalState.update("openAiBaseUrl", "https://example.com")
migrateOpenAiCompatibleBaseUrlToV1(storage)
expect(storage.globalState.get<string>("openAiBaseUrl")).toBe("https://example.com")
})
it("shares its marker across hosts through globalState.json", () => {
storage.globalState.update("openAiBaseUrl", "https://example.com")
migrateOpenAiCompatibleBaseUrlToV1(storage)
storage.globalState.update("openAiBaseUrl", "https://example.com")
const otherHost = createStorageContext({ clineDir: tempDir, workspacePath: tempDir })
migrateOpenAiCompatibleBaseUrlToV1(otherHost)
expect(otherHost.globalState.get<string>("openAiBaseUrl")).toBe("https://example.com")
})
})
@@ -0,0 +1,29 @@
import { Logger } from "@/shared/services/Logger"
import type { StorageContext } from "@/shared/storage/storage-context"
const MIGRATED_KEY = "__openAiBaseUrlV1Migrated"
/**
* Earlier SDK builds sent requests for an OpenAI Compatible base URL with no
* path (`http://localhost:8000`) to `<host>/v1`; the URL is now used as entered.
* Save the `/v1` so existing setups keep reaching the same endpoint.
*
* Runs once across all hosts: the marker lives in the shared globalState.json
* and is set even when there is nothing to rewrite, so a host-only URL saved
* afterwards (in any host) stays as entered.
*/
export function migrateOpenAiCompatibleBaseUrlToV1(storage: StorageContext): void {
if (storage.globalState.get(MIGRATED_KEY)) {
return
}
const baseUrl = storage.globalState.get<string>("openAiBaseUrl")?.trim()
if (baseUrl && URL.canParse(baseUrl)) {
const url = new URL(baseUrl)
if (url.pathname === "/") {
url.pathname = "/v1"
storage.globalState.update("openAiBaseUrl", url.toString().replace(/\/$/, ""))
Logger.info("[Migration] Added /v1 to the host-only OpenAI Compatible base URL")
}
}
storage.globalState.update(MIGRATED_KEY, true)
}
+2
View File
@@ -29,6 +29,7 @@ import { improveWithCline } from "./core/controller/commands/improveWithCline"
import { sendAddToInputEvent } from "./core/controller/ui/subscribeToAddToInput"
import { sendShowWebviewEvent } from "./core/controller/ui/subscribeToShowWebview"
import { HookDiscoveryCache } from "./core/hooks/HookDiscoveryCache"
import { migrateOpenAiCompatibleBaseUrlToV1 } from "./core/storage/openai-base-url-migration"
import {
cleanupMcpMarketplaceCatalogFromGlobalState,
cleanupOldApiKey,
@@ -84,6 +85,7 @@ export async function activate(context: vscode.ExtensionContext) {
const workspacePath = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath
const storageContext = createStorageContext({ workspacePath })
await exportVSCodeStorageToSharedFiles(context, storageContext)
migrateOpenAiCompatibleBaseUrlToV1(storageContext)
// 4. Register services and perform common initialization
// IMPORTANT: Must be done after host provider is setup and migrations are complete
+2
View File
@@ -14,6 +14,7 @@ import { retryOperation } from "@utils/retry"
import * as path from "path"
import { initialize, tearDown } from "@/common"
import { SqliteLockManager } from "@/core/locks/SqliteLockManager"
import { migrateOpenAiCompatibleBaseUrlToV1 } from "@/core/storage/openai-base-url-migration"
import { WebviewProvider } from "@/core/webview"
import { AuthHandler } from "@/hosts/external/AuthHandler"
import { HostProvider } from "@/hosts/host-provider"
@@ -87,6 +88,7 @@ async function main() {
// Create shared file-backed storage
const storageContext = createStorageContext()
migrateOpenAiCompatibleBaseUrlToV1(storageContext)
const webviewProvider = await initialize(storageContext)
// Enable the localhost HTTP server that handles auth redirects.
@@ -318,7 +318,7 @@ export const WelcomeSection: React.FC<WelcomeSectionProps> = ({
}, [bannerConfig, banners, clineUser, handleBannerAction, handleBannerDismiss, clinePassPromoBanner])
return (
<div className="flex flex-col flex-1 w-full h-full p-0 m-0">
<div className="flex flex-col flex-1 w-full min-h-0 p-0 m-0">
<WhatsNewModal
onBannerAction={handleBannerAction}
onClose={handleCloseWhatsNewModal}
@@ -39,14 +39,6 @@ const agentFeatures: FeatureToggle[] = [
stateKey: "useAutoCondense",
settingKey: "useAutoCondense",
},
{
id: "cloud-sessions",
label: "Cloud",
description:
"Run Cline on your GitHub repositories in secure cloud sandboxes. Adds a Cloud option to the new task composer. Requires a Cline account with GitHub connected.",
stateKey: "cloudSessionsEnabled",
settingKey: "cloudSessionsEnabled",
},
]
const editorFeatures: FeatureToggle[] = [
@@ -175,13 +167,11 @@ const FeatureSettingsSection = ({ renderSectionHeader }: FeatureSettingsSectionP
subagentsEnabled,
worktreesEnabled: worktreesEnabled?.user,
backgroundEditEnabled,
cloudSessionsEnabled,
}
// Visibility lookup for features with feature flags
const featureVisibility: Record<string, boolean | undefined> = {
worktreesEnabled: worktreesEnabled?.featureFlag,
cloudSessionsEnabled: !!cloudSessionsAvailable,
}
return (
@@ -228,6 +218,14 @@ const FeatureSettingsSection = ({ renderSectionHeader }: FeatureSettingsSectionP
label="Web Search"
onChange={(checked) => updateSetting("webSearchEnabled", checked)}
/>
<FeatureRow
checked={cloudSessionsEnabled}
description="Run Cline on your GitHub repositories in secure cloud sandboxes. Adds a Cloud option to the new task composer. Requires a Cline account with GitHub connected."
id="cloud-sessions"
isVisible={!!cloudSessionsAvailable}
label="Cloud"
onChange={(checked) => updateSetting("cloudSessionsEnabled", checked)}
/>
</div>
</div>
+19 -7
View File
@@ -60,6 +60,11 @@ import {
recordProviderRequestCapture,
wrapFetchForProviderRequestCapture,
} from "./provider-request-capture";
import {
appendRequestId,
extractResponseRequestId,
findRequestIdHeader,
} from "./response-request-id";
import {
applyPromptCacheToLastTextPart,
shouldApplyPromptCache,
@@ -1467,13 +1472,19 @@ interface CapturedStreamError {
* does not report the same failure a second time.
*/
reported?: boolean;
requestId?: string;
baseMessage: string;
}
function captureStreamError(error: unknown): CapturedStreamError {
const requestId = extractResponseRequestId(error);
const baseMessage = extractErrorMessage(error);
return {
message: extractErrorMessage(error),
message: appendRequestId(baseMessage, requestId),
baseMessage,
errorClass: classifyProviderError(error),
retryable: isRetryableBeyondSdkRetries(error),
...(requestId ? { requestId } : {}),
};
}
@@ -1517,9 +1528,7 @@ async function* emitAiSdkEvents(
continue;
}
if (part.type === "finish-step") {
requestId = Object.entries(part.response?.headers ?? {}).find(
([name]) => name.toLowerCase() === "x-request-id",
)?.[1];
requestId = findRequestIdHeader(part.response?.headers);
continue;
}
if (part.type === "text-delta") {
@@ -1994,10 +2003,11 @@ async function* emitAiSdkEvents(
};
}
const finishRequestId = requestId ?? streamError?.requestId;
yield {
type: "finish",
reason: streamError ? "error" : mapFinishReason(finishReason),
...(requestId ? { requestId } : {}),
...(finishRequestId ? { requestId: finishRequestId } : {}),
error: streamError?.message,
errorClass: streamError?.errorClass,
errorRetryable: streamError?.retryable,
@@ -2381,13 +2391,14 @@ function createAiSdkProvider(
component: "llms",
operation: "provider.stream",
error: streamError,
errorMessage: msg,
errorMessage: captured.baseMessage,
severity: "error",
handled: true,
context: {
providerId: request.providerId,
modelId: request.modelId,
providerKind: kind,
requestId: captured.requestId,
},
});
},
@@ -2430,13 +2441,14 @@ function createAiSdkProvider(
component: "llms",
operation: "provider.create_or_stream",
error,
errorMessage: msg,
errorMessage: captured.baseMessage,
severity: "error",
handled: true,
context: {
providerId: request.providerId,
modelId: request.modelId,
providerKind: kind,
requestId: captured.requestId,
},
});
yield {
+102 -16
View File
@@ -19,6 +19,7 @@ import {
type ITelemetryService,
resetSdkErrorRateLimiterForTests,
} from "@cline/shared";
import { APICallError } from "ai";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { normalizeModelsDevProviderModels } from "../catalog/catalog-live";
import { createOpenAICompatibleProvider } from "./ai-sdk";
@@ -96,22 +97,28 @@ function createFetchMock() {
};
}
vi.mock("ai", () => ({
jsonSchema: (schema: unknown, options: unknown) => ({
jsonSchema: schema,
...(options && typeof options === "object" ? options : {}),
}),
generateImage: (input: unknown) => generateImageSpy(input),
streamText: (input: unknown) => streamTextSpy(input),
// `wrapLanguageModel` is used by the openai-compatible and mistral
// vendors to attach `splitToolImagesMiddleware`. The middleware itself
// is exercised by its own unit tests; here we just need an identity
// pass-through so the vendor factories' downstream `model:` callbacks
// keep returning the spy-produced mock objects unchanged. (The mock
// objects don't satisfy the real `LanguageModelV4` interface, so we
// can't call the real `wrapLanguageModel` either way.)
wrapLanguageModel: ({ model }: { model: unknown }) => model,
}));
vi.mock("ai", async (importOriginal) => {
const { APICallError, RetryError } =
await importOriginal<typeof import("ai")>();
return {
APICallError,
RetryError,
jsonSchema: (schema: unknown, options: unknown) => ({
jsonSchema: schema,
...(options && typeof options === "object" ? options : {}),
}),
generateImage: (input: unknown) => generateImageSpy(input),
streamText: (input: unknown) => streamTextSpy(input),
// `wrapLanguageModel` is used by the openai-compatible and mistral
// vendors to attach `splitToolImagesMiddleware`. The middleware itself
// is exercised by its own unit tests; here we just need an identity
// pass-through so the vendor factories' downstream `model:` callbacks
// keep returning the spy-produced mock objects unchanged. (The mock
// objects don't satisfy the real `LanguageModelV4` interface, so we
// can't call the real `wrapLanguageModel` either way.)
wrapLanguageModel: ({ model }: { model: unknown }) => model,
};
});
vi.mock("@ai-sdk/openai", () => ({
createOpenAI: () => ({
@@ -1401,6 +1408,44 @@ describe("sdk-gateway", () => {
});
});
it("includes the response x-request-id in stream error messages", async () => {
streamTextSpy.mockReturnValue({
fullStream: makeStreamParts([
{
type: "error",
error: new APICallError({
message: "Unauthorized",
url: "https://api.cline.bot/api/v1/chat/completions",
requestBodyValues: {},
statusCode: 401,
responseHeaders: { "X-Request-ID": "req_abc123" },
responseBody: JSON.stringify({
error: { message: "Unauthorized: re-authenticate" },
}),
}),
},
]),
});
const gateway = createGateway({
providerConfigs: [{ providerId: "openai-native", apiKey: "test" }],
});
const events = await collect(
await gateway.stream({
providerId: "openai-native",
modelId: "gpt-5-mini",
messages: baseMessages,
}),
);
expect(events.at(-1)).toMatchObject({
type: "finish",
reason: "error",
requestId: "req_abc123",
error: "Unauthorized: re-authenticate (Request ID: req_abc123)",
});
});
it("preserves valid mixed text when a generated image is rejected", async () => {
const oversized = "A".repeat(DEFAULT_MAX_IMAGE_ENCODED_BYTES + 4);
streamTextSpy.mockReturnValue({
@@ -2802,6 +2847,47 @@ describe("sdk-gateway", () => {
expect(sdkErrors).toHaveLength(5);
});
it("rate-limits stream failures that differ only by request ID", async () => {
const { telemetry, capture } = createTelemetryMock();
const gateway = createGateway({
telemetry,
providerConfigs: [{ providerId: "openai-native", apiKey: "test" }],
});
for (let i = 0; i < 20; i++) {
const rawError = new APICallError({
message: "Unauthorized",
url: "https://api.cline.bot/api/v1/chat/completions",
requestBodyValues: {},
statusCode: 401,
responseHeaders: { "x-request-id": `req_${"abcdefghijklmnopqrst"[i]}` },
});
streamTextSpy.mockImplementation(
(input: { onError?: (event: { error: unknown }) => void }) => {
input.onError?.({ error: rawError });
return {
fullStream: makeStreamParts([{ type: "error", error: rawError }]),
};
},
);
await collect(
await gateway.stream({
providerId: "openai-native",
modelId: "gpt-5-mini",
messages: baseMessages,
}),
);
}
const sdkErrors = capture.mock.calls.filter(
([call]) => (call as { event: string }).event === "sdk.error",
);
expect(sdkErrors).toHaveLength(5);
expect(sdkErrors[0]?.[0]).toMatchObject({
properties: { error_message: "Unauthorized", requestId: "req_a" },
});
});
it("records the extracted cause when provider creation fails through a generic wrapper", async () => {
streamTextSpy.mockImplementation(() => {
throw new Error("No output generated. Check the stream for errors.", {
@@ -0,0 +1,76 @@
import { APICallError, RetryError } from "ai";
import { describe, expect, it } from "vitest";
import {
extractResponseRequestId,
findRequestIdHeader,
} from "./response-request-id";
function apiCallError(responseHeaders?: Record<string, string>) {
return new APICallError({
message: "Bad Gateway",
url: "https://api.cline.bot/api/v1/chat/completions",
requestBodyValues: {},
statusCode: 502,
responseHeaders,
});
}
describe("findRequestIdHeader", () => {
it("matches the header case-insensitively and trims it", () => {
expect(findRequestIdHeader({ "X-Request-ID": " req_1 " })).toBe("req_1");
});
it("returns undefined for missing or blank headers", () => {
expect(findRequestIdHeader(undefined)).toBeUndefined();
expect(findRequestIdHeader({ "x-request-id": " " })).toBeUndefined();
});
});
describe("extractResponseRequestId", () => {
it("reads a top-level APICallError", () => {
expect(
extractResponseRequestId(apiCallError({ "x-request-id": "req_top" })),
).toBe("req_top");
});
it("walks the cause chain to a wrapped APICallError", () => {
const wrapped = new Error("outer", {
cause: new Error("middle", {
cause: apiCallError({ "x-request-id": "req_nested" }),
}),
});
expect(extractResponseRequestId(wrapped)).toBe("req_nested");
});
it("stops at the depth guard on cyclic causes", () => {
const a = new Error("a") as Error & { cause?: unknown };
const b = new Error("b", { cause: a });
a.cause = b;
expect(extractResponseRequestId(a)).toBeUndefined();
});
it("falls back to earlier RetryError attempts newest-first", () => {
const error = new RetryError({
message: "Failed after 3 attempts",
reason: "maxRetriesExceeded",
errors: [
apiCallError({ "x-request-id": "req_first" }),
apiCallError({ "x-request-id": "req_second" }),
apiCallError(),
],
});
expect(extractResponseRequestId(error)).toBe("req_second");
});
it("prefers RetryError.lastError when it has a request id", () => {
const error = new RetryError({
message: "Failed after 2 attempts",
reason: "maxRetriesExceeded",
errors: [
apiCallError({ "x-request-id": "req_first" }),
apiCallError({ "x-request-id": "req_last" }),
],
});
expect(extractResponseRequestId(error)).toBe("req_last");
});
});
@@ -0,0 +1,58 @@
import { APICallError, RetryError } from "ai";
const REQUEST_ID_HEADER = "x-request-id";
const MAX_CAUSE_DEPTH = 5;
export function findRequestIdHeader(
headers: Record<string, string | undefined> | undefined,
): string | undefined {
const value = Object.entries(headers ?? {}).find(
([name]) => name.toLowerCase() === REQUEST_ID_HEADER,
)?.[1];
return value?.trim() || undefined;
}
export function extractResponseRequestId(
error: unknown,
depth = 0,
): string | undefined {
if (depth > MAX_CAUSE_DEPTH || !error || typeof error !== "object") {
return undefined;
}
if (RetryError.isInstance(error)) {
const fromLast = extractResponseRequestId(error.lastError, depth + 1);
if (fromLast) {
return fromLast;
}
for (let i = error.errors.length - 1; i >= 0; i--) {
const attempt = error.errors[i];
if (attempt === error.lastError) {
continue;
}
const fromAttempt = extractResponseRequestId(attempt, depth + 1);
if (fromAttempt) {
return fromAttempt;
}
}
}
if (APICallError.isInstance(error)) {
const fromHeaders = findRequestIdHeader(error.responseHeaders);
if (fromHeaders) {
return fromHeaders;
}
}
return extractResponseRequestId(
(error as { cause?: unknown }).cause,
depth + 1,
);
}
export function appendRequestId(
message: string,
requestId: string | undefined,
): string {
if (!requestId || message.includes(requestId)) {
return message;
}
return `${message} (Request ID: ${requestId})`;
}
+1 -1
View File
@@ -1,7 +1,7 @@
export const FeatureFlag = {
/** Enables ClinePass provider/model list exposure in supported clients. */
CLINE_PASS: "ext-cline-pass",
/** Makes the Cloud sessions opt-in visible in the Cline Code desktop app. */
/** Enables Cloud sessions in the Cline Code desktop app. */
CODE_CLOUD_AGENTS: "code-cloud-agents",
/** Shows the GitHub integration step in the desktop app */
CODE_ONBOARDING_GITHUB: "code-onboarding-github",