mirror of
https://github.com/cline/cline.git
synced 2026-09-07 22:16:30 +08:00
Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c6ed3c3bb8 | ||
|
|
c7b36ed518 | ||
|
|
2baf966db7 | ||
|
|
152ba674da | ||
|
|
12f5dc2e9e | ||
|
|
28d806a4e4 | ||
|
|
e92c7de8c0 | ||
|
|
718e5b53f6 |
@@ -20,7 +20,8 @@ jobs:
|
||||
github.event_name == 'pull_request_target' ||
|
||||
(github.event_name == 'issue_comment' &&
|
||||
github.event.issue.pull_request &&
|
||||
contains(github.event.comment.body, '/test-jetbrains'))
|
||||
contains(github.event.comment.body, '/test-jetbrains') &&
|
||||
contains(fromJSON('["MEMBER","OWNER","COLLABORATOR"]'), github.event.comment.author_association))
|
||||
steps:
|
||||
- name: Generate GitHub App Token
|
||||
id: app-token
|
||||
|
||||
@@ -1,5 +1,26 @@
|
||||
# Changelog
|
||||
|
||||
## [3.70.0]
|
||||
|
||||
### Added
|
||||
|
||||
- New Cline API docs: Getting Started, Auth, Chat Completions, Models, Errors, and SDK Examples
|
||||
- Hook payloads now include `model.provider` and `model.slug`
|
||||
- Token/cost updates now happen immediately as usage chunks arrive, not after tool execution
|
||||
|
||||
### Fixed
|
||||
|
||||
- Improve subagent context compaction logic
|
||||
- Subagent stream retry delay increased to reduce noise from transient failures
|
||||
- State serialization errors are now caught and logged instead of crashing
|
||||
- Removed incorrect `max_tokens` from OpenRouter requests
|
||||
|
||||
### Changed
|
||||
|
||||
- Windows test cleanup now retries on locked files and applies per-test timeouts
|
||||
- Updated hooks docs
|
||||
|
||||
|
||||
## [3.69.0]
|
||||
|
||||
### Added
|
||||
|
||||
@@ -1,5 +1,19 @@
|
||||
# cline
|
||||
|
||||
## [2.6.0]
|
||||
|
||||
### Added
|
||||
|
||||
- Hook payloads now include `model.provider` and `model.slug`
|
||||
- Token/cost updates now happen immediately as usage chunks arrive, not after tool execution
|
||||
|
||||
### Fixed
|
||||
|
||||
- Improve subagent context compaction logic
|
||||
- Subagent stream retry delay increased to reduce noise from transient failures
|
||||
- State serialization errors are now caught and logged instead of crashing
|
||||
- Removed incorrect `max_tokens` from OpenRouter requests
|
||||
|
||||
## [2.5.2]
|
||||
|
||||
### Added
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "cline",
|
||||
"version": "2.5.2",
|
||||
"version": "2.6.0",
|
||||
"description": "Autonomous coding agent CLI - capable of creating/editing files, running commands, using the browser, and more",
|
||||
"main": "dist/lib.mjs",
|
||||
"types": "dist/lib.d.ts",
|
||||
|
||||
@@ -209,9 +209,15 @@ Every hook receives a JSON object with common fields plus hook-specific data:
|
||||
```json
|
||||
{
|
||||
"taskId": "abc123",
|
||||
"hookName": "PreToolUse",
|
||||
"clineVersion": "3.17.0",
|
||||
"timestamp": 1736654400000,
|
||||
"workspacePath": "/path/to/project",
|
||||
"timestamp": "1736654400000",
|
||||
"workspaceRoots": ["/path/to/project"],
|
||||
"userId": "user_123",
|
||||
"model": {
|
||||
"provider": "openrouter",
|
||||
"slug": "anthropic/claude-sonnet-4.5"
|
||||
},
|
||||
|
||||
// Hook-specific field (name matches hook type in camelCase)
|
||||
"taskStart": {
|
||||
@@ -220,6 +226,17 @@ Every hook receives a JSON object with common fields plus hook-specific data:
|
||||
}
|
||||
```
|
||||
|
||||
`model.provider` and `model.slug` are machine-stable identifiers for the active provider/model at hook execution time. If unavailable, Cline sends deterministic fallback values: `"unknown"`.
|
||||
|
||||
<Note>
|
||||
Migration note for existing hook scripts:
|
||||
|
||||
- `timestamp` is a string (milliseconds since epoch), not a number
|
||||
- `workspaceRoots` is an array of workspace root paths and replaces the old singular `workspacePath`
|
||||
|
||||
If your scripts previously read `.workspacePath`, switch to `.workspaceRoots[0]` (or iterate all roots).
|
||||
</Note>
|
||||
|
||||
The hook-specific field name matches the hook type:
|
||||
- `taskStart`, `taskResume`, `taskCancel`, `taskComplete` contain `{ task: string }`
|
||||
- `preToolUse` contains `{ tool: string, parameters: object }`
|
||||
@@ -439,7 +456,7 @@ Inject project-specific information when a task begins:
|
||||
# TaskStart hook
|
||||
|
||||
INPUT=$(cat)
|
||||
WORKSPACE=$(echo "$INPUT" | jq -r '.workspacePath')
|
||||
WORKSPACE=$(echo "$INPUT" | jq -r '.workspaceRoots[0] // empty')
|
||||
|
||||
# Read project info if available
|
||||
if [[ -f "$WORKSPACE/.project-context" ]]; then
|
||||
|
||||
Generated
+3
-3
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "claude-dev",
|
||||
"version": "3.69.0",
|
||||
"version": "3.70.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "claude-dev",
|
||||
"version": "3.69.0",
|
||||
"version": "3.70.0",
|
||||
"license": "Apache-2.0",
|
||||
"workspaces": [
|
||||
".",
|
||||
@@ -161,7 +161,7 @@
|
||||
},
|
||||
"cli": {
|
||||
"name": "cline",
|
||||
"version": "2.5.0",
|
||||
"version": "2.6.0",
|
||||
"cpu": [
|
||||
"x64",
|
||||
"arm64"
|
||||
|
||||
+1
-1
@@ -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": "3.69.0",
|
||||
"version": "3.70.0",
|
||||
"icon": "assets/icons/icon.png",
|
||||
"workspaces": [
|
||||
".",
|
||||
|
||||
@@ -14,6 +14,7 @@ message HookInput {
|
||||
string task_id = 4;
|
||||
repeated string workspace_roots = 5;
|
||||
string user_id = 6;
|
||||
HookModelContext model = 7;
|
||||
oneof data {
|
||||
PreToolUseData pre_tool_use = 10;
|
||||
PostToolUseData post_tool_use = 11;
|
||||
@@ -26,6 +27,11 @@ message HookInput {
|
||||
}
|
||||
}
|
||||
|
||||
message HookModelContext {
|
||||
string provider = 1;
|
||||
string slug = 2;
|
||||
}
|
||||
|
||||
// Output message for all hooks
|
||||
message HookOutput {
|
||||
string context_modification = 1;
|
||||
|
||||
@@ -116,8 +116,6 @@ export async function createOpenRouterStream(
|
||||
break
|
||||
}
|
||||
|
||||
const maxTokens = model.info.maxTokens || undefined
|
||||
|
||||
let temperature: number | undefined = 0
|
||||
let topP: number | undefined
|
||||
if (
|
||||
@@ -185,7 +183,6 @@ export async function createOpenRouterStream(
|
||||
|
||||
const requestPayload: Record<string, unknown> = {
|
||||
model: model.id,
|
||||
max_tokens: maxTokens,
|
||||
temperature: temperature,
|
||||
top_p: topP,
|
||||
messages: openAiMessages,
|
||||
|
||||
@@ -12,6 +12,7 @@ describe("Hook System", () => {
|
||||
let sandbox: sinon.SinonSandbox
|
||||
let hookTestEnv: HookTestEnv
|
||||
const WINDOWS_HOOK_TEST_TIMEOUT_MS = 15000
|
||||
const WINDOWS_TEST_TIMEOUT_MS = 10000
|
||||
|
||||
// Helper to write executable hook script
|
||||
const writeHookScript = async (hookPath: string, nodeScript: string): Promise<void> => {
|
||||
@@ -25,6 +26,12 @@ describe("Hook System", () => {
|
||||
sandbox = hookTestEnv.sandbox
|
||||
})
|
||||
|
||||
beforeEach(function () {
|
||||
if (process.platform === "win32") {
|
||||
this.timeout(WINDOWS_TEST_TIMEOUT_MS)
|
||||
}
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await hookTestEnv.cleanup()
|
||||
})
|
||||
@@ -419,8 +426,10 @@ console.log(JSON.stringify({ cancel: false }))`
|
||||
const hookPath = path.join(tempDir, ".clinerules", "hooks", "PreToolUse")
|
||||
const hookScript = `#!/usr/bin/env node
|
||||
const input = JSON.parse(require('fs').readFileSync(0, 'utf-8'));
|
||||
const hasConcreteModelContext = input.model?.provider === 'openai' && input.model?.slug === 'gpt-5';
|
||||
const hasAllFields = input.clineVersion && input.hookName && input.timestamp &&
|
||||
input.taskId && input.workspaceRoots !== undefined;
|
||||
input.taskId && input.workspaceRoots !== undefined &&
|
||||
hasConcreteModelContext;
|
||||
console.log(JSON.stringify({
|
||||
cancel: false,
|
||||
contextModification: hasAllFields ? "All fields present" : "Missing fields"
|
||||
@@ -433,6 +442,10 @@ console.log(JSON.stringify({
|
||||
|
||||
const result = await runner.run({
|
||||
taskId: "test-task",
|
||||
model: {
|
||||
provider: "openai",
|
||||
slug: "gpt-5",
|
||||
},
|
||||
preToolUse: {
|
||||
toolName: "test_tool",
|
||||
parameters: { key: "value" },
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { describe, it } from "mocha"
|
||||
import "should"
|
||||
import { getHookModelContext } from "../hook-model-context"
|
||||
|
||||
describe("getHookModelContext", () => {
|
||||
it("should return concrete provider and model slug for plan mode", () => {
|
||||
const api = {
|
||||
getModel: () => ({ id: "handler-model-id" }),
|
||||
} as any
|
||||
|
||||
const stateManager = {
|
||||
getGlobalSettingsKey: (key: string) => (key === "mode" ? "plan" : undefined),
|
||||
getApiConfiguration: () => ({
|
||||
planModeApiProvider: "openrouter",
|
||||
planModeOpenRouterModelId: "anthropic/claude-sonnet-4.5",
|
||||
actModeApiProvider: "openai",
|
||||
}),
|
||||
} as any
|
||||
|
||||
const context = getHookModelContext(api, stateManager)
|
||||
context.provider?.should.equal("openrouter")
|
||||
context.slug?.should.equal("anthropic/claude-sonnet-4.5")
|
||||
})
|
||||
|
||||
it("should return concrete provider and model slug for act mode", () => {
|
||||
const api = {
|
||||
getModel: () => ({ id: "handler-act-model" }),
|
||||
} as any
|
||||
|
||||
const stateManager = {
|
||||
getGlobalSettingsKey: (key: string) => (key === "mode" ? "act" : undefined),
|
||||
getApiConfiguration: () => ({
|
||||
planModeApiProvider: "openrouter",
|
||||
actModeApiProvider: "openai",
|
||||
actModeOpenAiModelId: "gpt-5",
|
||||
}),
|
||||
} as any
|
||||
|
||||
const context = getHookModelContext(api, stateManager)
|
||||
context.provider?.should.equal("openai")
|
||||
context.slug?.should.equal("gpt-5")
|
||||
})
|
||||
|
||||
it("should fall back to unknown values when provider/slug are unavailable", () => {
|
||||
const api = {
|
||||
getModel: () => ({ id: "" }),
|
||||
} as any
|
||||
|
||||
const stateManager = {
|
||||
getGlobalSettingsKey: (_: string) => "act",
|
||||
getApiConfiguration: () => ({
|
||||
planModeApiProvider: "anthropic",
|
||||
actModeApiProvider: "",
|
||||
}),
|
||||
} as any
|
||||
|
||||
const context = getHookModelContext(api, stateManager)
|
||||
context.provider?.should.equal("unknown")
|
||||
context.slug?.should.equal("unknown")
|
||||
})
|
||||
})
|
||||
@@ -102,7 +102,8 @@ console.log(JSON.stringify({
|
||||
const input = JSON.parse(require('fs').readFileSync(0, 'utf-8'));
|
||||
const hasAllFields = input.clineVersion && input.hookName === 'TaskCancel' &&
|
||||
input.timestamp && input.taskId &&
|
||||
input.workspaceRoots !== undefined;
|
||||
input.workspaceRoots !== undefined &&
|
||||
input.model && input.model.provider && input.model.slug;
|
||||
// Exit with error if fields are missing (for test verification)
|
||||
if (!hasAllFields) {
|
||||
process.exit(1);
|
||||
|
||||
@@ -101,7 +101,8 @@ console.log(JSON.stringify({
|
||||
const input = JSON.parse(require('fs').readFileSync(0, 'utf-8'));
|
||||
const hasAllFields = input.clineVersion && input.hookName === 'TaskComplete' &&
|
||||
input.timestamp && input.taskId &&
|
||||
input.workspaceRoots !== undefined;
|
||||
input.workspaceRoots !== undefined &&
|
||||
input.model && input.model.provider && input.model.slug;
|
||||
console.log(JSON.stringify({
|
||||
cancel: false,
|
||||
contextModification: hasAllFields ? "All fields present" : "Missing fields",
|
||||
|
||||
@@ -71,7 +71,8 @@ console.log(JSON.stringify({
|
||||
const hookScript = `#!/usr/bin/env node
|
||||
const input = JSON.parse(require('fs').readFileSync(0, 'utf-8'));
|
||||
const hasAllFields = input.clineVersion && input.hookName && input.timestamp &&
|
||||
input.taskId && input.workspaceRoots !== undefined;
|
||||
input.taskId && input.workspaceRoots !== undefined &&
|
||||
input.model && input.model.provider && input.model.slug;
|
||||
console.log(JSON.stringify({
|
||||
cancel: false,
|
||||
contextModification: hasAllFields ? "All fields present" : "Missing fields"
|
||||
|
||||
@@ -67,7 +67,8 @@ console.log(JSON.stringify({
|
||||
const input = JSON.parse(require('fs').readFileSync(0, 'utf-8'));
|
||||
const hasAllFields = input.clineVersion && input.hookName === 'TaskStart' &&
|
||||
input.timestamp && input.taskId &&
|
||||
input.workspaceRoots !== undefined;
|
||||
input.workspaceRoots !== undefined &&
|
||||
input.model && input.model.provider && input.model.slug;
|
||||
console.log(JSON.stringify({
|
||||
cancel: false,
|
||||
contextModification: hasAllFields ? "All fields present" : "Missing fields",
|
||||
|
||||
@@ -3,10 +3,10 @@ import * as os from "os"
|
||||
import * as path from "path"
|
||||
import should from "should"
|
||||
import sinon from "sinon"
|
||||
import { StateManager } from "../../storage/StateManager"
|
||||
import * as diskModule from "../../storage/disk"
|
||||
import { HookDiscoveryCache } from "../HookDiscoveryCache"
|
||||
import { HookOutput } from "../../../shared/proto/cline/hooks"
|
||||
import * as diskModule from "../../storage/disk"
|
||||
import { StateManager } from "../../storage/StateManager"
|
||||
import { HookDiscoveryCache } from "../HookDiscoveryCache"
|
||||
import { Hooks, NamedHookInput } from "../hook-factory"
|
||||
|
||||
// Define HookName locally since it's not exported from hook-factory
|
||||
@@ -19,6 +19,25 @@ export type HookTestEnv = {
|
||||
cleanup: () => Promise<void>
|
||||
}
|
||||
|
||||
async function removeTempDirWithRetry(tempDir: string): Promise<void> {
|
||||
const maxAttempts = process.platform === "win32" ? 5 : 1
|
||||
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
||||
try {
|
||||
await fs.rm(tempDir, { recursive: true, force: true })
|
||||
return
|
||||
} catch (error) {
|
||||
const nodeError = error as NodeJS.ErrnoException
|
||||
const isRetryableWindowsLock = process.platform === "win32" && nodeError?.code === "EBUSY"
|
||||
if (!isRetryableWindowsLock || attempt === maxAttempts) {
|
||||
throw error
|
||||
}
|
||||
|
||||
// Give Windows a brief moment to release file handles from child processes.
|
||||
await new Promise((resolve) => setTimeout(resolve, 100 * attempt))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function resetHookCache(): void {
|
||||
HookDiscoveryCache.resetForTesting()
|
||||
}
|
||||
@@ -71,7 +90,7 @@ export async function createHookTestEnv(): Promise<HookTestEnv> {
|
||||
cleanup: async () => {
|
||||
sandbox.restore()
|
||||
resetHookCache()
|
||||
await fs.rm(tempDir, { recursive: true, force: true })
|
||||
await removeTempDirWithRetry(tempDir)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -114,7 +114,8 @@ console.log(JSON.stringify({
|
||||
const hookScript = `#!/usr/bin/env node
|
||||
const input = JSON.parse(require('fs').readFileSync(0, 'utf-8'));
|
||||
const hasAllFields = input.clineVersion && input.hookName && input.timestamp &&
|
||||
input.taskId && input.workspaceRoots !== undefined;
|
||||
input.taskId && input.workspaceRoots !== undefined &&
|
||||
input.model && input.model.provider && input.model.slug;
|
||||
console.log(JSON.stringify({
|
||||
cancel: false,
|
||||
contextModification: hasAllFields ? "All fields present" : "Missing fields"
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { HookOutput } from "@shared/proto/cline/hooks"
|
||||
import { Logger } from "@/shared/services/Logger"
|
||||
import { MessageStateHandler } from "../task/message-state"
|
||||
import { HookExecutionError } from "./HookError"
|
||||
import type { HookModelInputContext } from "./hook-factory"
|
||||
import { HookFactory } from "./hook-factory"
|
||||
|
||||
export interface HookExecutionOptions<Name extends keyof Hooks = any> {
|
||||
@@ -21,6 +22,7 @@ export interface HookExecutionOptions<Name extends keyof Hooks = any> {
|
||||
messageStateHandler: MessageStateHandler
|
||||
taskId: string
|
||||
hooksEnabled: boolean
|
||||
model?: HookModelInputContext
|
||||
toolName?: string // Optional tool name for PreToolUse/PostToolUse hooks
|
||||
pendingToolInfo?: any // Optional metadata about pending tool execution for PreToolUse
|
||||
}
|
||||
@@ -150,6 +152,7 @@ export async function executeHook<Name extends keyof Hooks>(options: HookExecuti
|
||||
const result = await hook.run({
|
||||
taskId,
|
||||
...hookInput,
|
||||
model: options.model,
|
||||
})
|
||||
|
||||
Logger.log(`[${hookName} Hook]`, result)
|
||||
|
||||
@@ -6,6 +6,7 @@ import { getDistinctId } from "../../services/logging/distinctId"
|
||||
import { telemetryService } from "../../services/telemetry"
|
||||
import {
|
||||
HookInput,
|
||||
HookModelContext,
|
||||
HookOutput,
|
||||
PostToolUseData,
|
||||
PreCompactData,
|
||||
@@ -124,6 +125,11 @@ export interface Hooks {
|
||||
}
|
||||
}
|
||||
|
||||
export interface HookModelInputContext {
|
||||
provider?: string
|
||||
slug?: string
|
||||
}
|
||||
|
||||
// The names of all supported hooks. Hooks[N] is the type of data the hook takes as input.
|
||||
type HookName = keyof Hooks
|
||||
|
||||
@@ -134,6 +140,7 @@ type HookName = keyof Hooks
|
||||
*/
|
||||
export type NamedHookInput<Name extends HookName> = {
|
||||
taskId: string
|
||||
model?: HookModelInputContext
|
||||
} & Hooks[Name]
|
||||
|
||||
// We look up HookRunner.exec via symbol so that the combined hook runner can call
|
||||
@@ -189,6 +196,12 @@ export abstract class HookRunner<Name extends HookName> {
|
||||
StateManager.get()
|
||||
.getGlobalStateKey("workspaceRoots")
|
||||
?.map((root) => root.path) || []
|
||||
|
||||
const model: HookModelContext = {
|
||||
provider: params.model?.provider?.trim() || "unknown",
|
||||
slug: params.model?.slug?.trim() || "unknown",
|
||||
}
|
||||
|
||||
return {
|
||||
clineVersion,
|
||||
hookName: this.hookName,
|
||||
@@ -196,6 +209,7 @@ export abstract class HookRunner<Name extends HookName> {
|
||||
workspaceRoots,
|
||||
userId: getDistinctId(), // Always available: Cline User ID, machine ID, or generated UUID
|
||||
...params,
|
||||
model,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { ApiHandler } from "@core/api"
|
||||
import type { StateManager } from "@core/storage/StateManager"
|
||||
import type { ApiProvider } from "@shared/api"
|
||||
import { getProviderModelIdKey } from "@shared/storage/provider-keys"
|
||||
import type { HookModelInputContext } from "./hook-factory"
|
||||
|
||||
export type ResolvedHookModelContext = Required<HookModelInputContext>
|
||||
|
||||
/**
|
||||
* Resolve the active provider/model pair used for hook payload metadata.
|
||||
*/
|
||||
export function getHookModelContext(api: ApiHandler, stateManager: StateManager): ResolvedHookModelContext {
|
||||
const mode = stateManager.getGlobalSettingsKey("mode")
|
||||
const resolvedMode = mode === "plan" ? "plan" : "act"
|
||||
const apiConfig = stateManager.getApiConfiguration()
|
||||
// `api` is expected to represent the handler for the currently resolved mode.
|
||||
// We still resolve provider/model from state config for deterministic hook metadata,
|
||||
// then fall back to the active handler model id if config is unavailable.
|
||||
const provider = (resolvedMode === "plan" ? apiConfig.planModeApiProvider : apiConfig.actModeApiProvider) as
|
||||
| ApiProvider
|
||||
| undefined
|
||||
|
||||
const genericModelKey = `${resolvedMode}ModeApiModelId`
|
||||
const providerModelKey = provider ? getProviderModelIdKey(provider, resolvedMode) : undefined
|
||||
const configRecord = apiConfig as Record<string, unknown>
|
||||
const providerModelSlug = providerModelKey ? (configRecord[providerModelKey] as string | undefined) : undefined
|
||||
|
||||
// Only read the generic fallback key when it differs from the provider key.
|
||||
// Some providers (e.g. anthropic/gemini/bedrock) intentionally map directly to the generic key,
|
||||
// so a second lookup would be redundant and add noise to fallback semantics.
|
||||
const genericModelSlug =
|
||||
providerModelKey !== genericModelKey ? (configRecord[genericModelKey] as string | undefined) : undefined
|
||||
const activeHandlerModelSlug = api.getModel().id
|
||||
const slug = providerModelSlug || genericModelSlug || activeHandlerModelSlug
|
||||
|
||||
return {
|
||||
provider: provider || "unknown",
|
||||
slug: slug || "unknown",
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import type { ClineStorageMessage } from "@shared/messages/content"
|
||||
import { Logger } from "@/shared/services/Logger"
|
||||
import type { ContextManager } from "../context/context-management/ContextManager"
|
||||
import type { MessageStateHandler } from "../task/message-state"
|
||||
import type { HookModelInputContext } from "./hook-factory"
|
||||
|
||||
/**
|
||||
* Active hook execution state
|
||||
@@ -119,6 +120,8 @@ export interface PreCompactHookParams {
|
||||
taskId: string
|
||||
/** ULID for telemetry */
|
||||
ulid: string
|
||||
/** Active hook model context */
|
||||
modelContext: HookModelInputContext
|
||||
|
||||
// Conversation state
|
||||
/** API conversation history */
|
||||
@@ -241,6 +244,7 @@ export async function executePreCompactHookWithCleanup(params: PreCompactHookPar
|
||||
messageStateHandler: params.messageStateHandler,
|
||||
taskId: params.taskId,
|
||||
hooksEnabled: params.hooksEnabled,
|
||||
model: params.modelContext,
|
||||
})
|
||||
|
||||
// Handle cancellation from hook
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { Logger } from "@/shared/services/Logger"
|
||||
|
||||
const DEFAULT_CHAR_THRESHOLD = 15_000
|
||||
const DEFAULT_TIME_THRESHOLD_MS = 60_000
|
||||
|
||||
/**
|
||||
* Detects in-generation text loops where a model produces excessive text
|
||||
* without ever emitting a tool call. Tracks both character count and elapsed
|
||||
* time since the last tool-related activity — aborts only when both thresholds
|
||||
* are exceeded to avoid false positives.
|
||||
*/
|
||||
export class InGenerationLoopDetector {
|
||||
private lastToolActivityTime: number
|
||||
private textLengthSinceLastTool = 0
|
||||
|
||||
constructor(
|
||||
private readonly charThreshold = DEFAULT_CHAR_THRESHOLD,
|
||||
private readonly timeThresholdMs = DEFAULT_TIME_THRESHOLD_MS,
|
||||
private readonly now: () => number = Date.now,
|
||||
) {
|
||||
this.lastToolActivityTime = this.now()
|
||||
}
|
||||
|
||||
onToolActivity(): void {
|
||||
this.lastToolActivityTime = this.now()
|
||||
this.textLengthSinceLastTool = 0
|
||||
}
|
||||
|
||||
/** Reset the timer without clearing the char count — reasoning tokens aren't text, but shouldn't count toward elapsed time. */
|
||||
onReasoningActivity(): void {
|
||||
this.lastToolActivityTime = this.now()
|
||||
}
|
||||
|
||||
onTextChunk(chunkLength: number): void {
|
||||
this.textLengthSinceLastTool += chunkLength
|
||||
}
|
||||
|
||||
isLooping(): boolean {
|
||||
const elapsed = this.now() - this.lastToolActivityTime
|
||||
if (this.textLengthSinceLastTool > this.charThreshold && elapsed > this.timeThresholdMs) {
|
||||
Logger.info(
|
||||
`[LoopDetection] Aborting stream: ${this.textLengthSinceLastTool} chars of text without tool activity in ${Math.round(elapsed / 1000)}s`,
|
||||
)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { ApiHandler } from "@core/api"
|
||||
import { FileContextTracker } from "@core/context/context-tracking/FileContextTracker"
|
||||
import { getHookModelContext } from "@core/hooks/hook-model-context"
|
||||
import { getHooksEnabledSafe } from "@core/hooks/hooks-utils"
|
||||
import { ClineIgnoreController } from "@core/ignore/ClineIgnoreController"
|
||||
import { CommandPermissionController } from "@core/permissions"
|
||||
@@ -483,6 +484,7 @@ export class ToolExecutor {
|
||||
messageStateHandler: this.messageStateHandler,
|
||||
taskId: this.taskId,
|
||||
hooksEnabled: true, // Already checked by caller
|
||||
model: getHookModelContext(this.api, this.stateManager),
|
||||
toolName: block.name,
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
import { describe, it } from "mocha"
|
||||
import "should"
|
||||
import { InGenerationLoopDetector } from "../InGenerationLoopDetector"
|
||||
|
||||
describe("InGenerationLoopDetector", () => {
|
||||
function createDetector(opts: { charThreshold?: number; timeThresholdMs?: number; startTime?: number } = {}) {
|
||||
let currentTime = opts.startTime ?? 0
|
||||
const now = () => currentTime
|
||||
const advance = (ms: number) => {
|
||||
currentTime += ms
|
||||
}
|
||||
const detector = new InGenerationLoopDetector(opts.charThreshold ?? 15_000, opts.timeThresholdMs ?? 60_000, now)
|
||||
return { detector, advance }
|
||||
}
|
||||
|
||||
it("should not trigger when under both thresholds", () => {
|
||||
const { detector, advance } = createDetector()
|
||||
detector.onTextChunk(5_000)
|
||||
advance(30_000)
|
||||
detector.isLooping().should.be.false()
|
||||
})
|
||||
|
||||
it("should not trigger when only char threshold is exceeded", () => {
|
||||
const { detector, advance } = createDetector()
|
||||
detector.onTextChunk(20_000)
|
||||
advance(30_000) // under 60s
|
||||
detector.isLooping().should.be.false()
|
||||
})
|
||||
|
||||
it("should not trigger when only time threshold is exceeded", () => {
|
||||
const { detector, advance } = createDetector()
|
||||
detector.onTextChunk(5_000) // under 15K
|
||||
advance(90_000)
|
||||
detector.isLooping().should.be.false()
|
||||
})
|
||||
|
||||
it("should trigger when both thresholds are exceeded", () => {
|
||||
const { detector, advance } = createDetector()
|
||||
detector.onTextChunk(16_000)
|
||||
advance(61_000)
|
||||
detector.isLooping().should.be.true()
|
||||
})
|
||||
|
||||
it("should accumulate text across multiple chunks", () => {
|
||||
const { detector, advance } = createDetector()
|
||||
for (let i = 0; i < 20; i++) {
|
||||
detector.onTextChunk(1_000) // 20 × 1K = 20K total
|
||||
}
|
||||
advance(61_000)
|
||||
detector.isLooping().should.be.true()
|
||||
})
|
||||
|
||||
it("should reset on tool activity", () => {
|
||||
const { detector, advance } = createDetector()
|
||||
detector.onTextChunk(16_000)
|
||||
advance(61_000)
|
||||
// Would trigger, but tool activity resets everything
|
||||
detector.onToolActivity()
|
||||
detector.isLooping().should.be.false()
|
||||
})
|
||||
|
||||
it("should reset char count on tool activity but re-accumulate after", () => {
|
||||
const { detector, advance } = createDetector()
|
||||
detector.onTextChunk(16_000)
|
||||
advance(30_000)
|
||||
detector.onToolActivity() // resets both trackers
|
||||
advance(61_000)
|
||||
detector.onTextChunk(5_000) // only 5K since reset
|
||||
detector.isLooping().should.be.false()
|
||||
})
|
||||
|
||||
it("should trigger after tool activity if new text exceeds thresholds", () => {
|
||||
const { detector, advance } = createDetector()
|
||||
detector.onTextChunk(16_000)
|
||||
detector.onToolActivity() // resets
|
||||
advance(61_000)
|
||||
detector.onTextChunk(16_000) // new text exceeds threshold
|
||||
detector.isLooping().should.be.true()
|
||||
})
|
||||
|
||||
it("should work with custom thresholds", () => {
|
||||
const { detector, advance } = createDetector({
|
||||
charThreshold: 100,
|
||||
timeThresholdMs: 1_000,
|
||||
})
|
||||
detector.onTextChunk(101)
|
||||
advance(1_001)
|
||||
detector.isLooping().should.be.true()
|
||||
})
|
||||
|
||||
it("should reset timer on reasoning activity without clearing char count", () => {
|
||||
const { detector, advance } = createDetector()
|
||||
detector.onTextChunk(16_000)
|
||||
advance(90_000) // 90s of reasoning
|
||||
detector.onReasoningActivity() // resets timer but keeps 16K chars
|
||||
advance(30_000) // only 30s since reasoning reset
|
||||
detector.isLooping().should.be.false()
|
||||
})
|
||||
|
||||
it("should trigger after reasoning if text continues long enough", () => {
|
||||
const { detector, advance } = createDetector()
|
||||
advance(90_000) // 90s of reasoning
|
||||
detector.onReasoningActivity()
|
||||
detector.onTextChunk(16_000)
|
||||
advance(61_000) // 61s since reasoning ended
|
||||
detector.isLooping().should.be.true()
|
||||
})
|
||||
|
||||
it("should not trigger at exact boundary values", () => {
|
||||
const { detector, advance } = createDetector({
|
||||
charThreshold: 100,
|
||||
timeThresholdMs: 1_000,
|
||||
})
|
||||
detector.onTextChunk(100) // exactly at, not over
|
||||
advance(1_000) // exactly at, not over
|
||||
detector.isLooping().should.be.false()
|
||||
})
|
||||
})
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
refreshExternalRulesToggles,
|
||||
} from "@core/context/instructions/user-instructions/external-rules"
|
||||
import { sendPartialMessageEvent } from "@core/controller/ui/subscribeToPartialMessage"
|
||||
import { getHookModelContext } from "@core/hooks/hook-model-context"
|
||||
import { getHooksEnabledSafe } from "@core/hooks/hooks-utils"
|
||||
import { executePreCompactHookWithCleanup, HookCancellationError, HookExecution } from "@core/hooks/precompact-executor"
|
||||
import { ClineIgnoreController } from "@core/ignore/ClineIgnoreController"
|
||||
@@ -113,6 +114,7 @@ import { Controller } from "../controller"
|
||||
import { executeHook } from "../hooks/hook-executor"
|
||||
import { StateManager } from "../storage/StateManager"
|
||||
import { FocusChainManager } from "./focus-chain"
|
||||
import { InGenerationLoopDetector } from "./InGenerationLoopDetector"
|
||||
import { MessageStateHandler } from "./message-state"
|
||||
import { StreamChunkCoordinator } from "./StreamChunkCoordinator"
|
||||
import { StreamResponseHandler } from "./StreamResponseHandler"
|
||||
@@ -918,6 +920,7 @@ export class Task {
|
||||
messageStateHandler: this.messageStateHandler,
|
||||
taskId: this.taskId,
|
||||
hooksEnabled,
|
||||
model: getHookModelContext(this.api, this.stateManager),
|
||||
})
|
||||
|
||||
// Handle cancellation from hook
|
||||
@@ -998,6 +1001,7 @@ export class Task {
|
||||
messageStateHandler: this.messageStateHandler,
|
||||
taskId: this.taskId,
|
||||
hooksEnabled,
|
||||
model: getHookModelContext(this.api, this.stateManager),
|
||||
})
|
||||
|
||||
// Handle cancellation from hook
|
||||
@@ -1151,6 +1155,7 @@ export class Task {
|
||||
messageStateHandler: this.messageStateHandler,
|
||||
taskId: this.taskId,
|
||||
hooksEnabled,
|
||||
model: getHookModelContext(this.api, this.stateManager),
|
||||
})
|
||||
|
||||
// Handle cancellation from hook
|
||||
@@ -1470,6 +1475,7 @@ export class Task {
|
||||
messageStateHandler: this.messageStateHandler,
|
||||
taskId: this.taskId,
|
||||
hooksEnabled,
|
||||
model: getHookModelContext(this.api, this.stateManager),
|
||||
})
|
||||
|
||||
// TaskCancel completed successfully
|
||||
@@ -1692,6 +1698,7 @@ export class Task {
|
||||
await executePreCompactHookWithCleanup({
|
||||
taskId: this.taskId,
|
||||
ulid: this.ulid,
|
||||
modelContext: getHookModelContext(this.api, this.stateManager),
|
||||
apiConversationHistory,
|
||||
conversationHistoryDeletedRange: this.taskState.conversationHistoryDeletedRange,
|
||||
contextManager: this.contextManager,
|
||||
@@ -2694,6 +2701,7 @@ export class Task {
|
||||
})
|
||||
|
||||
let shouldInterruptStream = false
|
||||
const loopDetector = new InGenerationLoopDetector()
|
||||
|
||||
while (true) {
|
||||
const chunk = await streamCoordinator.nextChunk()
|
||||
@@ -2717,6 +2725,8 @@ export class Task {
|
||||
redacted_data: chunk.redacted_data,
|
||||
})
|
||||
|
||||
loopDetector.onReasoningActivity()
|
||||
|
||||
// fixes bug where cancelling task > aborts task > for loop may be in middle of streaming reasoning > say function throws error before we get a chance to properly clean up and cancel the task.
|
||||
if (!this.taskState.abort) {
|
||||
const thinkingBlock = reasonsHandler.getCurrentReasoning()
|
||||
@@ -2748,6 +2758,7 @@ export class Task {
|
||||
}
|
||||
|
||||
await this.processNativeToolCalls(assistantTextOnly, toolUseHandler.getPartialToolUsesAsContent())
|
||||
loopDetector.onToolActivity()
|
||||
break
|
||||
}
|
||||
case "text": {
|
||||
@@ -2775,6 +2786,11 @@ export class Task {
|
||||
if (this.taskState.assistantMessageContent.length > prevLength) {
|
||||
this.taskState.userMessageContentReady = false // new content we need to present, reset to false in case previous content set this to true
|
||||
}
|
||||
|
||||
loopDetector.onTextChunk(chunk.text.length)
|
||||
if (this.taskState.assistantMessageContent.some((block) => block.type === "tool_use")) {
|
||||
loopDetector.onToolActivity()
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
@@ -2812,6 +2828,19 @@ export class Task {
|
||||
shouldInterruptStream = true
|
||||
break
|
||||
}
|
||||
|
||||
if (loopDetector.isLooping()) {
|
||||
const truncated = assistantMessage.slice(0, 500)
|
||||
assistantMessage =
|
||||
truncated + "\n\n[Response interrupted: excessive text output without tool use detected]"
|
||||
assistantTextOnly =
|
||||
assistantTextOnly.slice(0, 500) +
|
||||
"\n\n[Response interrupted: excessive text output without tool use detected]"
|
||||
this.taskState.assistantMessageContent = parseAssistantMessageV2(assistantMessage)
|
||||
this.api.abort?.()
|
||||
shouldInterruptStream = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (shouldInterruptStream) {
|
||||
|
||||
@@ -5,6 +5,7 @@ import { processFilesIntoText } from "@integrations/misc/extract-text"
|
||||
import type { ClineSayTool } from "@shared/ExtensionMessage"
|
||||
import { fileExistsAtPath } from "@utils/fs"
|
||||
import { getReadablePath, isLocatedInWorkspace } from "@utils/path"
|
||||
import { applyPatch } from "diff"
|
||||
import { telemetryService } from "@/services/telemetry"
|
||||
import { BASH_WRAPPERS, DiffError, PATCH_MARKERS, type Patch, PatchActionType, type PatchChunk } from "@/shared/Patch"
|
||||
import { preserveEscaping } from "@/shared/string"
|
||||
@@ -15,6 +16,7 @@ import type { IFullyManagedTool } from "../ToolExecutorCoordinator"
|
||||
import type { ToolValidator } from "../ToolValidator"
|
||||
import type { TaskConfig } from "../types/TaskConfig"
|
||||
import type { StronglyTypedUIHelpers } from "../types/UIHelpers"
|
||||
import { captureAccepted, captureRejected, getModelInfo } from "../utils/AiOutputTelemetry"
|
||||
import { type FileOpsResult, FileProviderOperations } from "../utils/FileProviderOperations"
|
||||
import { PatchParser } from "../utils/PatchParser"
|
||||
import { PathResolver } from "../utils/PathResolver"
|
||||
@@ -297,7 +299,7 @@ export class ApplyPatchHandler implements IFullyManagedTool {
|
||||
await this.prepareFileChange(change, operationPath)
|
||||
|
||||
// Get approval
|
||||
const approved = await this.handleApproval(config, block, message, rawInput)
|
||||
const approved = await this.handleApproval(config, block, message, rawInput, change)
|
||||
if (!approved) {
|
||||
this.config = undefined
|
||||
config.taskState.didRejectTool = true
|
||||
@@ -337,6 +339,9 @@ export class ApplyPatchHandler implements IFullyManagedTool {
|
||||
|
||||
this.config = undefined
|
||||
|
||||
// Extract provider info for human edit telemetry
|
||||
const { providerId, modelId } = getModelInfo(config)
|
||||
|
||||
// Build response with file contents and diagnostics
|
||||
const responseLines = ["Successfully applied patch to the following files:"]
|
||||
|
||||
@@ -357,6 +362,20 @@ export class ApplyPatchHandler implements IFullyManagedTool {
|
||||
diff: result.userEdits,
|
||||
}),
|
||||
)
|
||||
|
||||
// Capture human edit telemetry: diff between agent's proposed content and user's pre-save edits
|
||||
// Use applyPatch to reconstruct pre-save content from userEdits, excluding auto-formatting noise
|
||||
const change = commit.changes[path] || Object.values(commit.changes).find((c) => c.movePath === path)
|
||||
const preSaveContent = result.userEdits ? applyPatch(change?.newContent || "", result.userEdits) : false
|
||||
captureAccepted({
|
||||
ulid: config.ulid,
|
||||
tool: this.name,
|
||||
source: "human",
|
||||
beforeContent: change?.newContent || "",
|
||||
afterContent: preSaveContent || result.finalContent || "",
|
||||
providerId,
|
||||
modelId,
|
||||
})
|
||||
}
|
||||
if (result.autoFormattingEdits) {
|
||||
responseLines.push(`\nAuto-formatting was applied to ${path}:\n${result.autoFormattingEdits}\n`)
|
||||
@@ -687,16 +706,28 @@ export class ApplyPatchHandler implements IFullyManagedTool {
|
||||
return summaries
|
||||
}
|
||||
|
||||
private async handleApproval(config: TaskConfig, block: ToolUse, message: ClineSayTool, rawInput: string): Promise<boolean> {
|
||||
private async handleApproval(
|
||||
config: TaskConfig,
|
||||
block: ToolUse,
|
||||
message: ClineSayTool,
|
||||
rawInput: string,
|
||||
change?: FileChange,
|
||||
): Promise<boolean> {
|
||||
const patch = { ...message, content: rawInput }
|
||||
const completeMessage = JSON.stringify(patch)
|
||||
const shouldAutoApprove = await config.callbacks.shouldAutoApproveToolWithPath(block.name, message.path)
|
||||
|
||||
// Extract provider using the proven pattern from ReportBugHandler
|
||||
const apiConfig = config.services.stateManager.getApiConfiguration()
|
||||
const currentMode = config.services.stateManager.getGlobalSettingsKey("mode")
|
||||
const providerId = (currentMode === "plan" ? apiConfig.planModeApiProvider : apiConfig.actModeApiProvider) as string
|
||||
const modelId = config.api.getModel().id
|
||||
// Extract provider info for telemetry
|
||||
const { providerId, modelId } = getModelInfo(config)
|
||||
|
||||
// Determine file-level operation counts from the change type
|
||||
const fileOps = change
|
||||
? {
|
||||
filesCreated: change.type === PatchActionType.ADD ? 1 : 0,
|
||||
filesDeleted: change.type === PatchActionType.DELETE ? 1 : 0,
|
||||
filesMoved: change.type === PatchActionType.UPDATE && change.movePath ? 1 : 0,
|
||||
}
|
||||
: { filesCreated: 0, filesDeleted: 0, filesMoved: 0 }
|
||||
|
||||
if (shouldAutoApprove) {
|
||||
await config.callbacks.removeLastPartialMessageIfExistsWithType("ask", "tool")
|
||||
@@ -711,6 +742,16 @@ export class ApplyPatchHandler implements IFullyManagedTool {
|
||||
undefined,
|
||||
block.isNativeToolCall,
|
||||
)
|
||||
captureAccepted({
|
||||
ulid: config.ulid,
|
||||
tool: this.name,
|
||||
source: "agent",
|
||||
beforeContent: change?.oldContent || "",
|
||||
afterContent: change?.newContent || "",
|
||||
providerId,
|
||||
modelId,
|
||||
...fileOps,
|
||||
})
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -738,6 +779,30 @@ export class ApplyPatchHandler implements IFullyManagedTool {
|
||||
block.isNativeToolCall,
|
||||
)
|
||||
|
||||
if (approved) {
|
||||
captureAccepted({
|
||||
ulid: config.ulid,
|
||||
tool: this.name,
|
||||
source: "agent",
|
||||
beforeContent: change?.oldContent || "",
|
||||
afterContent: change?.newContent || "",
|
||||
providerId,
|
||||
modelId,
|
||||
...fileOps,
|
||||
})
|
||||
} else {
|
||||
captureRejected({
|
||||
ulid: config.ulid,
|
||||
tool: this.name,
|
||||
source: "agent",
|
||||
beforeContent: change?.oldContent || "",
|
||||
afterContent: change?.newContent || "",
|
||||
providerId,
|
||||
modelId,
|
||||
...fileOps,
|
||||
})
|
||||
}
|
||||
|
||||
return approved
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type Anthropic from "@anthropic-ai/sdk"
|
||||
import type { ToolUse } from "@core/assistant-message"
|
||||
import { getHookModelContext } from "@core/hooks/hook-model-context"
|
||||
import { getHooksEnabledSafe } from "@core/hooks/hooks-utils"
|
||||
import { formatResponse } from "@core/prompts/responses"
|
||||
import { processFilesIntoText } from "@integrations/misc/extract-text"
|
||||
@@ -328,6 +329,7 @@ export class AttemptCompletionHandler implements IToolHandler, IPartialBlockHand
|
||||
messageStateHandler: config.messageState,
|
||||
taskId: config.taskId,
|
||||
hooksEnabled,
|
||||
model: getHookModelContext(config.api, config.services.stateManager),
|
||||
})
|
||||
} catch (error) {
|
||||
// TaskComplete hook failed - non-fatal, just log
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ToolUse } from "@core/assistant-message"
|
||||
import { getHookModelContext } from "@core/hooks/hook-model-context"
|
||||
import { getHooksEnabledSafe } from "@core/hooks/hooks-utils"
|
||||
import { executePreCompactHookWithCleanup, HookCancellationError } from "@core/hooks/precompact-executor"
|
||||
import { continuationPrompt } from "@core/prompts/contextManagement"
|
||||
@@ -54,6 +55,7 @@ export class SummarizeTaskHandler implements IToolHandler, IPartialBlockHandler
|
||||
const result = await executePreCompactHookWithCleanup({
|
||||
taskId: config.taskId,
|
||||
ulid: config.ulid,
|
||||
modelContext: getHookModelContext(config.api, config.services.stateManager),
|
||||
apiConversationHistory: apiHistory,
|
||||
conversationHistoryDeletedRange: config.taskState.conversationHistoryDeletedRange,
|
||||
contextManager: config.services.contextManager,
|
||||
|
||||
@@ -9,6 +9,7 @@ import { ClineSayTool } from "@shared/ExtensionMessage"
|
||||
import { getLastApiReqTotalTokens } from "@shared/getApiMetrics"
|
||||
import { fileExistsAtPath } from "@utils/fs"
|
||||
import { arePathsEqual, getReadablePath, isLocatedInWorkspace } from "@utils/path"
|
||||
import { applyPatch } from "diff"
|
||||
import { telemetryService } from "@/services/telemetry"
|
||||
import { ClineDefaultTool } from "@/shared/tools"
|
||||
import type { ToolResponse } from "../../index"
|
||||
@@ -17,6 +18,7 @@ import type { IFullyManagedTool } from "../ToolExecutorCoordinator"
|
||||
import type { ToolValidator } from "../ToolValidator"
|
||||
import type { TaskConfig } from "../types/TaskConfig"
|
||||
import type { StronglyTypedUIHelpers } from "../types/UIHelpers"
|
||||
import { captureAccepted, captureRejected, getModelInfo } from "../utils/AiOutputTelemetry"
|
||||
import { applyModelContentFixes } from "../utils/ModelContentProcessor"
|
||||
import { ToolDisplayUtils } from "../utils/ToolDisplayUtils"
|
||||
import { ToolResultUtils } from "../utils/ToolResultUtils"
|
||||
@@ -97,7 +99,7 @@ export class WriteToFileToolHandler implements IFullyManagedTool {
|
||||
const rawDiff = block.params.diff // for replace_in_file
|
||||
|
||||
// Extract provider information for telemetry
|
||||
const { providerId, modelId } = this.getModelInfo(config)
|
||||
const { providerId, modelId } = getModelInfo(config)
|
||||
|
||||
// Validate required parameters based on tool type
|
||||
if (!rawRelPath) {
|
||||
@@ -211,6 +213,18 @@ export class WriteToFileToolHandler implements IFullyManagedTool {
|
||||
block.isNativeToolCall,
|
||||
)
|
||||
|
||||
// Capture AI output accepted telemetry with line diff stats
|
||||
captureAccepted({
|
||||
ulid: config.ulid,
|
||||
tool: block.name,
|
||||
source: "agent",
|
||||
beforeContent: config.services.diffViewProvider.originalContent || "",
|
||||
afterContent: newContent,
|
||||
providerId,
|
||||
modelId,
|
||||
filesCreated: fileExists ? 0 : 1,
|
||||
})
|
||||
|
||||
// we need an artificial delay to let the diagnostics catch up to the changes
|
||||
await setTimeoutPromise(3_500)
|
||||
} else {
|
||||
@@ -265,6 +279,18 @@ export class WriteToFileToolHandler implements IFullyManagedTool {
|
||||
block.isNativeToolCall,
|
||||
)
|
||||
|
||||
// Capture AI output rejected telemetry with line diff stats
|
||||
captureRejected({
|
||||
ulid: config.ulid,
|
||||
tool: block.name,
|
||||
source: "agent",
|
||||
beforeContent: config.services.diffViewProvider.originalContent || "",
|
||||
afterContent: newContent,
|
||||
providerId,
|
||||
modelId,
|
||||
filesCreated: fileExists ? 0 : 1,
|
||||
})
|
||||
|
||||
await config.services.diffViewProvider.revertChanges()
|
||||
return `The user denied this operation. ${fileDeniedNote}`
|
||||
}
|
||||
@@ -295,6 +321,18 @@ export class WriteToFileToolHandler implements IFullyManagedTool {
|
||||
workspaceContext,
|
||||
block.isNativeToolCall,
|
||||
)
|
||||
|
||||
// Capture AI output accepted telemetry with line diff stats (manual approval)
|
||||
captureAccepted({
|
||||
ulid: config.ulid,
|
||||
tool: block.name,
|
||||
source: "agent",
|
||||
beforeContent: config.services.diffViewProvider.originalContent || "",
|
||||
afterContent: newContent,
|
||||
providerId,
|
||||
modelId,
|
||||
filesCreated: fileExists ? 0 : 1,
|
||||
})
|
||||
}
|
||||
|
||||
// Run PreToolUse hook after approval but before execution
|
||||
@@ -340,6 +378,20 @@ export class WriteToFileToolHandler implements IFullyManagedTool {
|
||||
diff: userEdits,
|
||||
}),
|
||||
)
|
||||
|
||||
// Capture human edit telemetry: diff between agent's proposed content and user's pre-save edits
|
||||
// Use applyPatch to reconstruct pre-save content from userEdits, excluding auto-formatting noise
|
||||
const preSaveContent = applyPatch(newContent, userEdits)
|
||||
captureAccepted({
|
||||
ulid: config.ulid,
|
||||
tool: block.name,
|
||||
source: "human",
|
||||
beforeContent: newContent,
|
||||
afterContent: preSaveContent || finalContent || "",
|
||||
providerId,
|
||||
modelId,
|
||||
})
|
||||
|
||||
return formatResponse.fileEditWithUserChanges(
|
||||
relPath,
|
||||
userEdits,
|
||||
@@ -459,7 +511,7 @@ export class WriteToFileToolHandler implements IFullyManagedTool {
|
||||
await config.callbacks.say("diff_error", relPath, undefined, undefined, true)
|
||||
|
||||
// Extract provider information for telemetry
|
||||
const { providerId, modelId } = this.getModelInfo(config)
|
||||
const { providerId, modelId } = getModelInfo(config)
|
||||
|
||||
// Extract error type from error message if possible
|
||||
const errorType =
|
||||
@@ -517,13 +569,4 @@ export class WriteToFileToolHandler implements IFullyManagedTool {
|
||||
|
||||
return { relPath, absolutePath, fileExists, diff, content, newContent, workspaceContext, matchIndices }
|
||||
}
|
||||
|
||||
private getModelInfo(config: TaskConfig) {
|
||||
// Extract provider information for telemetry
|
||||
const apiConfig = config.services.stateManager.getApiConfiguration()
|
||||
const currentMode = config.services.stateManager.getGlobalSettingsKey("mode")
|
||||
const providerId = (currentMode === "plan" ? apiConfig.planModeApiProvider : apiConfig.actModeApiProvider) as string
|
||||
const modelId = config.api.getModel().id
|
||||
return { providerId, modelId }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { telemetryService } from "@/services/telemetry"
|
||||
import type { TaskConfig } from "../types/TaskConfig"
|
||||
import { computeLineDiffStats } from "./lineDiffStats"
|
||||
|
||||
/**
|
||||
* Shared utility for emitting AI output telemetry from file editing tools.
|
||||
* Centralizes the logic for capturing accepted/rejected edits with proper source attribution.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Extracts provider and model information from task config for telemetry.
|
||||
*/
|
||||
export function getModelInfo(config: TaskConfig): { providerId: string; modelId: string } {
|
||||
const apiConfig = config.services.stateManager.getApiConfiguration()
|
||||
const currentMode = config.services.stateManager.getGlobalSettingsKey("mode")
|
||||
const providerId = (currentMode === "plan" ? apiConfig.planModeApiProvider : apiConfig.actModeApiProvider) as string
|
||||
const modelId = config.api.getModel().id
|
||||
return { providerId, modelId }
|
||||
}
|
||||
|
||||
/**
|
||||
* Captures telemetry when a file edit is accepted.
|
||||
* Computes line diff stats and emits event with proper source attribution.
|
||||
*/
|
||||
export function captureAccepted(args: {
|
||||
ulid: string
|
||||
tool: string
|
||||
source: "agent" | "human"
|
||||
beforeContent: string
|
||||
afterContent: string
|
||||
providerId: string
|
||||
modelId: string
|
||||
filesCreated?: number
|
||||
filesDeleted?: number
|
||||
filesMoved?: number
|
||||
}): void {
|
||||
const diffStats = computeLineDiffStats(args.beforeContent, args.afterContent)
|
||||
telemetryService.captureAiOutputAccepted({
|
||||
ulid: args.ulid,
|
||||
tool: args.tool,
|
||||
provider: args.providerId,
|
||||
model: args.modelId,
|
||||
source: args.source,
|
||||
...diffStats,
|
||||
filesCreated: args.filesCreated,
|
||||
filesDeleted: args.filesDeleted,
|
||||
filesMoved: args.filesMoved,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Captures telemetry when a file edit is rejected.
|
||||
* Computes line diff stats and emits event with proper source attribution.
|
||||
*/
|
||||
export function captureRejected(args: {
|
||||
ulid: string
|
||||
tool: string
|
||||
source: "agent" | "human"
|
||||
beforeContent: string
|
||||
afterContent: string
|
||||
providerId: string
|
||||
modelId: string
|
||||
filesCreated?: number
|
||||
filesDeleted?: number
|
||||
filesMoved?: number
|
||||
}): void {
|
||||
const diffStats = computeLineDiffStats(args.beforeContent, args.afterContent)
|
||||
telemetryService.captureAiOutputRejected({
|
||||
ulid: args.ulid,
|
||||
tool: args.tool,
|
||||
provider: args.providerId,
|
||||
model: args.modelId,
|
||||
source: args.source,
|
||||
...diffStats,
|
||||
filesCreated: args.filesCreated,
|
||||
filesDeleted: args.filesDeleted,
|
||||
filesMoved: args.filesMoved,
|
||||
})
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ToolUse } from "@core/assistant-message"
|
||||
import { getHookModelContext } from "@core/hooks/hook-model-context"
|
||||
import { getHooksEnabledSafe } from "@core/hooks/hooks-utils"
|
||||
import { PreToolUseHookCancellationError } from "@core/hooks/PreToolUseHookCancellationError"
|
||||
import type { TaskConfig } from "../types/TaskConfig"
|
||||
@@ -84,6 +85,7 @@ export class ToolHookUtils {
|
||||
messageStateHandler: config.messageState,
|
||||
taskId: config.taskId,
|
||||
hooksEnabled,
|
||||
model: getHookModelContext(config.api, config.services.stateManager),
|
||||
toolName: block.name,
|
||||
pendingToolInfo,
|
||||
})
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import { expect } from "chai"
|
||||
import { computeLineDiffStats } from "../lineDiffStats"
|
||||
|
||||
describe("computeLineDiffStats", () => {
|
||||
it("returns zeros for identical content", () => {
|
||||
const content = "line1\nline2\nline3"
|
||||
expect(computeLineDiffStats(content, content)).to.deep.equal({
|
||||
linesAdded: 0,
|
||||
linesDeleted: 0,
|
||||
linesChanged: 0,
|
||||
})
|
||||
})
|
||||
|
||||
it("counts all lines as added for new file", () => {
|
||||
expect(computeLineDiffStats("", "a\nb\nc")).to.deep.equal({
|
||||
linesAdded: 3,
|
||||
linesDeleted: 0,
|
||||
linesChanged: 0,
|
||||
})
|
||||
})
|
||||
|
||||
it("counts all lines as deleted for removed file", () => {
|
||||
expect(computeLineDiffStats("a\nb\nc", "")).to.deep.equal({
|
||||
linesAdded: 0,
|
||||
linesDeleted: 3,
|
||||
linesChanged: 0,
|
||||
})
|
||||
})
|
||||
|
||||
it("handles insertion in the middle without false changes", () => {
|
||||
const before = "line1\nline2\nline3\nline4\nline5"
|
||||
const after = "line1\nline2\nnewA\nnewB\nnewC\nnewD\nnewE\nline3\nline4\nline5"
|
||||
const stats = computeLineDiffStats(before, after)
|
||||
expect(stats).to.deep.equal({
|
||||
linesAdded: 5,
|
||||
linesDeleted: 0,
|
||||
linesChanged: 0,
|
||||
})
|
||||
})
|
||||
|
||||
it("handles deletion in the middle without false changes", () => {
|
||||
const before = "line1\nline2\nline3\nline4\nline5"
|
||||
const after = "line1\nline5"
|
||||
const stats = computeLineDiffStats(before, after)
|
||||
expect(stats).to.deep.equal({
|
||||
linesAdded: 0,
|
||||
linesDeleted: 3,
|
||||
linesChanged: 0,
|
||||
})
|
||||
})
|
||||
|
||||
it("counts single line replacement as changed", () => {
|
||||
const before = "line1\nline2\nline3"
|
||||
const after = "line1\nmodified\nline3"
|
||||
const stats = computeLineDiffStats(before, after)
|
||||
expect(stats).to.deep.equal({
|
||||
linesAdded: 0,
|
||||
linesDeleted: 0,
|
||||
linesChanged: 1,
|
||||
})
|
||||
})
|
||||
|
||||
it("handles replace with more lines (changed + added)", () => {
|
||||
const before = "line1\nold\nline3"
|
||||
const after = "line1\nnewA\nnewB\nnewC\nline3"
|
||||
const stats = computeLineDiffStats(before, after)
|
||||
// 1 old line replaced by 3 new lines = 1 changed + 2 added
|
||||
expect(stats).to.deep.equal({
|
||||
linesAdded: 2,
|
||||
linesDeleted: 0,
|
||||
linesChanged: 1,
|
||||
})
|
||||
})
|
||||
|
||||
it("handles replace with fewer lines (changed + deleted)", () => {
|
||||
const before = "line1\noldA\noldB\noldC\nline5"
|
||||
const after = "line1\nnew\nline5"
|
||||
const stats = computeLineDiffStats(before, after)
|
||||
// 3 old lines replaced by 1 new line = 1 changed + 2 deleted
|
||||
expect(stats).to.deep.equal({
|
||||
linesAdded: 0,
|
||||
linesDeleted: 2,
|
||||
linesChanged: 1,
|
||||
})
|
||||
})
|
||||
|
||||
it("handles empty before and empty after", () => {
|
||||
expect(computeLineDiffStats("", "")).to.deep.equal({
|
||||
linesAdded: 0,
|
||||
linesDeleted: 0,
|
||||
linesChanged: 0,
|
||||
})
|
||||
})
|
||||
|
||||
it("handles append at end of file", () => {
|
||||
const before = "line1\nline2"
|
||||
const after = "line1\nline2\nline3\nline4"
|
||||
const stats = computeLineDiffStats(before, after)
|
||||
expect(stats).to.deep.equal({
|
||||
linesAdded: 2,
|
||||
linesDeleted: 0,
|
||||
linesChanged: 0,
|
||||
})
|
||||
})
|
||||
|
||||
it("handles multiple disjoint edits", () => {
|
||||
const before = "a\nb\nc\nd\ne\nf\ng"
|
||||
const after = "a\nB\nc\nd\ne\nF\ng"
|
||||
const stats = computeLineDiffStats(before, after)
|
||||
expect(stats).to.deep.equal({
|
||||
linesAdded: 0,
|
||||
linesDeleted: 0,
|
||||
linesChanged: 2,
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,72 @@
|
||||
import * as diff from "diff"
|
||||
|
||||
/**
|
||||
* Computes line-level diff statistics between two strings.
|
||||
*
|
||||
* Uses the Myers diff algorithm (via the `diff` package) to accurately
|
||||
* track insertions, deletions, and changes — even when edits occur in
|
||||
* the middle of a file.
|
||||
*/
|
||||
export interface LineDiffStats {
|
||||
linesAdded: number
|
||||
linesDeleted: number
|
||||
linesChanged: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Count the number of lines in a diff hunk value.
|
||||
* Handles trailing newlines correctly (diffLines includes them in the value).
|
||||
*/
|
||||
function countLines(value: string): number {
|
||||
if (!value) return 0
|
||||
// diffLines values end with \n, so split produces a trailing empty string
|
||||
const lines = value.split("\n")
|
||||
return lines[lines.length - 1] === "" ? lines.length - 1 : lines.length
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate line diff stats between before and after content using Myers diff.
|
||||
*
|
||||
* Adjacent remove+add hunks are paired as "changed" lines (min of the two),
|
||||
* with the remainder counted as pure adds or deletes.
|
||||
*
|
||||
* @param before - The original file content (empty string for new files)
|
||||
* @param after - The new file content (empty string for deleted files)
|
||||
* @returns LineDiffStats with counts of added, deleted, and changed lines
|
||||
*/
|
||||
export function computeLineDiffStats(before: string, after: string): LineDiffStats {
|
||||
// Normalize trailing newlines so diffLines doesn't treat last-line boundary shifts as changes
|
||||
const normBefore = before ? (before.endsWith("\n") ? before : before + "\n") : ""
|
||||
const normAfter = after ? (after.endsWith("\n") ? after : after + "\n") : ""
|
||||
const changes = diff.diffLines(normBefore, normAfter)
|
||||
|
||||
let linesAdded = 0
|
||||
let linesDeleted = 0
|
||||
let linesChanged = 0
|
||||
|
||||
for (let i = 0; i < changes.length; i++) {
|
||||
const change = changes[i]
|
||||
|
||||
if (change.removed) {
|
||||
const removedCount = countLines(change.value)
|
||||
const next = i + 1 < changes.length ? changes[i + 1] : undefined
|
||||
|
||||
// Pair adjacent remove+add as "changed"
|
||||
if (next?.added) {
|
||||
const addedCount = countLines(next.value)
|
||||
const paired = Math.min(removedCount, addedCount)
|
||||
linesChanged += paired
|
||||
linesDeleted += removedCount - paired
|
||||
linesAdded += addedCount - paired
|
||||
i++ // skip the paired add hunk
|
||||
} else {
|
||||
linesDeleted += removedCount
|
||||
}
|
||||
} else if (change.added) {
|
||||
linesAdded += countLines(change.value)
|
||||
}
|
||||
// unchanged hunks are ignored
|
||||
}
|
||||
|
||||
return { linesAdded, linesDeleted, linesChanged }
|
||||
}
|
||||
+1
-1
@@ -602,7 +602,7 @@ function setupHostProvider(context: ExtensionContext) {
|
||||
const createCommentReview = () => getVscodeCommentReviewController()
|
||||
const createTerminalManager = () => new VscodeTerminalManager()
|
||||
|
||||
const getCallbackUrl = async (path: string) => {
|
||||
const getCallbackUrl = async (path: string, _preferredPort?: number) => {
|
||||
const scheme = vscode.env.uriScheme || "vscode"
|
||||
const callbackUri = vscode.Uri.parse(`${scheme}://${context.extension.id}${path}`)
|
||||
|
||||
|
||||
Vendored
+9
-5
@@ -40,7 +40,7 @@ export class AuthHandler {
|
||||
this.enabled = enabled
|
||||
}
|
||||
|
||||
public async getCallbackUrl(path = ""): Promise<string> {
|
||||
public async getCallbackUrl(path = "", preferredPort?: number): Promise<string> {
|
||||
if (!this.enabled) {
|
||||
throw Error("AuthHandler was not enabled")
|
||||
}
|
||||
@@ -51,7 +51,8 @@ export class AuthHandler {
|
||||
await this.serverCreationPromise
|
||||
} else {
|
||||
// Start server creation and track the promise
|
||||
this.serverCreationPromise = this.createServer()
|
||||
// Pass preferred port so we try to bind it first (preserves OAuth client registrations)
|
||||
this.serverCreationPromise = this.createServer(preferredPort)
|
||||
await this.serverCreationPromise
|
||||
}
|
||||
} else {
|
||||
@@ -61,13 +62,16 @@ export class AuthHandler {
|
||||
return `http://127.0.0.1:${this.port}${path}`
|
||||
}
|
||||
|
||||
private async createServer(): Promise<void> {
|
||||
private async createServer(preferredPort?: number): Promise<void> {
|
||||
return new Promise(async (resolve, reject) => {
|
||||
try {
|
||||
const server = http.createServer(this.handleRequest.bind(this))
|
||||
|
||||
// Try to bind on a port from the allowed range
|
||||
for (const port of PORTS) {
|
||||
// Build the port list: try preferred port first (if provided), then the normal range
|
||||
const portsToTry = preferredPort ? [preferredPort, ...PORTS.filter((p) => p !== preferredPort)] : PORTS
|
||||
|
||||
// Try to bind on a port from the list
|
||||
for (const port of portsToTry) {
|
||||
try {
|
||||
await this.tryListenOnPort(server, port)
|
||||
|
||||
|
||||
@@ -30,7 +30,9 @@ export class HostProvider {
|
||||
|
||||
// Returns a callback URL that will redirect to Cline.
|
||||
// The path parameter specifies the route for the callback (e.g., "/auth", "/openrouter").
|
||||
getCallbackUrl: (path: string) => Promise<string>
|
||||
// The optional preferredPort parameter hints that the provider should try to bind
|
||||
// this specific port first (used to preserve OAuth client registrations across sessions).
|
||||
getCallbackUrl: (path: string, preferredPort?: number) => Promise<string>
|
||||
|
||||
// Returns the location of the binary `name`.
|
||||
// Use `getBinaryLocation()` from utils/ts.ts instead of using
|
||||
@@ -53,7 +55,7 @@ export class HostProvider {
|
||||
createTerminalManager: TerminalManagerCreator,
|
||||
hostBridge: HostBridgeClientProvider,
|
||||
logToChannel: LogToChannel,
|
||||
getCallbackUrl: (path: string) => Promise<string>,
|
||||
getCallbackUrl: (path: string, preferredPort?: number) => Promise<string>,
|
||||
getBinaryLocation: (name: string) => Promise<string>,
|
||||
extensionFsPath: string,
|
||||
globalStorageFsPath: string,
|
||||
@@ -77,7 +79,7 @@ export class HostProvider {
|
||||
terminalManagerCreator: TerminalManagerCreator,
|
||||
hostBridgeProvider: HostBridgeClientProvider,
|
||||
logToChannel: LogToChannel,
|
||||
getCallbackUrl: (path: string) => Promise<string>,
|
||||
getCallbackUrl: (path: string, preferredPort?: number) => Promise<string>,
|
||||
getBinaryLocation: (name: string) => Promise<string>,
|
||||
extensionFsPath: string,
|
||||
globalStorageFsPath: string,
|
||||
|
||||
+13
-10
@@ -45,6 +45,7 @@ import { getServerAuthHash } from "@/utils/mcpAuth"
|
||||
import { TelemetryService } from "../telemetry/TelemetryService"
|
||||
import { DEFAULT_REQUEST_TIMEOUT_MS } from "./constants"
|
||||
import { McpOAuthManager } from "./McpOAuthManager"
|
||||
import { StreamableHttpReconnectHandler } from "./StreamableHttpReconnectHandler"
|
||||
import { BaseConfigSchema, McpSettingsSchema, ServerConfigSchema } from "./schemas"
|
||||
import { McpConnection, McpServerConfig, Transport } from "./types"
|
||||
export class McpHub {
|
||||
@@ -514,16 +515,18 @@ export class McpHub {
|
||||
},
|
||||
fetch: streamableHttpFetch,
|
||||
})
|
||||
transport.onerror = async (error) => {
|
||||
Logger.error(`Transport error for "${name}":`, error)
|
||||
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()
|
||||
}
|
||||
|
||||
const reconnectHandler = new StreamableHttpReconnectHandler(name, {
|
||||
findConnection: () => this.findConnection(name, source),
|
||||
deleteConnection: () => this.deleteConnection(name),
|
||||
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),
|
||||
})
|
||||
|
||||
transport.onerror = (error) => reconnectHandler.handleError(error)
|
||||
break
|
||||
}
|
||||
default:
|
||||
|
||||
@@ -6,6 +6,7 @@ import { HostProvider } from "@/hosts/host-provider"
|
||||
import { Logger } from "@/shared/services/Logger"
|
||||
import { openExternal } from "@/utils/env"
|
||||
import { getMcpServerCallbackPath, getServerAuthHash } from "@/utils/mcpAuth"
|
||||
import { McpOAuthRedirectResolver } from "./McpOAuthRedirectResolver"
|
||||
|
||||
/**
|
||||
* Structure for all OAuth data stored in the single mcpOAuthSecrets JSON
|
||||
@@ -15,6 +16,7 @@ interface McpOAuthSecrets {
|
||||
tokens?: OAuthTokens
|
||||
tokens_saved_at?: number
|
||||
client_info?: OAuthClientInformationFull
|
||||
redirect_url_at_registration?: string
|
||||
code_verifier?: string
|
||||
oauth_state?: string
|
||||
oauth_state_timestamp?: number
|
||||
@@ -55,6 +57,7 @@ class ClineOAuthClientProvider implements OAuthClientProvider {
|
||||
private serverName: string
|
||||
private serverUrl: string
|
||||
private _redirectUrl: string
|
||||
private isRegistrationValid: boolean
|
||||
private serverHash: string
|
||||
|
||||
constructor(serverName: string, serverUrl: string) {
|
||||
@@ -62,14 +65,26 @@ class ClineOAuthClientProvider implements OAuthClientProvider {
|
||||
this.serverUrl = serverUrl
|
||||
this.serverHash = getServerAuthHash(serverName, serverUrl)
|
||||
|
||||
// Redirect URL will be set when initialize() is called
|
||||
// Redirect URL and registration validity will be set when initialize() is called
|
||||
this._redirectUrl = ""
|
||||
this.isRegistrationValid = false
|
||||
}
|
||||
|
||||
async initialize(): Promise<void> {
|
||||
// Get the full callback URL with the MCP server-specific path
|
||||
// Get the full callback URL with the MCP server-specific path,
|
||||
// attempting to reuse the previously-registered port to preserve the OAuth client registration.
|
||||
const callbackPath = getMcpServerCallbackPath(this.serverName, this.serverUrl)
|
||||
this._redirectUrl = await HostProvider.get().getCallbackUrl(callbackPath)
|
||||
const secrets = getMcpOAuthSecrets()
|
||||
const savedRedirectUrl = secrets[this.serverHash]?.redirect_url_at_registration
|
||||
|
||||
const resolution = await McpOAuthRedirectResolver.resolve(
|
||||
savedRedirectUrl,
|
||||
callbackPath,
|
||||
HostProvider.get().getCallbackUrl,
|
||||
)
|
||||
|
||||
this._redirectUrl = resolution.redirectUrl
|
||||
this.isRegistrationValid = resolution.isRegistrationValid
|
||||
}
|
||||
|
||||
get redirectUrl(): string {
|
||||
@@ -94,6 +109,24 @@ class ClineOAuthClientProvider implements OAuthClientProvider {
|
||||
}
|
||||
|
||||
async clientInformation(): Promise<OAuthClientInformationFull | undefined> {
|
||||
// If the redirect URL has changed since the client was registered
|
||||
// (e.g., different port bound, platform migration), the saved client_info
|
||||
// is stale — the OAuth server will reject requests with the old redirect_uri.
|
||||
// Return undefined to force the SDK to re-register a new client.
|
||||
if (!this.isRegistrationValid) {
|
||||
const secrets = getMcpOAuthSecrets()
|
||||
if (secrets[this.serverHash]?.client_info) {
|
||||
Logger.log(`[McpOAuth] Discarding stale client registration for ${this.serverName} — redirect URL changed`)
|
||||
// Clear the stale client_info and tokens (tokens are bound to the old client_id)
|
||||
delete secrets[this.serverHash].client_info
|
||||
delete secrets[this.serverHash].redirect_url_at_registration
|
||||
delete secrets[this.serverHash].tokens
|
||||
delete secrets[this.serverHash].tokens_saved_at
|
||||
saveMcpOAuthSecrets(secrets)
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
const secrets = getMcpOAuthSecrets()
|
||||
return secrets[this.serverHash]?.client_info
|
||||
}
|
||||
@@ -104,7 +137,13 @@ class ClineOAuthClientProvider implements OAuthClientProvider {
|
||||
secrets[this.serverHash] = {}
|
||||
}
|
||||
secrets[this.serverHash].client_info = clientInformation
|
||||
// Track the redirect URL used for this registration so we can detect
|
||||
// when it changes and force re-registration (see clientInformation())
|
||||
secrets[this.serverHash].redirect_url_at_registration = this._redirectUrl
|
||||
saveMcpOAuthSecrets(secrets)
|
||||
|
||||
// After a successful registration, the current redirect URL is now the registered one
|
||||
this.isRegistrationValid = true
|
||||
}
|
||||
|
||||
async tokens(): Promise<OAuthTokens | undefined> {
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
import { Logger } from "@/shared/services/Logger"
|
||||
|
||||
/**
|
||||
* Result of resolving an OAuth redirect URL for an MCP server.
|
||||
*/
|
||||
export interface RedirectUrlResolution {
|
||||
/** The resolved redirect URL to use for OAuth */
|
||||
redirectUrl: string
|
||||
/** Whether the previously saved client registration can be reused */
|
||||
isRegistrationValid: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Function type for obtaining a callback URL.
|
||||
* @param path - The callback path (e.g., /mcp-auth/callback/{hash})
|
||||
* @param preferredPort - Optional port to try binding first (ignored by non-loopback providers like VSCode desktop)
|
||||
*/
|
||||
export type GetCallbackUrlFn = (path: string, preferredPort?: number) => Promise<string>
|
||||
|
||||
/**
|
||||
* Pure logic for MCP OAuth redirect URL resolution.
|
||||
*
|
||||
* Solves the problem where a dynamically-registered OAuth client_id becomes
|
||||
* stale when the local callback server port changes between sessions.
|
||||
* OAuth servers (like Linear) reject authorization requests where the
|
||||
* redirect_uri doesn't match the URI registered for the client_id.
|
||||
*
|
||||
* Strategy:
|
||||
* 1. If we have a saved redirect URL from a previous registration, extract the port
|
||||
* 2. Ask the callback URL provider to try that port first
|
||||
* 3. If we get the same URL back → existing registration is valid
|
||||
* 4. If port was unavailable or URL differs → force re-registration
|
||||
*
|
||||
* This handles all combinations:
|
||||
* - Standalone/JetBrains/CLI (http://127.0.0.1:{port}/...) — dynamic port, can become stale
|
||||
* - VSCode Desktop (vscode://extension-id/...) — stable, no port
|
||||
* - VSCode Web (https://codespace.github.dev/...) — stable per-codespace
|
||||
* - Legacy state (no saved redirect URL) — conservative: assume stale
|
||||
* - Cross-platform migration (VSCode → JetBrains) — detect scheme change
|
||||
*/
|
||||
export class McpOAuthRedirectResolver {
|
||||
/**
|
||||
* Extract the port number from an http://127.0.0.1:{port}/... URL.
|
||||
* Returns undefined for non-loopback URLs (vscode://, https://, etc.)
|
||||
* or URLs that don't match the expected loopback pattern.
|
||||
*/
|
||||
static extractLoopbackPort(url: string): number | undefined {
|
||||
if (!McpOAuthRedirectResolver.isLoopbackUrl(url)) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = new URL(url)
|
||||
const port = Number.parseInt(parsed.port, 10)
|
||||
return Number.isNaN(port) || port <= 0 || port > 65535 ? undefined : port
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines if a redirect URL is an http://127.0.0.1 loopback URL
|
||||
* (i.e., the type that uses dynamic ports and can become stale).
|
||||
*/
|
||||
static isLoopbackUrl(url: string): boolean {
|
||||
try {
|
||||
const parsed = new URL(url)
|
||||
return parsed.protocol === "http:" && parsed.hostname === "127.0.0.1"
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines if two redirect URLs are compatible for OAuth client reuse.
|
||||
*
|
||||
* Rules:
|
||||
* - If savedUrl is undefined (legacy state, no tracking), return false (conservative:
|
||||
* we don't know what URL was registered, so we force re-registration to be safe)
|
||||
* - If both are identical strings → compatible
|
||||
* - Otherwise → incompatible (different port, different scheme, different platform)
|
||||
*/
|
||||
static isRedirectCompatible(savedRedirectUrl: string | undefined, currentRedirectUrl: string): boolean {
|
||||
if (savedRedirectUrl === undefined) {
|
||||
return false
|
||||
}
|
||||
return savedRedirectUrl === currentRedirectUrl
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the redirect URL, attempting to preserve existing client registrations.
|
||||
*
|
||||
* For loopback URLs (standalone/JetBrains/CLI): extracts the previously-used port
|
||||
* and asks the callback URL provider to try binding it first. If the same URL is
|
||||
* obtained, the existing registration remains valid.
|
||||
*
|
||||
* For scheme URLs (VSCode desktop): no port to prefer, returns URL directly.
|
||||
*
|
||||
* For legacy state (no saved URL): gets a fresh URL, marks registration as invalid
|
||||
* so the SDK will re-register with the new redirect_uri.
|
||||
*
|
||||
* @param savedRedirectUrl - The redirect URL from a previous registration (may be undefined for legacy state)
|
||||
* @param callbackPath - The OAuth callback path (e.g., /mcp-auth/callback/{hash})
|
||||
* @param getCallbackUrl - Function to get a callback URL, optionally with a preferred port
|
||||
*/
|
||||
static async resolve(
|
||||
savedRedirectUrl: string | undefined,
|
||||
callbackPath: string,
|
||||
getCallbackUrl: GetCallbackUrlFn,
|
||||
): Promise<RedirectUrlResolution> {
|
||||
// Determine if we have a preferred port to try
|
||||
const preferredPort =
|
||||
savedRedirectUrl !== undefined ? McpOAuthRedirectResolver.extractLoopbackPort(savedRedirectUrl) : undefined
|
||||
|
||||
// Get the callback URL, passing the preferred port if we have one
|
||||
const redirectUrl = await getCallbackUrl(callbackPath, preferredPort)
|
||||
|
||||
// Check if the resolved URL matches the saved one
|
||||
const isRegistrationValid = McpOAuthRedirectResolver.isRedirectCompatible(savedRedirectUrl, redirectUrl)
|
||||
|
||||
if (savedRedirectUrl !== undefined && !isRegistrationValid) {
|
||||
Logger.log(
|
||||
`[McpOAuthRedirectResolver] Redirect URL changed: saved="${savedRedirectUrl}" current="${redirectUrl}" — client re-registration required`,
|
||||
)
|
||||
}
|
||||
|
||||
return { redirectUrl, isRegistrationValid }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
import { Logger } from "@/shared/services/Logger"
|
||||
|
||||
/**
|
||||
* Callbacks that the reconnect handler uses to interact with McpHub.
|
||||
* Injecting these allow the handler to be tested in isolation.
|
||||
*/
|
||||
export interface ReconnectCallbacks {
|
||||
/** Returns the current connection object, or undefined if it no longer exists */
|
||||
findConnection: () => { server: { status: string; disabled?: boolean; uid?: string } } | undefined
|
||||
/** Tears down the existing connection */
|
||||
deleteConnection: () => Promise<void>
|
||||
/** Establishes a new connection */
|
||||
connectToServer: () => Promise<void>
|
||||
/** Pushes updated server state to the webview */
|
||||
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>
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration for the reconnection strategy.
|
||||
*/
|
||||
export interface ReconnectConfig {
|
||||
/** Maximum number of consecutive reconnect attempts before giving up */
|
||||
maxAttempts: number
|
||||
/** Returns the delay in milliseconds for a given attempt (0-based). */
|
||||
getDelayMs: (attempt: number) => number
|
||||
}
|
||||
|
||||
/** Default configuration: up to 6 attempts with exponential backoff starting at 2 s. */
|
||||
export const DEFAULT_RECONNECT_CONFIG: ReconnectConfig = {
|
||||
maxAttempts: 6,
|
||||
getDelayMs: (attempt: number) => 2000 * 2 ** attempt,
|
||||
}
|
||||
|
||||
/**
|
||||
* Manages reconnection logic for a single StreamableHTTP MCP transport.
|
||||
*
|
||||
* Each instance tracks its own attempt counter. When the transport's `onerror`
|
||||
* fires, call {@link handleError}. The handler will:
|
||||
*
|
||||
* 1. Skip if the connection is disabled or already reconnecting.
|
||||
* 2. Wait with exponential backoff.
|
||||
* 3. Tear down and re-establish the connection.
|
||||
* 4. Reset the counter on success.
|
||||
* 5. After exhausting retries, mark the server as disconnected.
|
||||
*/
|
||||
export class StreamableHttpReconnectHandler {
|
||||
private attempts = 0
|
||||
private readonly serverName: string
|
||||
private readonly config: ReconnectConfig
|
||||
private readonly callbacks: ReconnectCallbacks
|
||||
|
||||
constructor(serverName: string, callbacks: ReconnectCallbacks, config: ReconnectConfig = DEFAULT_RECONNECT_CONFIG) {
|
||||
this.serverName = serverName
|
||||
this.callbacks = callbacks
|
||||
this.config = config
|
||||
}
|
||||
|
||||
/** Number of consecutive reconnect attempts so far */
|
||||
get attemptCount(): number {
|
||||
return this.attempts
|
||||
}
|
||||
|
||||
/** Reset the attempt counter (e.g. after a successful long-lived connection) */
|
||||
resetAttempts(): void {
|
||||
this.attempts = 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle a transport error. Call this from `transport.onerror`.
|
||||
*/
|
||||
async handleError(error: unknown): Promise<void> {
|
||||
Logger.error(`Transport error for "${this.serverName}":`, error)
|
||||
|
||||
const connection = this.callbacks.findConnection()
|
||||
if (!connection) {
|
||||
return
|
||||
}
|
||||
|
||||
// Don't retry if intentionally disabled or already mid-reconnect
|
||||
if (connection.server.disabled || connection.server.status === "connecting") {
|
||||
return
|
||||
}
|
||||
|
||||
if (this.attempts >= this.config.maxAttempts) {
|
||||
// Max retries exhausted
|
||||
Logger.error(
|
||||
`StreamableHTTP max reconnect attempts (${this.config.maxAttempts}) ` +
|
||||
`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
|
||||
}
|
||||
|
||||
// First attempt: backoff, verify staleness, then delete + connect.
|
||||
// Subsequent attempts (on connectToServer failure) just backoff + connect.
|
||||
const initialDelay = this.config.getDelayMs(this.attempts)
|
||||
this.attempts++
|
||||
Logger.log(
|
||||
`StreamableHTTP transport error for "${this.serverName}", attempting reconnect ` +
|
||||
`${this.attempts}/${this.config.maxAttempts} in ${initialDelay / 1000}s...`,
|
||||
)
|
||||
|
||||
connection.server.status = "connecting"
|
||||
await this.callbacks.notifyWebviewOfServerChanges()
|
||||
|
||||
await this.callbacks.delay(initialDelay)
|
||||
|
||||
// Verify connection still exists and hasn't been replaced during the delay
|
||||
const currentConnection = this.callbacks.findConnection()
|
||||
if (!currentConnection || currentConnection !== connection) {
|
||||
return
|
||||
}
|
||||
|
||||
// Tear down the old connection, then retry connectToServer in a loop.
|
||||
// We loop here instead of relying on the new transport's onerror because
|
||||
// connectToServer() may throw before a new transport/error-handler is
|
||||
// established, which would silently break the retry chain.
|
||||
await this.callbacks.deleteConnection()
|
||||
|
||||
while (this.attempts <= this.config.maxAttempts) {
|
||||
try {
|
||||
await this.callbacks.connectToServer()
|
||||
Logger.log(`StreamableHTTP reconnect succeeded for "${this.serverName}"`)
|
||||
this.attempts = 0
|
||||
return
|
||||
} catch (reconnectError) {
|
||||
Logger.error(`StreamableHTTP reconnect failed for "${this.serverName}":`, reconnectError)
|
||||
if (this.attempts < this.config.maxAttempts) {
|
||||
const retryDelay = this.config.getDelayMs(this.attempts)
|
||||
this.attempts++
|
||||
Logger.log(
|
||||
`StreamableHTTP retrying reconnect ${this.attempts}/${this.config.maxAttempts} ` +
|
||||
`for "${this.serverName}" in ${retryDelay / 1000}s...`,
|
||||
)
|
||||
await this.callbacks.delay(retryDelay)
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// All retry attempts exhausted during the connect loop.
|
||||
Logger.error(
|
||||
`StreamableHTTP max reconnect attempts (${this.config.maxAttempts}) ` +
|
||||
`exhausted for "${this.serverName}". Server marked as disconnected.`,
|
||||
)
|
||||
// The old connection was deleted; check if connectToServer left a partial one.
|
||||
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()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
import { describe, it } from "mocha"
|
||||
import "should"
|
||||
import { type GetCallbackUrlFn, McpOAuthRedirectResolver } from "../McpOAuthRedirectResolver"
|
||||
|
||||
describe("McpOAuthRedirectResolver", () => {
|
||||
describe("extractLoopbackPort", () => {
|
||||
it("should extract port from http://127.0.0.1:48801/path", () => {
|
||||
const port = McpOAuthRedirectResolver.extractLoopbackPort("http://127.0.0.1:48801/mcp-auth/callback/abc123")
|
||||
port!.should.equal(48801)
|
||||
})
|
||||
|
||||
it("should extract port from http://127.0.0.1:48811 (no path)", () => {
|
||||
const port = McpOAuthRedirectResolver.extractLoopbackPort("http://127.0.0.1:48811")
|
||||
port!.should.equal(48811)
|
||||
})
|
||||
|
||||
it("should return undefined for vscode:// URLs", () => {
|
||||
const port = McpOAuthRedirectResolver.extractLoopbackPort("vscode://saoudrizwan.claude-dev/mcp-auth/callback/abc123")
|
||||
should(port).be.undefined()
|
||||
})
|
||||
|
||||
it("should return undefined for https:// URLs", () => {
|
||||
const port = McpOAuthRedirectResolver.extractLoopbackPort("https://codespace-abc.github.dev/mcp-auth/callback/abc123")
|
||||
should(port).be.undefined()
|
||||
})
|
||||
|
||||
it("should return undefined for malformed URLs", () => {
|
||||
const port = McpOAuthRedirectResolver.extractLoopbackPort("not-a-url")
|
||||
should(port).be.undefined()
|
||||
})
|
||||
|
||||
it("should return undefined for empty string", () => {
|
||||
const port = McpOAuthRedirectResolver.extractLoopbackPort("")
|
||||
should(port).be.undefined()
|
||||
})
|
||||
|
||||
it("should return undefined for http://localhost (not 127.0.0.1)", () => {
|
||||
const port = McpOAuthRedirectResolver.extractLoopbackPort("http://localhost:3000/callback")
|
||||
should(port).be.undefined()
|
||||
})
|
||||
|
||||
it("should return undefined for http://127.0.0.1 without a port", () => {
|
||||
// http://127.0.0.1/path has no explicit port (defaults to 80)
|
||||
// URL.port returns "" for default ports
|
||||
const port = McpOAuthRedirectResolver.extractLoopbackPort("http://127.0.0.1/path")
|
||||
should(port).be.undefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe("isLoopbackUrl", () => {
|
||||
it("should return true for http://127.0.0.1:48801/...", () => {
|
||||
McpOAuthRedirectResolver.isLoopbackUrl("http://127.0.0.1:48801/mcp-auth/callback/abc").should.be.true()
|
||||
})
|
||||
|
||||
it("should return true for http://127.0.0.1 without port", () => {
|
||||
McpOAuthRedirectResolver.isLoopbackUrl("http://127.0.0.1/path").should.be.true()
|
||||
})
|
||||
|
||||
it("should return false for vscode:// URLs", () => {
|
||||
McpOAuthRedirectResolver.isLoopbackUrl("vscode://saoudrizwan.claude-dev/path").should.be.false()
|
||||
})
|
||||
|
||||
it("should return false for https:// URLs", () => {
|
||||
McpOAuthRedirectResolver.isLoopbackUrl("https://example.com/path").should.be.false()
|
||||
})
|
||||
|
||||
it("should return false for http://localhost (not 127.0.0.1)", () => {
|
||||
McpOAuthRedirectResolver.isLoopbackUrl("http://localhost:3000/path").should.be.false()
|
||||
})
|
||||
|
||||
it("should return false for malformed URLs", () => {
|
||||
McpOAuthRedirectResolver.isLoopbackUrl("not-a-url").should.be.false()
|
||||
})
|
||||
})
|
||||
|
||||
describe("isRedirectCompatible", () => {
|
||||
it("should return true when URLs are identical", () => {
|
||||
McpOAuthRedirectResolver.isRedirectCompatible(
|
||||
"http://127.0.0.1:48801/mcp-auth/callback/abc123",
|
||||
"http://127.0.0.1:48801/mcp-auth/callback/abc123",
|
||||
).should.be.true()
|
||||
})
|
||||
|
||||
it("should return true for identical vscode:// URLs", () => {
|
||||
McpOAuthRedirectResolver.isRedirectCompatible(
|
||||
"vscode://saoudrizwan.claude-dev/mcp-auth/callback/abc123",
|
||||
"vscode://saoudrizwan.claude-dev/mcp-auth/callback/abc123",
|
||||
).should.be.true()
|
||||
})
|
||||
|
||||
it("should return true for identical https:// URLs", () => {
|
||||
McpOAuthRedirectResolver.isRedirectCompatible(
|
||||
"https://codespace-abc.github.dev/mcp-auth/callback/abc123",
|
||||
"https://codespace-abc.github.dev/mcp-auth/callback/abc123",
|
||||
).should.be.true()
|
||||
})
|
||||
|
||||
it("should return false when saved URL is undefined (legacy state)", () => {
|
||||
McpOAuthRedirectResolver.isRedirectCompatible(
|
||||
undefined,
|
||||
"http://127.0.0.1:48801/mcp-auth/callback/abc123",
|
||||
).should.be.false()
|
||||
})
|
||||
|
||||
it("should return false when ports differ", () => {
|
||||
McpOAuthRedirectResolver.isRedirectCompatible(
|
||||
"http://127.0.0.1:48801/mcp-auth/callback/abc123",
|
||||
"http://127.0.0.1:48802/mcp-auth/callback/abc123",
|
||||
).should.be.false()
|
||||
})
|
||||
|
||||
it("should return false when schemes differ (VSCode → JetBrains migration)", () => {
|
||||
McpOAuthRedirectResolver.isRedirectCompatible(
|
||||
"vscode://saoudrizwan.claude-dev/mcp-auth/callback/abc123",
|
||||
"http://127.0.0.1:48801/mcp-auth/callback/abc123",
|
||||
).should.be.false()
|
||||
})
|
||||
|
||||
it("should return false when schemes differ (JetBrains → VSCode migration)", () => {
|
||||
McpOAuthRedirectResolver.isRedirectCompatible(
|
||||
"http://127.0.0.1:48801/mcp-auth/callback/abc123",
|
||||
"vscode://saoudrizwan.claude-dev/mcp-auth/callback/abc123",
|
||||
).should.be.false()
|
||||
})
|
||||
|
||||
it("should return false when paths differ (different server hash)", () => {
|
||||
McpOAuthRedirectResolver.isRedirectCompatible(
|
||||
"http://127.0.0.1:48801/mcp-auth/callback/hash1",
|
||||
"http://127.0.0.1:48801/mcp-auth/callback/hash2",
|
||||
).should.be.false()
|
||||
})
|
||||
|
||||
it("should return false when codespace domains differ", () => {
|
||||
McpOAuthRedirectResolver.isRedirectCompatible(
|
||||
"https://codespace-old.github.dev/mcp-auth/callback/abc123",
|
||||
"https://codespace-new.github.dev/mcp-auth/callback/abc123",
|
||||
).should.be.false()
|
||||
})
|
||||
})
|
||||
|
||||
describe("resolve", () => {
|
||||
it("should get fresh URL and mark registration invalid when no saved URL", async () => {
|
||||
const getCallbackUrl: GetCallbackUrlFn = async (path, _preferredPort) => {
|
||||
return `http://127.0.0.1:48801${path}`
|
||||
}
|
||||
|
||||
const result = await McpOAuthRedirectResolver.resolve(undefined, "/mcp-auth/callback/abc123", getCallbackUrl)
|
||||
|
||||
result.redirectUrl.should.equal("http://127.0.0.1:48801/mcp-auth/callback/abc123")
|
||||
result.isRegistrationValid.should.be.false()
|
||||
})
|
||||
|
||||
it("should reuse port when saved loopback URL port is available", async () => {
|
||||
const savedUrl = "http://127.0.0.1:48803/mcp-auth/callback/abc123"
|
||||
|
||||
// Mock: the provider successfully binds the preferred port
|
||||
const getCallbackUrl: GetCallbackUrlFn = async (path, preferredPort) => {
|
||||
// Simulate: preferred port was available, so we got the same port back
|
||||
const port = preferredPort || 48801
|
||||
return `http://127.0.0.1:${port}${path}`
|
||||
}
|
||||
|
||||
const result = await McpOAuthRedirectResolver.resolve(savedUrl, "/mcp-auth/callback/abc123", getCallbackUrl)
|
||||
|
||||
result.redirectUrl.should.equal("http://127.0.0.1:48803/mcp-auth/callback/abc123")
|
||||
result.isRegistrationValid.should.be.true()
|
||||
})
|
||||
|
||||
it("should fall back to new port and mark registration invalid when preferred port is unavailable", async () => {
|
||||
const savedUrl = "http://127.0.0.1:48803/mcp-auth/callback/abc123"
|
||||
|
||||
// Mock: the provider cannot bind the preferred port, falls back to another
|
||||
const getCallbackUrl: GetCallbackUrlFn = async (path, _preferredPort) => {
|
||||
// Simulate: preferred port was occupied, fell back to 48805
|
||||
return `http://127.0.0.1:48805${path}`
|
||||
}
|
||||
|
||||
const result = await McpOAuthRedirectResolver.resolve(savedUrl, "/mcp-auth/callback/abc123", getCallbackUrl)
|
||||
|
||||
result.redirectUrl.should.equal("http://127.0.0.1:48805/mcp-auth/callback/abc123")
|
||||
result.isRegistrationValid.should.be.false()
|
||||
})
|
||||
|
||||
it("should pass preferred port to getCallbackUrl for loopback URLs", async () => {
|
||||
const savedUrl = "http://127.0.0.1:48807/mcp-auth/callback/abc123"
|
||||
let receivedPreferredPort: number | undefined
|
||||
|
||||
const getCallbackUrl: GetCallbackUrlFn = async (path, preferredPort) => {
|
||||
receivedPreferredPort = preferredPort
|
||||
const port = preferredPort || 48801
|
||||
return `http://127.0.0.1:${port}${path}`
|
||||
}
|
||||
|
||||
await McpOAuthRedirectResolver.resolve(savedUrl, "/mcp-auth/callback/abc123", getCallbackUrl)
|
||||
|
||||
receivedPreferredPort!.should.equal(48807)
|
||||
})
|
||||
|
||||
it("should NOT pass preferred port for non-loopback saved URLs (vscode://)", async () => {
|
||||
const savedUrl = "vscode://saoudrizwan.claude-dev/mcp-auth/callback/abc123"
|
||||
let receivedPreferredPort: number | undefined
|
||||
|
||||
const getCallbackUrl: GetCallbackUrlFn = async (path, preferredPort) => {
|
||||
receivedPreferredPort = preferredPort
|
||||
return `vscode://saoudrizwan.claude-dev${path}`
|
||||
}
|
||||
|
||||
await McpOAuthRedirectResolver.resolve(savedUrl, "/mcp-auth/callback/abc123", getCallbackUrl)
|
||||
|
||||
should(receivedPreferredPort).be.undefined()
|
||||
})
|
||||
|
||||
it("should mark registration valid when VSCode URLs match", async () => {
|
||||
const savedUrl = "vscode://saoudrizwan.claude-dev/mcp-auth/callback/abc123"
|
||||
|
||||
const getCallbackUrl: GetCallbackUrlFn = async (path, _preferredPort) => {
|
||||
return `vscode://saoudrizwan.claude-dev${path}`
|
||||
}
|
||||
|
||||
const result = await McpOAuthRedirectResolver.resolve(savedUrl, "/mcp-auth/callback/abc123", getCallbackUrl)
|
||||
|
||||
result.redirectUrl.should.equal("vscode://saoudrizwan.claude-dev/mcp-auth/callback/abc123")
|
||||
result.isRegistrationValid.should.be.true()
|
||||
})
|
||||
|
||||
it("should mark registration invalid for VSCode → JetBrains cross-platform migration", async () => {
|
||||
const savedUrl = "vscode://saoudrizwan.claude-dev/mcp-auth/callback/abc123"
|
||||
|
||||
// Now running on JetBrains, which uses loopback
|
||||
const getCallbackUrl: GetCallbackUrlFn = async (path, _preferredPort) => {
|
||||
return `http://127.0.0.1:48801${path}`
|
||||
}
|
||||
|
||||
const result = await McpOAuthRedirectResolver.resolve(savedUrl, "/mcp-auth/callback/abc123", getCallbackUrl)
|
||||
|
||||
result.redirectUrl.should.equal("http://127.0.0.1:48801/mcp-auth/callback/abc123")
|
||||
result.isRegistrationValid.should.be.false()
|
||||
})
|
||||
|
||||
it("should mark registration invalid for JetBrains → VSCode cross-platform migration", async () => {
|
||||
const savedUrl = "http://127.0.0.1:48801/mcp-auth/callback/abc123"
|
||||
|
||||
// Now running on VSCode
|
||||
const getCallbackUrl: GetCallbackUrlFn = async (path, _preferredPort) => {
|
||||
return `vscode://saoudrizwan.claude-dev${path}`
|
||||
}
|
||||
|
||||
const result = await McpOAuthRedirectResolver.resolve(savedUrl, "/mcp-auth/callback/abc123", getCallbackUrl)
|
||||
|
||||
result.redirectUrl.should.equal("vscode://saoudrizwan.claude-dev/mcp-auth/callback/abc123")
|
||||
result.isRegistrationValid.should.be.false()
|
||||
})
|
||||
|
||||
it("should handle VSCode Web URLs (https://)", async () => {
|
||||
const savedUrl = "https://codespace-abc.github.dev/mcp-auth/callback/abc123"
|
||||
|
||||
const getCallbackUrl: GetCallbackUrlFn = async (path, _preferredPort) => {
|
||||
return `https://codespace-abc.github.dev${path}`
|
||||
}
|
||||
|
||||
const result = await McpOAuthRedirectResolver.resolve(savedUrl, "/mcp-auth/callback/abc123", getCallbackUrl)
|
||||
|
||||
result.redirectUrl.should.equal("https://codespace-abc.github.dev/mcp-auth/callback/abc123")
|
||||
result.isRegistrationValid.should.be.true()
|
||||
})
|
||||
|
||||
it("should detect codespace change as registration invalid", async () => {
|
||||
const savedUrl = "https://codespace-old.github.dev/mcp-auth/callback/abc123"
|
||||
|
||||
const getCallbackUrl: GetCallbackUrlFn = async (path, _preferredPort) => {
|
||||
return `https://codespace-new.github.dev${path}`
|
||||
}
|
||||
|
||||
const result = await McpOAuthRedirectResolver.resolve(savedUrl, "/mcp-auth/callback/abc123", getCallbackUrl)
|
||||
|
||||
result.redirectUrl.should.equal("https://codespace-new.github.dev/mcp-auth/callback/abc123")
|
||||
result.isRegistrationValid.should.be.false()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,302 @@
|
||||
import { beforeEach, describe, it } from "mocha"
|
||||
import "should"
|
||||
import sinon from "sinon"
|
||||
import {
|
||||
DEFAULT_RECONNECT_CONFIG,
|
||||
ReconnectCallbacks,
|
||||
ReconnectConfig,
|
||||
StreamableHttpReconnectHandler,
|
||||
} from "../StreamableHttpReconnectHandler"
|
||||
|
||||
/** Build a mock connection object whose status can be inspected. */
|
||||
function makeConnection(overrides: Partial<{ status: string; disabled: boolean; uid: string }> = {}) {
|
||||
return {
|
||||
server: {
|
||||
status: overrides.status ?? "connected",
|
||||
disabled: overrides.disabled ?? false,
|
||||
uid: overrides.uid ?? "uid-123",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/** Build a default set of spy-based callbacks. The `delay` resolves immediately. */
|
||||
function makeCallbacks(connection?: ReturnType<typeof makeConnection>): ReconnectCallbacks & {
|
||||
/** Direct access to the underlying sinon stubs for assertions */
|
||||
stubs: Record<string, sinon.SinonStub>
|
||||
} {
|
||||
const conn = connection ?? makeConnection()
|
||||
const stubs: Record<string, sinon.SinonStub> = {
|
||||
findConnection: sinon.stub().returns(conn),
|
||||
deleteConnection: sinon.stub().resolves(),
|
||||
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 }
|
||||
}
|
||||
|
||||
/** A config with a small max so tests don't loop many times. */
|
||||
const TEST_CONFIG: ReconnectConfig = {
|
||||
maxAttempts: 3,
|
||||
getDelayMs: (attempt) => 100 * 2 ** attempt, // 100, 200, 400
|
||||
}
|
||||
|
||||
describe("StreamableHttpReconnectHandler", () => {
|
||||
let sandbox: sinon.SinonSandbox
|
||||
|
||||
beforeEach(() => {
|
||||
sandbox = sinon.createSandbox()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
sandbox.restore()
|
||||
})
|
||||
|
||||
// ── basics ──────────────────────────────────────────────────────────
|
||||
|
||||
it("should export sensible defaults", () => {
|
||||
DEFAULT_RECONNECT_CONFIG.maxAttempts.should.equal(6)
|
||||
DEFAULT_RECONNECT_CONFIG.getDelayMs(0).should.equal(2000)
|
||||
DEFAULT_RECONNECT_CONFIG.getDelayMs(1).should.equal(4000)
|
||||
DEFAULT_RECONNECT_CONFIG.getDelayMs(2).should.equal(8000)
|
||||
})
|
||||
|
||||
// ── no-op cases ─────────────────────────────────────────────────────
|
||||
|
||||
it("should do nothing when findConnection returns undefined", async () => {
|
||||
const cbs = makeCallbacks()
|
||||
cbs.stubs.findConnection.returns(undefined)
|
||||
const handler = new StreamableHttpReconnectHandler("test-server", cbs, TEST_CONFIG)
|
||||
|
||||
await handler.handleError(new Error("boom"))
|
||||
|
||||
cbs.stubs.deleteConnection.called.should.be.false()
|
||||
cbs.stubs.connectToServer.called.should.be.false()
|
||||
handler.attemptCount.should.equal(0)
|
||||
})
|
||||
|
||||
it("should skip reconnect when server is disabled", async () => {
|
||||
const conn = makeConnection({ disabled: true })
|
||||
const cbs = makeCallbacks(conn)
|
||||
const handler = new StreamableHttpReconnectHandler("test-server", cbs, TEST_CONFIG)
|
||||
|
||||
await handler.handleError(new Error("boom"))
|
||||
|
||||
cbs.stubs.deleteConnection.called.should.be.false()
|
||||
handler.attemptCount.should.equal(0)
|
||||
})
|
||||
|
||||
it("should skip reconnect when server is already connecting", async () => {
|
||||
const conn = makeConnection({ status: "connecting" })
|
||||
const cbs = makeCallbacks(conn)
|
||||
const handler = new StreamableHttpReconnectHandler("test-server", cbs, TEST_CONFIG)
|
||||
|
||||
await handler.handleError(new Error("boom"))
|
||||
|
||||
cbs.stubs.deleteConnection.called.should.be.false()
|
||||
handler.attemptCount.should.equal(0)
|
||||
})
|
||||
|
||||
// ── successful reconnect ────────────────────────────────────────────
|
||||
|
||||
it("should reconnect on first error and reset attempt counter", async () => {
|
||||
const conn = makeConnection()
|
||||
const cbs = makeCallbacks(conn)
|
||||
const handler = new StreamableHttpReconnectHandler("test-server", cbs, TEST_CONFIG)
|
||||
|
||||
await handler.handleError(new Error("connection lost"))
|
||||
|
||||
// Should have called delete + connect
|
||||
cbs.stubs.deleteConnection.calledOnce.should.be.true()
|
||||
cbs.stubs.connectToServer.calledOnce.should.be.true()
|
||||
// Counter resets to 0 after success
|
||||
handler.attemptCount.should.equal(0)
|
||||
// Status was set to "connecting" during the attempt
|
||||
cbs.stubs.notifyWebviewOfServerChanges.called.should.be.true()
|
||||
})
|
||||
|
||||
it("should use the configured delay for each attempt", async () => {
|
||||
const conn = makeConnection()
|
||||
const cbs = makeCallbacks(conn)
|
||||
// Make connectToServer fail on first call, succeed on second
|
||||
cbs.stubs.connectToServer.onFirstCall().rejects(new Error("fail"))
|
||||
cbs.stubs.connectToServer.onSecondCall().resolves()
|
||||
const handler = new StreamableHttpReconnectHandler("test-server", cbs, TEST_CONFIG)
|
||||
|
||||
// Single handleError call — the retry loop handles both attempts internally
|
||||
await handler.handleError(new Error("err1"))
|
||||
|
||||
// Initial backoff delay (attempt 0 → 100ms), then retry delay (attempt 1 → 200ms)
|
||||
cbs.stubs.delay.callCount.should.equal(2)
|
||||
cbs.stubs.delay.firstCall.args[0].should.equal(100)
|
||||
cbs.stubs.delay.secondCall.args[0].should.equal(200)
|
||||
|
||||
// Second connect succeeded → counter reset
|
||||
handler.attemptCount.should.equal(0)
|
||||
cbs.stubs.connectToServer.callCount.should.equal(2)
|
||||
})
|
||||
|
||||
// ── exhausted retries ───────────────────────────────────────────────
|
||||
|
||||
it("should mark server as disconnected after maxAttempts exhausted", async () => {
|
||||
const conn = makeConnection()
|
||||
const cbs = makeCallbacks(conn)
|
||||
// All reconnect attempts fail
|
||||
cbs.stubs.connectToServer.rejects(new Error("still broken"))
|
||||
|
||||
// After deleteConnection, findConnection returns undefined (old conn deleted)
|
||||
// but connectToServer may leave a partial connection, so simulate that
|
||||
const partialConn = makeConnection({ uid: "uid-partial" })
|
||||
let deleted = false
|
||||
cbs.stubs.findConnection.callsFake(() => {
|
||||
if (!deleted) return conn
|
||||
return partialConn
|
||||
})
|
||||
cbs.stubs.deleteConnection.callsFake(async () => {
|
||||
deleted = true
|
||||
})
|
||||
|
||||
const handler = new StreamableHttpReconnectHandler("test-server", cbs, TEST_CONFIG)
|
||||
|
||||
// Single handleError call exhausts all 3 attempts via the internal retry loop
|
||||
await handler.handleError(new Error("transport error"))
|
||||
|
||||
handler.attemptCount.should.equal(TEST_CONFIG.maxAttempts)
|
||||
|
||||
// connectToServer was called maxAttempts times (3)
|
||||
cbs.stubs.connectToServer.callCount.should.equal(TEST_CONFIG.maxAttempts)
|
||||
|
||||
// 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")
|
||||
})
|
||||
|
||||
it("should mark disconnected even when no connection exists after exhaustion", async () => {
|
||||
const conn = makeConnection()
|
||||
const cbs = makeCallbacks(conn)
|
||||
cbs.stubs.connectToServer.rejects(new Error("broken"))
|
||||
|
||||
// After deleteConnection, findConnection returns undefined
|
||||
let deleted = false
|
||||
cbs.stubs.findConnection.callsFake(() => (deleted ? undefined : conn))
|
||||
cbs.stubs.deleteConnection.callsFake(async () => {
|
||||
deleted = true
|
||||
})
|
||||
|
||||
const handler = new StreamableHttpReconnectHandler("test-server", cbs, TEST_CONFIG)
|
||||
|
||||
await handler.handleError(new Error("transport error"))
|
||||
|
||||
handler.attemptCount.should.equal(TEST_CONFIG.maxAttempts)
|
||||
// No partial connection to mark, but notifyWebview should still be called
|
||||
cbs.stubs.notifyWebviewOfServerChanges.called.should.be.true()
|
||||
// appendErrorMessage not called since there's no connection to append to
|
||||
cbs.stubs.appendErrorMessage.called.should.be.false()
|
||||
})
|
||||
|
||||
it("should exhaust retries when called with attempts already at max", async () => {
|
||||
const conn = makeConnection()
|
||||
const cbs = makeCallbacks(conn)
|
||||
const config: ReconnectConfig = { maxAttempts: 0, getDelayMs: () => 0 }
|
||||
const handler = new StreamableHttpReconnectHandler("test-server", cbs, config)
|
||||
|
||||
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()
|
||||
})
|
||||
|
||||
// ── connectToServer failure retries automatically ───────────────────
|
||||
|
||||
it("should retry connectToServer on failure without relying on onerror", async () => {
|
||||
const conn = makeConnection()
|
||||
const cbs = makeCallbacks(conn)
|
||||
// First two attempts fail, third succeeds
|
||||
cbs.stubs.connectToServer.onFirstCall().rejects(new Error("fail 1"))
|
||||
cbs.stubs.connectToServer.onSecondCall().rejects(new Error("fail 2"))
|
||||
cbs.stubs.connectToServer.onThirdCall().resolves()
|
||||
const handler = new StreamableHttpReconnectHandler("test-server", cbs, TEST_CONFIG)
|
||||
|
||||
// A single handleError call should retry all 3 attempts internally
|
||||
await handler.handleError(new Error("transport error"))
|
||||
|
||||
cbs.stubs.connectToServer.callCount.should.equal(3)
|
||||
handler.attemptCount.should.equal(0) // reset on success
|
||||
|
||||
// Delays: initial(100) + retry(200) + retry(400)
|
||||
cbs.stubs.delay.callCount.should.equal(3)
|
||||
cbs.stubs.delay.getCall(0).args[0].should.equal(100)
|
||||
cbs.stubs.delay.getCall(1).args[0].should.equal(200)
|
||||
cbs.stubs.delay.getCall(2).args[0].should.equal(400)
|
||||
})
|
||||
|
||||
// ── connection replaced mid-reconnect ───────────────────────────────
|
||||
|
||||
it("should abort reconnect if connection was replaced during delay", async () => {
|
||||
const conn = makeConnection()
|
||||
const differentConn = makeConnection({ uid: "uid-replaced" })
|
||||
const cbs = makeCallbacks(conn)
|
||||
// After the delay, findConnection returns a different object
|
||||
cbs.stubs.findConnection.onFirstCall().returns(conn)
|
||||
cbs.stubs.findConnection.onSecondCall().returns(differentConn)
|
||||
const handler = new StreamableHttpReconnectHandler("test-server", cbs, TEST_CONFIG)
|
||||
|
||||
await handler.handleError(new Error("err"))
|
||||
|
||||
// delay was called, but delete/connect were NOT (connection was replaced)
|
||||
cbs.stubs.delay.calledOnce.should.be.true()
|
||||
cbs.stubs.deleteConnection.called.should.be.false()
|
||||
cbs.stubs.connectToServer.called.should.be.false()
|
||||
})
|
||||
|
||||
it("should abort reconnect if connection disappeared during delay", async () => {
|
||||
const conn = makeConnection()
|
||||
const cbs = makeCallbacks(conn)
|
||||
cbs.stubs.findConnection.onFirstCall().returns(conn)
|
||||
cbs.stubs.findConnection.onSecondCall().returns(undefined)
|
||||
const handler = new StreamableHttpReconnectHandler("test-server", cbs, TEST_CONFIG)
|
||||
|
||||
await handler.handleError(new Error("err"))
|
||||
|
||||
cbs.stubs.deleteConnection.called.should.be.false()
|
||||
cbs.stubs.connectToServer.called.should.be.false()
|
||||
})
|
||||
|
||||
// ── resetAttempts ───────────────────────────────────────────────────
|
||||
|
||||
it("should allow manual reset of attempt counter", async () => {
|
||||
const conn = makeConnection()
|
||||
const cbs = makeCallbacks(conn)
|
||||
cbs.stubs.connectToServer.rejects(new Error("fail"))
|
||||
const handler = new StreamableHttpReconnectHandler("test-server", cbs, TEST_CONFIG)
|
||||
|
||||
// After exhausting all retries, attemptCount should be at max
|
||||
await handler.handleError(new Error("err1"))
|
||||
handler.attemptCount.should.equal(TEST_CONFIG.maxAttempts)
|
||||
|
||||
handler.resetAttempts()
|
||||
handler.attemptCount.should.equal(0)
|
||||
})
|
||||
|
||||
// ── error message formatting ────────────────────────────────────────
|
||||
|
||||
it("should use string coercion for non-Error objects on exhaustion", async () => {
|
||||
const conn = makeConnection()
|
||||
const cbs = makeCallbacks(conn)
|
||||
cbs.stubs.connectToServer.rejects(new Error("fail"))
|
||||
const config: ReconnectConfig = { maxAttempts: 0, getDelayMs: () => 0 }
|
||||
const handler = new StreamableHttpReconnectHandler("test-server", cbs, config)
|
||||
|
||||
// Pass a plain string as the error
|
||||
await handler.handleError("string-error-message")
|
||||
|
||||
cbs.stubs.appendErrorMessage.calledOnce.should.be.true()
|
||||
cbs.stubs.appendErrorMessage.firstCall.args[1].should.equal("string-error-message")
|
||||
})
|
||||
})
|
||||
@@ -162,6 +162,20 @@ export class TelemetryService {
|
||||
CONTEXT_MODIFICATIONS_TOTAL: "cline.hooks.context_modifications.total",
|
||||
CACHE_ACCESSES_TOTAL: "cline.hooks.cache.accesses.total",
|
||||
},
|
||||
AI_OUTPUT: {
|
||||
ACCEPTED_LINES_ADDED: "cline.ai_output.accepted.lines_added.total",
|
||||
ACCEPTED_LINES_DELETED: "cline.ai_output.accepted.lines_deleted.total",
|
||||
ACCEPTED_LINES_CHANGED: "cline.ai_output.accepted.lines_changed.total",
|
||||
ACCEPTED_FILES_CREATED: "cline.ai_output.accepted.files_created.total",
|
||||
ACCEPTED_FILES_DELETED: "cline.ai_output.accepted.files_deleted.total",
|
||||
ACCEPTED_FILES_MOVED: "cline.ai_output.accepted.files_moved.total",
|
||||
REJECTED_LINES_ADDED: "cline.ai_output.rejected.lines_added.total",
|
||||
REJECTED_LINES_DELETED: "cline.ai_output.rejected.lines_deleted.total",
|
||||
REJECTED_LINES_CHANGED: "cline.ai_output.rejected.lines_changed.total",
|
||||
REJECTED_FILES_CREATED: "cline.ai_output.rejected.files_created.total",
|
||||
REJECTED_FILES_DELETED: "cline.ai_output.rejected.files_deleted.total",
|
||||
REJECTED_FILES_MOVED: "cline.ai_output.rejected.files_moved.total",
|
||||
},
|
||||
GRPC: {
|
||||
RESPONSE_SIZE_BYTES: "cline.grpc.response.size_bytes",
|
||||
},
|
||||
@@ -2177,6 +2191,108 @@ export class TelemetryService {
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Records when a file edit (write_to_file, replace_in_file, apply_patch) is accepted by the user
|
||||
* Tracks lines added, deleted, and changed for the accepted edit.
|
||||
*
|
||||
* @param args Properties for the accepted AI output event
|
||||
*/
|
||||
public captureAiOutputAccepted(args: {
|
||||
ulid: string
|
||||
tool: string
|
||||
provider?: string
|
||||
model?: string
|
||||
source: "agent" | "human"
|
||||
linesAdded: number
|
||||
linesDeleted: number
|
||||
linesChanged: number
|
||||
filesCreated?: number
|
||||
filesDeleted?: number
|
||||
filesMoved?: number
|
||||
}): void {
|
||||
this.capture({
|
||||
event: "task.ai_output.accepted",
|
||||
properties: {
|
||||
ulid: args.ulid,
|
||||
tool: args.tool,
|
||||
provider: args.provider,
|
||||
model: args.model,
|
||||
source: args.source,
|
||||
linesAdded: args.linesAdded,
|
||||
linesDeleted: args.linesDeleted,
|
||||
linesChanged: args.linesChanged,
|
||||
filesCreated: args.filesCreated ?? 0,
|
||||
filesDeleted: args.filesDeleted ?? 0,
|
||||
filesMoved: args.filesMoved ?? 0,
|
||||
},
|
||||
})
|
||||
|
||||
const attrs = { ulid: args.ulid, tool: args.tool, provider: args.provider, model: args.model, source: args.source }
|
||||
this.recordCounter(TelemetryService.METRICS.AI_OUTPUT.ACCEPTED_LINES_ADDED, args.linesAdded, attrs)
|
||||
this.recordCounter(TelemetryService.METRICS.AI_OUTPUT.ACCEPTED_LINES_DELETED, args.linesDeleted, attrs)
|
||||
this.recordCounter(TelemetryService.METRICS.AI_OUTPUT.ACCEPTED_LINES_CHANGED, args.linesChanged, attrs)
|
||||
if (args.filesCreated) {
|
||||
this.recordCounter(TelemetryService.METRICS.AI_OUTPUT.ACCEPTED_FILES_CREATED, args.filesCreated, attrs)
|
||||
}
|
||||
if (args.filesDeleted) {
|
||||
this.recordCounter(TelemetryService.METRICS.AI_OUTPUT.ACCEPTED_FILES_DELETED, args.filesDeleted, attrs)
|
||||
}
|
||||
if (args.filesMoved) {
|
||||
this.recordCounter(TelemetryService.METRICS.AI_OUTPUT.ACCEPTED_FILES_MOVED, args.filesMoved, attrs)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Records when a file edit (write_to_file, replace_in_file, apply_patch) is rejected by the user
|
||||
* Tracks lines that would have been added, deleted, and changed.
|
||||
*
|
||||
* @param args Properties for the rejected AI output event
|
||||
*/
|
||||
public captureAiOutputRejected(args: {
|
||||
ulid: string
|
||||
tool: string
|
||||
provider?: string
|
||||
model?: string
|
||||
source: "agent" | "human"
|
||||
linesAdded: number
|
||||
linesDeleted: number
|
||||
linesChanged: number
|
||||
filesCreated?: number
|
||||
filesDeleted?: number
|
||||
filesMoved?: number
|
||||
}): void {
|
||||
this.capture({
|
||||
event: "task.ai_output.rejected",
|
||||
properties: {
|
||||
ulid: args.ulid,
|
||||
tool: args.tool,
|
||||
provider: args.provider,
|
||||
model: args.model,
|
||||
source: args.source,
|
||||
linesAdded: args.linesAdded,
|
||||
linesDeleted: args.linesDeleted,
|
||||
linesChanged: args.linesChanged,
|
||||
filesCreated: args.filesCreated ?? 0,
|
||||
filesDeleted: args.filesDeleted ?? 0,
|
||||
filesMoved: args.filesMoved ?? 0,
|
||||
},
|
||||
})
|
||||
|
||||
const attrs = { ulid: args.ulid, tool: args.tool, provider: args.provider, model: args.model, source: args.source }
|
||||
this.recordCounter(TelemetryService.METRICS.AI_OUTPUT.REJECTED_LINES_ADDED, args.linesAdded, attrs)
|
||||
this.recordCounter(TelemetryService.METRICS.AI_OUTPUT.REJECTED_LINES_DELETED, args.linesDeleted, attrs)
|
||||
this.recordCounter(TelemetryService.METRICS.AI_OUTPUT.REJECTED_LINES_CHANGED, args.linesChanged, attrs)
|
||||
if (args.filesCreated) {
|
||||
this.recordCounter(TelemetryService.METRICS.AI_OUTPUT.REJECTED_FILES_CREATED, args.filesCreated, attrs)
|
||||
}
|
||||
if (args.filesDeleted) {
|
||||
this.recordCounter(TelemetryService.METRICS.AI_OUTPUT.REJECTED_FILES_DELETED, args.filesDeleted, attrs)
|
||||
}
|
||||
if (args.filesMoved) {
|
||||
this.recordCounter(TelemetryService.METRICS.AI_OUTPUT.REJECTED_FILES_MOVED, args.filesMoved, attrs)
|
||||
}
|
||||
}
|
||||
|
||||
public captureHostEvent(name: string, content: string) {
|
||||
this.capture({
|
||||
event: TelemetryService.EVENTS.HOST.DETECTED,
|
||||
|
||||
@@ -110,8 +110,8 @@ function setupHostProvider(extensionContext: any, extensionDir: string, dataDir:
|
||||
}
|
||||
const createCommentReview = () => new ExternalCommentReviewController()
|
||||
const createTerminalManager = () => new StandaloneTerminalManager()
|
||||
const getCallbackUrl = (path: string): Promise<string> => {
|
||||
return AuthHandler.getInstance().getCallbackUrl(path)
|
||||
const getCallbackUrl = (path: string, preferredPort?: number): Promise<string> => {
|
||||
return AuthHandler.getInstance().getCallbackUrl(path, preferredPort)
|
||||
}
|
||||
// cline-core expects the binaries to be unpacked in the directory where it is running.
|
||||
const getBinaryLocation = async (name: string): Promise<string> => path.join(process.cwd(), name)
|
||||
|
||||
Reference in New Issue
Block a user