Compare commits

...
Author SHA1 Message Date
Robin Newhouse c6ed3c3bb8 fix: reset loop detector timer during extended thinking
Reasoning chunks now call onReasoningActivity() which resets the
elapsed-time clock without clearing the char count. This prevents
false positives where extended thinking (>60s) followed by legitimate
large text output would incorrectly trigger the loop detector.

Made-with: Cursor
2026-03-05 10:15:08 -08:00
Robin Newhouse c7b36ed518 fix: detect and abort in-generation text loops during streaming
Models (especially Gemini Flash) can enter degenerate text loops within
a single generation, producing thousands of lines of repetitive text
without ever emitting a tool call. This burns through context window
and causes timeouts.

Adds InGenerationLoopDetector that tracks text output and time since
last tool activity during streaming. Aborts the stream when both
thresholds are exceeded (15K chars AND 60s), truncates the garbage
text, and lets the existing noToolsUsed recovery path handle the retry.

Thresholds derived from analysis of 30 SWE-bench runs (20 passed,
10 failed/looping) with zero false positives.

Made-with: Cursor
2026-03-05 00:37:49 -08:00
Dominic Cooney 2baf966db7 fix: handle streamableHttp reconnects and preserve OAuth redirect URIs across sessions (#9642)
- Make redirect URIs with dynamic ports valid, or reregister.
- Handle reconnects for streaming HTTP MCP servers.
2026-03-05 09:00:20 +09:00
alex-lumandTomás Barreiro 152ba674da feat: add OTel tracking for AI output line/file metrics (lines added/deleted/changed, files created/deleted/moved) (#9562)
* feat: add telemetry for AI output accepted/rejected across tool handlers

Add line-level diff stats and file operation tracking to telemetry
events when users accept or reject tool outputs. Introduces a shared
`computeLineDiffStats` utility and `captureAiOutputAccepted`/
`captureAiOutputRejected` methods on the telemetry service, wired
into ApplyPatch, WriteToFile, ExecuteCommand, InsertContent, and
SearchAndReplace handlers.

* feat(telemetry): add source tracking for agent vs human edits

Add telemetry differentiation between agent-generated changes and
human modifications to capture more granular edit metrics:

- Add 'source' field to captureAiOutputAccepted telemetry events
- Track human edits by computing diff stats between agent's proposed
  content and final saved content
- Apply source tracking to ApplyPatchHandler and WriteToFileToolHandler
- Enable separate analytics for agent vs human contributions

This allows measuring how often and to what extent users modify
AI-generated code, providing insights into AI output quality and
user trust patterns.

* refactor(telemetry): centralize ai output attribution across file edit handlers

- add shared `AiOutputTelemetry` utility for accepted/rejected events
- refactor `WriteToFileToolHandler` and `ApplyPatchHandler` to use shared helpers
- preserve existing telemetry behavior (`source: "agent" | "human"`) while reducing duplication
- keep line diff/file-op attribution semantics unchanged

* fix(telemetry): use pre-save content for human edit line diff stats

The source:"human" telemetry was diffing agent content against
finalContent (post-save), which includes auto-formatting changes
from the editor. This inflated linesChanged/linesDeleted counts
when the formatter modified lines alongside the user's actual edits.

Use diff.applyPatch() to reconstruct the user's pre-save content
from the existing userEdits patch, excluding formatter noise from
the line diff stats.

* fixing syntax error

* refactor(telemetry): make next-hunk bounds check explicit

* remove comment

Co-authored-by: Tomás Barreiro <52393857+BarreiroT@users.noreply.github.com>

---------

Co-authored-by: Tomás Barreiro <52393857+BarreiroT@users.noreply.github.com>
2026-03-04 14:45:57 -08:00
MaxandMax Paulus 🥪 12f5dc2e9e update changelog and bump version numbers (#9664)
Co-authored-by: Max Paulus 🥪 <max@cline.bot>
2026-03-04 13:39:15 -08:00
Saoud Rizwan 28d806a4e4 fix(openrouter): stop sending max_tokens in stream requests (#9634) 2026-03-04 18:48:59 +01:00
AraandClaude Opus 4.6 e92c7de8c0 Restrict /test-jetbrains workflow trigger to authorized users (#9657)
Add author_association check so only MEMBER, OWNER, and COLLABORATOR
users can trigger the JetBrains test workflow via issue comments.
Previously any GitHub user could trigger it, allowing unauthorized
use of the GitHub App token and Actions minutes.

Fixes GHSA-5fq9-fh5x-w83r (SEC-29)

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 09:13:41 -08:00
CandiedUniverse 718e5b53f6 Add model identifier to the JSON payload that hooks receive (#9646)
* Add provider/model context to all hook payloads

* Fixes as per Greptile feedback

* Further fixes as per Greptile feedback

* Further fixes as per Greptile feedback

* further fixes as per Greptile feedback

* Fix flapping hooks tests on Windows
2026-03-03 19:08:41 -08:00
44 changed files with 1897 additions and 64 deletions
@@ -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
+21
View File
@@ -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
+14
View File
@@ -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
View File
@@ -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",
+20 -3
View File
@@ -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
+3 -3
View File
@@ -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
View File
@@ -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": [
".",
+6
View File
@@ -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,
+14 -1
View File
@@ -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")
})
})
+2 -1
View File
@@ -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",
+2 -1
View File
@@ -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"
+2 -1
View File
@@ -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",
+23 -4
View File
@@ -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"
+3
View File
@@ -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)
+14
View File
@@ -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,
}
}
}
+40
View File
@@ -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
View File
@@ -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
+48
View File
@@ -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
}
}
+2
View File
@@ -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()
})
})
+29
View File
@@ -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
View File
@@ -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}`)
+9 -5
View File
@@ -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)
+5 -3
View File
@@ -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
View File
@@ -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:
+42 -3
View File
@@ -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")
})
})
+116
View File
@@ -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,
+2 -2
View File
@@ -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)