mirror of
https://github.com/cline/cline.git
synced 2026-09-15 04:14:34 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e143b3d1ba | ||
|
|
2eab216815 | ||
|
|
e57174eec8 | ||
|
|
ad4631c682 | ||
|
|
aae23e8685 | ||
|
|
0ff27591d5 | ||
|
|
23127f22de | ||
|
|
57ed14d1b1 | ||
|
|
a769585406 | ||
|
|
c44b29b002 | ||
|
|
bb4e397a51 | ||
|
|
9824d8d476 | ||
|
|
a46c5288ca | ||
|
|
91b947de69 | ||
|
|
7b25a21b26 | ||
|
|
1d1071dcf5 | ||
|
|
8d07b7d6cb | ||
|
|
aacc69a558 | ||
|
|
a27bedffb6 | ||
|
|
bbeecefe84 | ||
|
|
50b57f472f | ||
|
|
0e7e0099cd | ||
|
|
44cbfe9d9c | ||
|
|
4ecccf5105 | ||
|
|
6d29bc6551 | ||
|
|
820057308a | ||
|
|
7355f7e3b9 | ||
|
|
156f18f7b2 | ||
|
|
6129caa423 | ||
|
|
ffa4785f43 | ||
|
|
92ec126dca | ||
|
|
c4c8b16afb | ||
|
|
2eed79dcd7 | ||
|
|
d39a53feed | ||
|
|
cae9bff416 | ||
|
|
36618fb327 | ||
|
|
a335b5cb3e | ||
|
|
bce71b4448 |
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"cline": minor
|
||||
---
|
||||
|
||||
Adds Messages API support to Oracle Code Assist, adding functionality for Claude models
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"cline": patch
|
||||
---
|
||||
|
||||
Add /q command to quit CLI
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"cline": patch
|
||||
---
|
||||
|
||||
Add Additional Markdown Formatting in CLI
|
||||
@@ -1,15 +0,0 @@
|
||||
---
|
||||
"cline": patch
|
||||
---
|
||||
|
||||
fix: bypass git hooks on checkpoint initial commit
|
||||
|
||||
The initial checkpoint commit in `CheckpointGitOperations.ts` was missing
|
||||
the `--no-verify` flag, causing Cline to fail to initialize when users
|
||||
have global git pre-commit hooks (e.g., conventional commits enforcement).
|
||||
|
||||
Subsequent checkpoint commits already used `--no-verify` (in
|
||||
`CheckpointTracker.ts`), but the initial empty commit did not, creating
|
||||
an inconsistency.
|
||||
|
||||
Fixes #9672
|
||||
@@ -1,16 +0,0 @@
|
||||
---
|
||||
"cline": patch
|
||||
---
|
||||
|
||||
fix: resolve "Could not find the file context" error in Explain Changes comment replies
|
||||
|
||||
When clicking a line to start a discussion in the Explain Changes diff view, replies would
|
||||
intermittently fail with "Error: Could not find the file context". This happened because
|
||||
the reply handler and the `onCommentStart` callback were using a strict `absolutePath`-only
|
||||
match to look up files in `changedFiles`, while the VS Code comment controller may return
|
||||
paths in different formats (relative vs. absolute, different separators on Windows, etc.).
|
||||
|
||||
Fixed by adding a `relativePath` fallback in both lookup sites, making them consistent with
|
||||
the already-correct logic in `streamAIExplanationComments`.
|
||||
|
||||
Fixes #9382
|
||||
@@ -1,18 +0,0 @@
|
||||
---
|
||||
"cline": patch
|
||||
---
|
||||
|
||||
fix: clear all OCA secrets on auth refresh failure to prevent re-auth loop
|
||||
|
||||
When OCA (Oracle Code Assist) token refresh fails with 400 invalid_grant or 401,
|
||||
the stale secrets were not fully cleared from storage. The `clearAuth()` method
|
||||
only cleared `ocaApiKey` and `ocaRefreshToken`, leaving legacy secrets
|
||||
`ocaAccessToken` and `ocaTokenSet` (set by older Cline versions) in VS Code's
|
||||
secret storage. These stale secrets caused every subsequent re-auth attempt to
|
||||
fail in a loop, requiring manual SQLite deletion to recover.
|
||||
|
||||
Fix:
|
||||
- Added `ocaAccessToken` and `ocaTokenSet` to `SecretKeys` in `state-keys.ts`
|
||||
- Updated `OcaAuthProvider.clearAuth()` to clear all 4 OCA secrets
|
||||
|
||||
Fixes #9567
|
||||
@@ -1,9 +0,0 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Fix OpenAI-compatible `gpt-oss` native tool mode so file editing works reliably:
|
||||
|
||||
- Enable `apply_patch` for `gpt-oss` models when using native GPT-5 prompt variants.
|
||||
- Add regression tests covering model family selection and tool availability.
|
||||
- Add a smoke-test scenario for OpenAI-compatible `gpt-oss` file editing and improve the smoke runner for per-scenario auth/env requirements.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Fix a streaming crash when a chunk has usage data but no `delta` by guarding reasoning field checks in provider handlers. Add regression tests for OpenRouter, Cline, Vercel AI Gateway, and Fireworks handlers to cover usage-only chunks.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"cline": patch
|
||||
---
|
||||
|
||||
Use JSON_SCHEMA for yaml.load to prevent unsafe deserialization from untrusted sources
|
||||
@@ -1,4 +0,0 @@
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Add missing smoke evaluation npm scripts so documented commands like `npm run eval:smoke:run` work from the repository root.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"cline": patch
|
||||
---
|
||||
|
||||
Add automatic retries (up to 3 attempts) for smoke test CI jobs to reduce flaky failures
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"cline": patch
|
||||
---
|
||||
|
||||
add focus ring on action buttons
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"cline": patch
|
||||
---
|
||||
|
||||
fix acp auth check so acp mode can be used with more providers
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Update SambaNova Provider models list and add temperature for models
|
||||
@@ -14,7 +14,6 @@ This is a VS Code extension. Read `.clinerules/general.md` for tribal knowledge
|
||||
- **Watch**: `npm run watch` (extension + webview).
|
||||
- **Protos**: `npm run protos` — run **immediately** after any `.proto` change. Generates into `src/shared/proto/`, `src/generated/`.
|
||||
- **Tests**: `npm run test:unit`. After prompt/tool changes: `UPDATE_SNAPSHOTS=true npm run test:unit`.
|
||||
- **Changesets**: `npm run changeset` for user-facing changes (patch only, never minor/major).
|
||||
|
||||
## Protobuf RPC Workflow (4 steps)
|
||||
1. **Define** in `proto/cline/*.proto`. Naming: `PascalCaseService`, `camelCase` RPCs, `PascalCase` Messages. Use `common.proto` shared types for simple data.
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
name: CLI TUI Tests
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
workflow_dispatch:
|
||||
workflow_call:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
cli-tui-tests:
|
||||
name: CLI TUI Tests
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build CLI
|
||||
run: npm run cli:build
|
||||
|
||||
- name: Run TUI Tests
|
||||
id: tui_tests
|
||||
run: |
|
||||
npm run test:e2e:cli:tui 2>&1 | tee tui-test-output.log
|
||||
exit_code=${PIPESTATUS[0]}
|
||||
echo "tui_exit_code=$exit_code" >> $GITHUB_OUTPUT
|
||||
exit $exit_code
|
||||
|
||||
- name: Write failure summary
|
||||
if: always() && steps.tui_tests.outcome != 'success' && steps.tui_tests.outcome != 'skipped'
|
||||
run: |
|
||||
echo "## ❌ CLI TUI Tests Failed" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "**Step outcome:** \`${{ steps.tui_tests.outcome }}\`" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "### Test Output" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo '```' >> $GITHUB_STEP_SUMMARY
|
||||
if [ -f tui-test-output.log ]; then
|
||||
cat tui-test-output.log >> $GITHUB_STEP_SUMMARY
|
||||
else
|
||||
echo "(no test output captured — process may have been killed before output was flushed)" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
echo '```' >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "### Debugging" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- **TUI traces** are attached as artifacts below — download and inspect them to see terminal state at the point of failure." >> $GITHUB_STEP_SUMMARY
|
||||
echo "- **To view a trace replay/Run a TUI Trace: ** run \`npx tui-test show-trace path/to/trace/file\` in your terminal" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- **Full test log** is also attached as an artifact." >> $GITHUB_STEP_SUMMARY
|
||||
echo "- Tests run with \`retries: 2\` so any failure shown is a consistent failure, not a flake." >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
- name: Upload TUI traces
|
||||
if: always() && steps.tui_tests.outcome != 'success' && steps.tui_tests.outcome != 'skipped'
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: tui-test-traces
|
||||
path: tests/e2e/cli/tui-traces/
|
||||
retention-days: 14
|
||||
if-no-files-found: warn
|
||||
|
||||
- name: Upload test log
|
||||
if: always() && steps.tui_tests.outcome != 'success' && steps.tui_tests.outcome != 'skipped'
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: tui-test-log
|
||||
path: tui-test-output.log
|
||||
retention-days: 14
|
||||
if-no-files-found: warn
|
||||
@@ -30,7 +30,11 @@ permissions:
|
||||
pull-requests: write # Required by nested reusable test workflow
|
||||
|
||||
jobs:
|
||||
cli-tui-tests:
|
||||
uses: ./.github/workflows/cli-tui-tests.yml
|
||||
|
||||
publish-main:
|
||||
needs: cli-tui-tests
|
||||
if: |
|
||||
github.repository == 'cline/cline' && (
|
||||
github.event_name == 'workflow_dispatch' &&
|
||||
@@ -44,6 +48,7 @@ jobs:
|
||||
confirm_publish: ${{ github.event.inputs.confirm_publish }}
|
||||
|
||||
publish-nightly:
|
||||
needs: cli-tui-tests
|
||||
if: |
|
||||
github.repository == 'cline/cline' && (
|
||||
github.event_name == 'schedule' ||
|
||||
|
||||
@@ -51,3 +51,8 @@ test-results
|
||||
|
||||
# Smoke test results (generated)
|
||||
evals/smoke-tests/results/
|
||||
|
||||
.tui-test
|
||||
secrets.json
|
||||
tui-traces
|
||||
tests/**/cache
|
||||
|
||||
@@ -1,5 +1,39 @@
|
||||
# Changelog
|
||||
|
||||
## [3.73.0]
|
||||
|
||||
### Added
|
||||
|
||||
- Added W&B Inference by CoreWeave as a new API provider with 17 models
|
||||
- Improved parallel tool calling support for OpenRouter and Cline providers
|
||||
|
||||
### Fixed
|
||||
|
||||
- Claude Code Provider: handle rate limit events, empty content arrays, error results, and unknown content types without crashing
|
||||
- Tool handlers (`read_file`, `list_files`, `list_code_definition_names`, `search_files`) now return graceful errors instead of crashing
|
||||
|
||||
## [3.72.0]
|
||||
|
||||
### Added
|
||||
|
||||
- Added Anthropic Opus 4.6 fast mode variants
|
||||
|
||||
### Fixed
|
||||
|
||||
- Resolved native tool placeholder interpolation in prompts
|
||||
- Gemini: capped Flash output tokens to 8192 across providers
|
||||
- Fixed Windows unit test path normalization
|
||||
- Fixed flaky hooks tests on Windows
|
||||
- Bedrock: handle thinking and redacted_thinking blocks correctly in message conversion and streaming
|
||||
- Prevent crash when `list_files` or `list_code_definition_names` receives a file path
|
||||
|
||||
### Changed
|
||||
|
||||
- Updated Jupyter Notebook GIFs
|
||||
- Markdown image loading now requires user consent
|
||||
- Added `.github/copilot-instructions.md` for coding agents
|
||||
- Hooks: reintroduced feature toggle
|
||||
|
||||
## [3.71.0]
|
||||
|
||||
### Added
|
||||
|
||||
@@ -1,5 +1,39 @@
|
||||
# cline
|
||||
|
||||
## [2.8.0]
|
||||
|
||||
### Added
|
||||
|
||||
- Added W&B Inference by CoreWeave as a new API provider with 17 models including DeepSeek-V3.1, Llama 4, and Qwen3-Coder
|
||||
- Added CLI TUI end-to-end test suite
|
||||
|
||||
### Fixed
|
||||
|
||||
- Claude Code: handle rate limit events, empty content arrays, error results, and unknown content types without crashing
|
||||
- CLI: `/q` and `/exit` slash commands now execute immediately on Enter without requiring the slash menu to be visible
|
||||
- CLI: slash command filtering now prioritizes exact and prefix matches over fuzzy matches
|
||||
|
||||
## [2.7.0]
|
||||
|
||||
### Added
|
||||
|
||||
- Added MCP add shortcuts for stdio and HTTP servers
|
||||
- Added `--continue` for the current directory
|
||||
- Added `--auto-condense` flag for AI-powered context compaction
|
||||
- Added `--hooks-dir` flag for runtime hook injection
|
||||
- Enabled error autocapture
|
||||
- Prompt rules now include test verification guidance and make `CLI_RULES` language-agnostic
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed remount behavior so TUI remounts only on width resize
|
||||
- Fixed startup prompt replay on resize remount
|
||||
- Fixed task flags so they are applied before the welcome TUI mounts
|
||||
|
||||
### Changed
|
||||
|
||||
- Hooks: reintroduced feature toggle
|
||||
|
||||
## [2.6.1]
|
||||
|
||||
### Added
|
||||
|
||||
@@ -162,6 +162,8 @@ When running **cline** with just a prompt (no subcommand), these options are ava
|
||||
|
||||
**-T**, **\--taskId** *id* : Resume an existing task by ID instead of starting a new one. The prompt becomes an optional follow-up message.
|
||||
|
||||
**\--continue** : Resume the most recent task from the current working directory instead of starting a new one.
|
||||
|
||||
# JSON OUTPUT FORMAT
|
||||
|
||||
When using **\--json**, each message is output as a JSON object with these fields:
|
||||
@@ -268,6 +270,9 @@ cline -T abc123def
|
||||
# Resume a task with a follow-up message
|
||||
cline -T abc123def "Now add unit tests for the changes"
|
||||
|
||||
# Resume the most recent task from the current directory
|
||||
cline --continue
|
||||
|
||||
# Resume in plan mode to review before continuing
|
||||
cline -T abc123def -p "What's left to do?"
|
||||
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "cline",
|
||||
"version": "2.6.1",
|
||||
"version": "2.8.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",
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
import { Text } from "ink"
|
||||
import { render } from "ink-testing-library"
|
||||
import React from "react"
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
|
||||
import { App } from "./App"
|
||||
|
||||
const CLEAR_SEQUENCE = "\x1b[2J\x1b[3J\x1b[H"
|
||||
|
||||
function setTerminalSize(columns: number, rows: number) {
|
||||
Object.defineProperty(process.stdout, "columns", {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: columns,
|
||||
})
|
||||
|
||||
Object.defineProperty(process.stdout, "rows", {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: rows,
|
||||
})
|
||||
}
|
||||
|
||||
function hasClearSequenceCall(calls: unknown[][]): boolean {
|
||||
return calls.some((call) => call[0] === CLEAR_SEQUENCE)
|
||||
}
|
||||
|
||||
vi.mock("./ChatView", () => ({
|
||||
ChatView: ({ controller, initialPrompt, initialImages }: any) => {
|
||||
React.useEffect(() => {
|
||||
if (initialPrompt || (initialImages && initialImages.length > 0)) {
|
||||
controller?.initTask(initialPrompt || "", initialImages)
|
||||
}
|
||||
}, [])
|
||||
|
||||
return React.createElement(Text, null, "ChatView")
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock("./TaskJsonView", () => ({
|
||||
TaskJsonView: () => React.createElement(Text, null, "TaskJsonView"),
|
||||
}))
|
||||
|
||||
vi.mock("./HistoryView", () => ({
|
||||
HistoryView: () => React.createElement(Text, null, "HistoryView"),
|
||||
}))
|
||||
|
||||
vi.mock("./ConfigView", () => ({
|
||||
ConfigView: () => React.createElement(Text, null, "ConfigView"),
|
||||
}))
|
||||
|
||||
vi.mock("./AuthView", () => ({
|
||||
AuthView: () => React.createElement(Text, null, "AuthView"),
|
||||
}))
|
||||
|
||||
vi.mock("../context/TaskContext", () => ({
|
||||
TaskContextProvider: ({ children }: any) => children,
|
||||
}))
|
||||
|
||||
vi.mock("../context/StdinContext", () => ({
|
||||
StdinProvider: ({ children }: any) => children,
|
||||
}))
|
||||
|
||||
describe("App startup prompt resize behavior", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
vi.restoreAllMocks()
|
||||
delete (process.stdout as any).columns
|
||||
delete (process.stdout as any).rows
|
||||
})
|
||||
|
||||
it("does not replay initialPrompt after a width resize", async () => {
|
||||
const initTask = vi.fn()
|
||||
setTerminalSize(120, 40)
|
||||
|
||||
const writeSpy = vi.spyOn(process.stdout, "write").mockImplementation(((...args: any[]) => {
|
||||
const callback = args.find((arg) => typeof arg === "function")
|
||||
if (callback) {
|
||||
callback()
|
||||
}
|
||||
return true
|
||||
}) as any)
|
||||
|
||||
const { unmount } = render(
|
||||
<App controller={{ initTask }} initialPrompt="hello" isRawModeSupported={true} view="welcome" />,
|
||||
)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(initTask).toHaveBeenCalledTimes(1)
|
||||
writeSpy.mockClear()
|
||||
|
||||
setTerminalSize(121, 40)
|
||||
process.stdout.emit("resize")
|
||||
await vi.advanceTimersByTimeAsync(350)
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
|
||||
expect(initTask).toHaveBeenCalledTimes(1)
|
||||
expect(hasClearSequenceCall(writeSpy.mock.calls as unknown[][])).toBe(true)
|
||||
|
||||
unmount()
|
||||
})
|
||||
|
||||
it("does not remount on height-only resize", async () => {
|
||||
const initTask = vi.fn()
|
||||
setTerminalSize(120, 40)
|
||||
|
||||
const writeSpy = vi.spyOn(process.stdout, "write").mockImplementation(((...args: any[]) => {
|
||||
const callback = args.find((arg) => typeof arg === "function")
|
||||
if (callback) {
|
||||
callback()
|
||||
}
|
||||
return true
|
||||
}) as any)
|
||||
|
||||
const { unmount } = render(
|
||||
<App controller={{ initTask }} initialPrompt="hello" isRawModeSupported={true} view="welcome" />,
|
||||
)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(initTask).toHaveBeenCalledTimes(1)
|
||||
writeSpy.mockClear()
|
||||
|
||||
setTerminalSize(120, 45)
|
||||
process.stdout.emit("resize")
|
||||
await vi.advanceTimersByTimeAsync(350)
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
|
||||
expect(initTask).toHaveBeenCalledTimes(1)
|
||||
expect(hasClearSequenceCall(writeSpy.mock.calls as unknown[][])).toBe(false)
|
||||
|
||||
unmount()
|
||||
})
|
||||
})
|
||||
@@ -4,7 +4,7 @@
|
||||
*/
|
||||
|
||||
import { Box, useApp } from "ink"
|
||||
import React, { ReactNode, useCallback, useState } from "react"
|
||||
import React, { ReactNode, useCallback, useEffect, useState } from "react"
|
||||
import { StdinProvider } from "../context/StdinContext"
|
||||
import { TaskContextProvider } from "../context/TaskContext"
|
||||
import { useTerminalSize } from "../hooks/useTerminalSize"
|
||||
@@ -146,6 +146,17 @@ const InternalApp: React.FC<AppProps> = ({
|
||||
const { resizeKey } = useTerminalSize()
|
||||
const [currentView, setCurrentView] = useState<ViewType>(initialView)
|
||||
const [selectedTaskId, setSelectedTaskId] = useState<string | undefined>(taskId)
|
||||
const [pendingInitialPrompt, setPendingInitialPrompt] = useState<string | undefined>(initialPrompt)
|
||||
const [pendingInitialImages, setPendingInitialImages] = useState<string[] | undefined>(initialImages)
|
||||
|
||||
useEffect(() => {
|
||||
if (!pendingInitialPrompt && (!pendingInitialImages || pendingInitialImages.length === 0)) {
|
||||
return
|
||||
}
|
||||
|
||||
setPendingInitialPrompt(undefined)
|
||||
setPendingInitialImages(undefined)
|
||||
}, [pendingInitialPrompt, pendingInitialImages])
|
||||
|
||||
const handleSelectTask = useCallback((taskId: string) => {
|
||||
setSelectedTaskId(taskId)
|
||||
@@ -253,8 +264,8 @@ const InternalApp: React.FC<AppProps> = ({
|
||||
) : (
|
||||
<ChatView
|
||||
controller={controller}
|
||||
initialImages={initialImages}
|
||||
initialPrompt={initialPrompt}
|
||||
initialImages={pendingInitialImages}
|
||||
initialPrompt={pendingInitialPrompt}
|
||||
onComplete={onComplete}
|
||||
onError={onError}
|
||||
onExit={onWelcomeExit}
|
||||
|
||||
@@ -12,7 +12,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
|
||||
import { ChatView } from "./ChatView"
|
||||
|
||||
// Helper to wait for async state updates
|
||||
const delay = (ms: number = 60) => new Promise((resolve) => setTimeout(resolve, ms))
|
||||
const delay = (ms = 60) => new Promise((resolve) => setTimeout(resolve, ms))
|
||||
|
||||
// Type for our exit mock function
|
||||
type ExitMockFn = ReturnType<typeof vi.fn> & (() => void)
|
||||
@@ -126,12 +126,16 @@ vi.mock("../utils/file-search", () => ({
|
||||
searchWorkspaceFiles: vi.fn(async () => []),
|
||||
}))
|
||||
|
||||
vi.mock("../utils/slash-commands", () => ({
|
||||
extractSlashQuery: vi.fn(() => ({ inSlashMode: false, query: "", slashIndex: -1 })),
|
||||
filterCommands: vi.fn(() => []),
|
||||
insertSlashCommand: vi.fn((text: string) => text),
|
||||
sortCommandsWorkflowsFirst: vi.fn((cmds: unknown[]) => cmds),
|
||||
}))
|
||||
vi.mock("../utils/slash-commands", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../utils/slash-commands")>()
|
||||
return {
|
||||
...actual,
|
||||
extractSlashQuery: vi.fn(() => ({ inSlashMode: false, query: "", slashIndex: -1 })),
|
||||
filterCommands: vi.fn(() => []),
|
||||
insertSlashCommand: vi.fn((text: string) => text),
|
||||
sortCommandsWorkflowsFirst: vi.fn((cmds: unknown[]) => cmds),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock("../utils/input", () => ({
|
||||
isMouseEscapeSequence: vi.fn(() => false),
|
||||
|
||||
@@ -108,7 +108,6 @@ import type { ClineAsk, ClineMessage } from "@shared/ExtensionMessage"
|
||||
import { getApiMetrics, getLastApiReqTotalTokens } from "@shared/getApiMetrics"
|
||||
import { EmptyRequest, StringRequest } from "@shared/proto/cline/common"
|
||||
import type { SlashCommandInfo } from "@shared/proto/cline/slash"
|
||||
import { CLI_ONLY_COMMANDS } from "@shared/slashCommands"
|
||||
import { getProviderDefaultModelId, getProviderModelIdKey } from "@shared/storage"
|
||||
import type { Mode } from "@shared/storage/types"
|
||||
import { execSync } from "child_process"
|
||||
@@ -137,7 +136,14 @@ import {
|
||||
} from "../utils/file-search"
|
||||
import { isMouseEscapeSequence } from "../utils/input"
|
||||
import { jsonParseSafe, parseImagesFromInput } from "../utils/parser"
|
||||
import { extractSlashQuery, filterCommands, insertSlashCommand, sortCommandsWorkflowsFirst } from "../utils/slash-commands"
|
||||
import {
|
||||
createCliOnlySlashCommands,
|
||||
extractSlashQuery,
|
||||
filterCommands,
|
||||
getStandaloneSlashCommandToExecute,
|
||||
insertSlashCommand,
|
||||
sortCommandsWorkflowsFirst,
|
||||
} from "../utils/slash-commands"
|
||||
import { waitFor } from "../utils/timeout"
|
||||
import { isFileEditTool, parseToolFromMessage } from "../utils/tools"
|
||||
import { shutdownEvent } from "../vscode-shim"
|
||||
@@ -403,7 +409,7 @@ export const ChatView: React.FC<ChatViewProps> = ({
|
||||
const PASTE_UPDATE_DEBOUNCE_MS = 50 // Debounce visual updates to avoid flicker
|
||||
|
||||
// Slash command state
|
||||
const [availableCommands, setAvailableCommands] = useState<SlashCommandInfo[]>([])
|
||||
const [availableCommands, setAvailableCommands] = useState<SlashCommandInfo[]>(() => createCliOnlySlashCommands())
|
||||
const [selectedSlashIndex, setSelectedSlashIndex] = useState(0)
|
||||
const [slashMenuDismissed, setSlashMenuDismissed] = useState(false)
|
||||
const lastSlashIndexRef = useRef<number>(-1)
|
||||
@@ -614,16 +620,15 @@ export const ChatView: React.FC<ChatViewProps> = ({
|
||||
try {
|
||||
const response = await getAvailableSlashCommands(ctrl, EmptyRequest.create())
|
||||
const cliCommands = response.commands.filter((cmd) => cmd.cliCompatible !== false)
|
||||
// Add CLI-only commands (like /settings) that are handled locally
|
||||
const cliOnlyCommands: SlashCommandInfo[] = CLI_ONLY_COMMANDS.map((cmd) => ({
|
||||
name: cmd.name,
|
||||
description: cmd.description || "",
|
||||
section: cmd.section || "default",
|
||||
cliCompatible: true,
|
||||
}))
|
||||
// Add CLI-only commands (like /settings) that are handled locally.
|
||||
// Seed these synchronously on first render so locally handled commands like
|
||||
// /q and /exit are immediately available, even before the async command
|
||||
// fetch completes. This avoids a race that can make the quit command tests
|
||||
// flaky on slower Windows CI runners.
|
||||
const cliOnlyCommands = createCliOnlySlashCommands()
|
||||
setAvailableCommands([...cliOnlyCommands, ...sortCommandsWorkflowsFirst(cliCommands)])
|
||||
} catch {
|
||||
// Fallback: commands will be empty, menu won't show
|
||||
// Keep CLI-only commands available even if backend command loading fails.
|
||||
}
|
||||
}
|
||||
loadCommands()
|
||||
@@ -843,6 +848,77 @@ export const ChatView: React.FC<ChatViewProps> = ({
|
||||
}, 150)
|
||||
}, [inkExit, onExit])
|
||||
|
||||
const handleCliOnlySlashCommand = useCallback(
|
||||
(commandName: string): boolean => {
|
||||
if (commandName === "help") {
|
||||
setActivePanel({ type: "help" })
|
||||
setTextInput("")
|
||||
setCursorPos(0)
|
||||
setSelectedSlashIndex(0)
|
||||
setSlashMenuDismissed(true)
|
||||
return true
|
||||
}
|
||||
|
||||
if (commandName === "settings") {
|
||||
setActivePanel({ type: "settings" })
|
||||
setTextInput("")
|
||||
setCursorPos(0)
|
||||
setSelectedSlashIndex(0)
|
||||
setSlashMenuDismissed(true)
|
||||
return true
|
||||
}
|
||||
|
||||
if (commandName === "models") {
|
||||
const apiConfig = StateManager.get().getApiConfiguration()
|
||||
const provider =
|
||||
mode === "act"
|
||||
? apiConfig.actModeApiProvider || apiConfig.planModeApiProvider
|
||||
: apiConfig.planModeApiProvider || apiConfig.actModeApiProvider
|
||||
const initialMode = !provider ? undefined : provider === "cline" ? "featured-models" : "model-picker"
|
||||
const initialModelKey = mode === "act" ? "actModelId" : "planModelId"
|
||||
setActivePanel({ type: "settings", initialMode, initialModelKey })
|
||||
setTextInput("")
|
||||
setCursorPos(0)
|
||||
setSelectedSlashIndex(0)
|
||||
setSlashMenuDismissed(true)
|
||||
return true
|
||||
}
|
||||
|
||||
if (commandName === "history") {
|
||||
setActivePanel({ type: "history" })
|
||||
setTextInput("")
|
||||
setCursorPos(0)
|
||||
setSelectedSlashIndex(0)
|
||||
setSlashMenuDismissed(true)
|
||||
return true
|
||||
}
|
||||
|
||||
if (commandName === "skills") {
|
||||
setActivePanel({ type: "skills" })
|
||||
setTextInput("")
|
||||
setCursorPos(0)
|
||||
setSelectedSlashIndex(0)
|
||||
setSlashMenuDismissed(true)
|
||||
return true
|
||||
}
|
||||
|
||||
if (commandName === "clear") {
|
||||
void clearViewAndResetTask()
|
||||
setSelectedSlashIndex(0)
|
||||
setSlashMenuDismissed(true)
|
||||
return true
|
||||
}
|
||||
|
||||
if (commandName === "exit" || commandName === "q") {
|
||||
handleExit()
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
},
|
||||
[clearViewAndResetTask, handleExit, mode, setCursorPos, setTextInput],
|
||||
)
|
||||
|
||||
// Get button config based on the last message state
|
||||
const buttonConfig = useMemo(() => {
|
||||
const lastMsg = messages[messages.length - 1] as ClineMessage | undefined
|
||||
@@ -1102,6 +1178,17 @@ export const ChatView: React.FC<ChatViewProps> = ({
|
||||
|
||||
const inSlashMenu = slashInfo.inSlashMode && filteredCommands.length > 0 && !slashMenuDismissed
|
||||
const inFileMenu = mentionInfo.inMentionMode && fileResults.length > 0 && !inSlashMenu
|
||||
const standaloneSlashCommand = getStandaloneSlashCommandToExecute({
|
||||
prompt,
|
||||
inSlashMode: slashInfo.inSlashMode,
|
||||
hasSlashMenu: inSlashMenu,
|
||||
hasPendingAsk: !!pendingAsk,
|
||||
isSpinnerActive,
|
||||
})
|
||||
|
||||
if (key.return && standaloneSlashCommand && handleCliOnlySlashCommand(standaloneSlashCommand)) {
|
||||
return
|
||||
}
|
||||
|
||||
// 5. Slash command menu navigation (takes priority over file menu)
|
||||
if (inSlashMenu) {
|
||||
@@ -1116,64 +1203,7 @@ export const ChatView: React.FC<ChatViewProps> = ({
|
||||
if (key.tab || key.return) {
|
||||
const cmd = filteredCommands[selectedSlashIndex]
|
||||
if (cmd) {
|
||||
// Handle CLI-only commands locally
|
||||
if (cmd.name === "help") {
|
||||
setActivePanel({ type: "help" })
|
||||
setTextInput("")
|
||||
setCursorPos(0)
|
||||
setSelectedSlashIndex(0)
|
||||
setSlashMenuDismissed(true)
|
||||
return
|
||||
}
|
||||
if (cmd.name === "settings") {
|
||||
setActivePanel({ type: "settings" })
|
||||
setTextInput("")
|
||||
setCursorPos(0)
|
||||
setSelectedSlashIndex(0)
|
||||
setSlashMenuDismissed(true)
|
||||
return
|
||||
}
|
||||
if (cmd.name === "models") {
|
||||
const apiConfig = StateManager.get().getApiConfiguration()
|
||||
// Use current mode's provider to determine picker type
|
||||
const provider =
|
||||
mode === "act"
|
||||
? apiConfig.actModeApiProvider || apiConfig.planModeApiProvider
|
||||
: apiConfig.planModeApiProvider || apiConfig.actModeApiProvider
|
||||
const initialMode = !provider ? undefined : provider === "cline" ? "featured-models" : "model-picker"
|
||||
// Set model for current mode (plan or act)
|
||||
const initialModelKey = mode === "act" ? "actModelId" : "planModelId"
|
||||
setActivePanel({ type: "settings", initialMode, initialModelKey })
|
||||
setTextInput("")
|
||||
setCursorPos(0)
|
||||
setSelectedSlashIndex(0)
|
||||
setSlashMenuDismissed(true)
|
||||
return
|
||||
}
|
||||
if (cmd.name === "history") {
|
||||
setActivePanel({ type: "history" })
|
||||
setTextInput("")
|
||||
setCursorPos(0)
|
||||
setSelectedSlashIndex(0)
|
||||
setSlashMenuDismissed(true)
|
||||
return
|
||||
}
|
||||
if (cmd.name === "skills") {
|
||||
setActivePanel({ type: "skills" })
|
||||
setTextInput("")
|
||||
setCursorPos(0)
|
||||
setSelectedSlashIndex(0)
|
||||
setSlashMenuDismissed(true)
|
||||
return
|
||||
}
|
||||
if (cmd.name === "clear") {
|
||||
clearViewAndResetTask()
|
||||
setSelectedSlashIndex(0)
|
||||
setSlashMenuDismissed(true)
|
||||
return
|
||||
}
|
||||
if (cmd.name === "exit" || cmd.name === "q") {
|
||||
handleExit()
|
||||
if (handleCliOnlySlashCommand(cmd.name)) {
|
||||
return
|
||||
}
|
||||
const newText = insertSlashCommand(textInput, slashInfo.slashIndex, cmd.name)
|
||||
|
||||
@@ -62,6 +62,8 @@ import {
|
||||
sapAiCoreModels,
|
||||
vertexDefaultModelId,
|
||||
vertexModels,
|
||||
wandbDefaultModelId,
|
||||
wandbModels,
|
||||
xaiDefaultModelId,
|
||||
xaiModels,
|
||||
} from "@/shared/api"
|
||||
@@ -101,6 +103,7 @@ export const providerModels: Record<string, { models: Record<string, unknown>; d
|
||||
sambanova: { models: sambanovaModels, defaultId: sambanovaDefaultModelId },
|
||||
sapaicore: { models: sapAiCoreModels, defaultId: sapAiCoreDefaultModelId },
|
||||
vertex: { models: vertexModels, defaultId: vertexDefaultModelId },
|
||||
wandb: { models: wandbModels, defaultId: wandbDefaultModelId },
|
||||
xai: { models: xaiModels, defaultId: xaiDefaultModelId },
|
||||
zai: { models: internationalZAiModels, defaultId: internationalZAiDefaultModelId },
|
||||
}
|
||||
|
||||
@@ -1,112 +1,38 @@
|
||||
import { render } from "ink-testing-library"
|
||||
// biome-ignore lint/correctness/noUnusedImports: React must be in scope for JSX in this test file.
|
||||
import React from "react"
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest"
|
||||
import { CLI_ONLY_COMMANDS } from "@shared/slashCommands"
|
||||
import { describe, expect, it } from "vitest"
|
||||
import { filterCommands, getStandaloneSlashCommandName, getStandaloneSlashCommandToExecute } from "../utils/slash-commands"
|
||||
|
||||
// Mock ink's useApp
|
||||
const mockExit = vi.fn()
|
||||
vi.mock("ink", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("ink")>()
|
||||
return {
|
||||
...actual,
|
||||
useApp: () => ({ exit: mockExit }),
|
||||
}
|
||||
})
|
||||
|
||||
// Mock child_process
|
||||
vi.mock("child_process", () => ({
|
||||
execSync: vi.fn().mockReturnValue(""),
|
||||
exec: vi.fn(),
|
||||
const cliOnlySlashCommands = CLI_ONLY_COMMANDS.map((cmd) => ({
|
||||
name: cmd.name,
|
||||
description: cmd.description || "",
|
||||
section: cmd.section || "default",
|
||||
cliCompatible: true,
|
||||
}))
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock("@/core/controller/slash/getAvailableSlashCommands", () => ({
|
||||
getAvailableSlashCommands: vi.fn().mockResolvedValue({ commands: [] }),
|
||||
}))
|
||||
|
||||
vi.mock("@/core/storage/StateManager", () => ({
|
||||
StateManager: {
|
||||
get: () => ({
|
||||
getGlobalSettingsKey: vi.fn().mockReturnValue("act"),
|
||||
getGlobalStateKey: vi.fn().mockReturnValue([]),
|
||||
getApiConfiguration: vi.fn().mockReturnValue({}),
|
||||
}),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock("@/services/telemetry", () => ({
|
||||
telemetryService: {
|
||||
captureHostEvent: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock("@shared/services/Session", () => ({
|
||||
Session: {
|
||||
get: () => ({
|
||||
getStats: vi.fn().mockReturnValue({}),
|
||||
}),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock("../context/TaskContext", () => ({
|
||||
useTaskContext: () => ({
|
||||
controller: {},
|
||||
clearState: vi.fn(),
|
||||
}),
|
||||
useTaskState: () => ({
|
||||
clineMessages: [],
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock("../hooks/useStateSubscriber", () => ({
|
||||
useIsSpinnerActive: () => ({ isActive: false, startTime: 0 }),
|
||||
}))
|
||||
|
||||
import { ChatView } from "./ChatView"
|
||||
|
||||
// Helper to wait for async state updates
|
||||
const delay = (ms = 60) => new Promise((resolve) => setTimeout(resolve, ms))
|
||||
|
||||
describe("Quit Command (/q and /exit)", () => {
|
||||
const mockOnExit = vi.fn()
|
||||
it("prioritizes /q as the selected slash command for an exact q query", () => {
|
||||
const result = filterCommands(cliOnlySlashCommands, "q")
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
expect(result[0]?.name).toBe("q")
|
||||
})
|
||||
|
||||
it("should exit the application when /q is selected from slash menu", async () => {
|
||||
const { stdin } = render(<ChatView onExit={mockOnExit} />)
|
||||
await delay()
|
||||
|
||||
// Type /q
|
||||
stdin.write("/q")
|
||||
await delay()
|
||||
|
||||
// Press Enter
|
||||
stdin.write("\r")
|
||||
|
||||
// handleExit has a 150ms timeout
|
||||
await delay(200)
|
||||
|
||||
expect(mockExit).toHaveBeenCalled()
|
||||
expect(mockOnExit).toHaveBeenCalled()
|
||||
it("detects /q as a standalone slash command", () => {
|
||||
expect(getStandaloneSlashCommandName("/q")).toBe("q")
|
||||
})
|
||||
|
||||
it("should exit the application when /exit is selected from slash menu", async () => {
|
||||
const { stdin } = render(<ChatView onExit={mockOnExit} />)
|
||||
await delay()
|
||||
it("detects /exit as a standalone slash command", () => {
|
||||
expect(getStandaloneSlashCommandName("/exit")).toBe("exit")
|
||||
})
|
||||
|
||||
// Type /exit
|
||||
stdin.write("/exit")
|
||||
await delay()
|
||||
|
||||
// Press Enter
|
||||
stdin.write("\r")
|
||||
|
||||
// handleExit has a 150ms timeout
|
||||
await delay(200)
|
||||
|
||||
expect(mockExit).toHaveBeenCalled()
|
||||
expect(mockOnExit).toHaveBeenCalled()
|
||||
it("resolves /q to direct execution when no slash menu is active", () => {
|
||||
expect(
|
||||
getStandaloneSlashCommandToExecute({
|
||||
prompt: "/q",
|
||||
inSlashMode: true,
|
||||
hasSlashMenu: false,
|
||||
hasPendingAsk: false,
|
||||
isSpinnerActive: false,
|
||||
}),
|
||||
).toBe("q")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -120,7 +120,9 @@ describe("SkillsPanelContent", () => {
|
||||
await delay()
|
||||
|
||||
// Navigate down to marketplace (past the one skill)
|
||||
stdin.write("\x1B[B") // Down arrow
|
||||
// Use vim-style navigation here because it's more deterministic in the
|
||||
// full suite than raw arrow escape sequences on Windows.
|
||||
stdin.write("j")
|
||||
await delay()
|
||||
|
||||
stdin.write("\r") // Enter
|
||||
|
||||
@@ -26,6 +26,9 @@ import { useCallback, useEffect, useRef, useState } from "react"
|
||||
* to unmount and remount everything from scratch. This resets Ink's internal tracking
|
||||
* AND re-renders Static content since the components are brand new instances.
|
||||
*
|
||||
* We only run this full recovery when terminal width changes. Height-only resizes do not
|
||||
* affect wrapping in the same way and should not restart the task view.
|
||||
*
|
||||
* Gemini CLI does the same thing in AppContainer.tsx: debounce 300ms, then
|
||||
* stdout.write(ansiEscapes.clearTerminal) + setHistoryRemountKey(prev => prev + 1).
|
||||
*
|
||||
@@ -41,6 +44,8 @@ export function useTerminalSize() {
|
||||
})
|
||||
const [resizeKey, setResizeKey] = useState(0)
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const previousColumnsRef = useRef(process.stdout.columns || 80)
|
||||
const pendingWidthRefreshRef = useRef(false)
|
||||
|
||||
const refreshAfterResize = useCallback(() => {
|
||||
// Clear terminal + scrollback to wipe stale content from old width
|
||||
@@ -56,17 +61,33 @@ export function useTerminalSize() {
|
||||
|
||||
useEffect(() => {
|
||||
function updateSize() {
|
||||
const nextColumns = process.stdout.columns || 80
|
||||
const nextRows = process.stdout.rows || 24
|
||||
const didWidthChange = nextColumns !== previousColumnsRef.current
|
||||
previousColumnsRef.current = nextColumns
|
||||
|
||||
setSize({
|
||||
columns: process.stdout.columns || 80,
|
||||
rows: process.stdout.rows || 24,
|
||||
columns: nextColumns,
|
||||
rows: nextRows,
|
||||
})
|
||||
|
||||
if (didWidthChange) {
|
||||
pendingWidthRefreshRef.current = true
|
||||
}
|
||||
|
||||
if (!pendingWidthRefreshRef.current) {
|
||||
return
|
||||
}
|
||||
|
||||
// Debounce: wait 300ms after last resize event to do full recovery
|
||||
if (debounceRef.current) {
|
||||
clearTimeout(debounceRef.current)
|
||||
}
|
||||
debounceRef.current = setTimeout(() => {
|
||||
refreshAfterResize()
|
||||
if (pendingWidthRefreshRef.current) {
|
||||
refreshAfterResize()
|
||||
pendingWidthRefreshRef.current = false
|
||||
}
|
||||
debounceRef.current = null
|
||||
}, 300)
|
||||
}
|
||||
@@ -76,6 +97,7 @@ export function useTerminalSize() {
|
||||
if (debounceRef.current) {
|
||||
clearTimeout(debounceRef.current)
|
||||
}
|
||||
pendingWidthRefreshRef.current = false
|
||||
}
|
||||
}, [refreshAfterResize])
|
||||
|
||||
|
||||
@@ -67,6 +67,22 @@ describe("CLI Commands", () => {
|
||||
.option("--config <path>", "Configuration directory")
|
||||
.action(() => {})
|
||||
|
||||
const mcpCommand = program.command("mcp").description("Manage MCP servers")
|
||||
mcpCommand
|
||||
.command("add")
|
||||
.description("Add an MCP server shortcut")
|
||||
.argument("<name>", "MCP server name")
|
||||
.argument("[targetOrCommand...]", "Command args for stdio, or URL for remote")
|
||||
.option("--type <type>", "Transport type", "stdio")
|
||||
.option("-c, --cwd <path>", "Working directory")
|
||||
.option("--config <path>", "Configuration directory")
|
||||
.action(() => {})
|
||||
|
||||
program
|
||||
.command("kanban")
|
||||
.description("Run npx kanban --agent cline")
|
||||
.action(() => {})
|
||||
|
||||
// Default command for interactive mode
|
||||
program
|
||||
.argument("[prompt]", "Task prompt")
|
||||
@@ -81,6 +97,7 @@ describe("CLI Commands", () => {
|
||||
.option("--auto-condense", "Enable AI-powered context compaction instead of mechanical truncation")
|
||||
.option("--hooks-dir <path>", "Additional hooks directory")
|
||||
.option("--auto-approve-all", "Enable auto-approve all")
|
||||
.option("--kanban", "Run npx kanban --agent cline")
|
||||
.action(() => {})
|
||||
})
|
||||
|
||||
@@ -274,6 +291,13 @@ describe("CLI Commands", () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe("kanban command", () => {
|
||||
it("should parse kanban command", () => {
|
||||
const args = ["node", "cli", "kanban"]
|
||||
program.parse(args)
|
||||
})
|
||||
})
|
||||
|
||||
describe("auth command", () => {
|
||||
it("should parse auth command", () => {
|
||||
const args = ["node", "cli", "auth"]
|
||||
@@ -318,6 +342,32 @@ describe("CLI Commands", () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe("mcp command", () => {
|
||||
it("should parse mcp add stdio syntax", () => {
|
||||
const args = ["node", "cli", "mcp", "add", "kanban", "--", "kanban", "mcp"]
|
||||
program.parse(args)
|
||||
})
|
||||
|
||||
it("should parse mcp add remote http syntax", () => {
|
||||
const args = ["node", "cli", "mcp", "add", "linear", "https://mcp.linear.app/mcp", "--type", "http"]
|
||||
program.parse(args)
|
||||
})
|
||||
|
||||
it("should default mcp add type to stdio", () => {
|
||||
const mcpCmd = program.commands.find((c) => c.name() === "mcp")!
|
||||
const addCmd = mcpCmd.commands.find((c) => c.name() === "add")!
|
||||
addCmd.parse(["kanban", "--", "kanban", "mcp"], { from: "user" })
|
||||
expect(addCmd.opts().type).toBe("stdio")
|
||||
})
|
||||
|
||||
it("should parse mcp add type option", () => {
|
||||
const mcpCmd = program.commands.find((c) => c.name() === "mcp")!
|
||||
const addCmd = mcpCmd.commands.find((c) => c.name() === "add")!
|
||||
addCmd.parse(["linear", "https://mcp.linear.app/mcp", "--type", "http"], { from: "user" })
|
||||
expect(addCmd.opts().type).toBe("http")
|
||||
})
|
||||
})
|
||||
|
||||
describe("default command (interactive mode)", () => {
|
||||
it("should parse optional prompt argument", () => {
|
||||
const args = ["node", "cli", "do something"]
|
||||
@@ -368,6 +418,11 @@ describe("CLI Commands", () => {
|
||||
program.parse(["node", "cli", "--auto-approve-all"])
|
||||
expect(program.opts().autoApproveAll).toBe(true)
|
||||
})
|
||||
|
||||
it("should parse --kanban flag", () => {
|
||||
program.parse(["node", "cli", "--kanban"])
|
||||
expect(program.opts().kanban).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe("command structure", () => {
|
||||
@@ -377,6 +432,8 @@ describe("CLI Commands", () => {
|
||||
expect(commandNames).toContain("history")
|
||||
expect(commandNames).toContain("config")
|
||||
expect(commandNames).toContain("auth")
|
||||
expect(commandNames).toContain("mcp")
|
||||
expect(commandNames).toContain("kanban")
|
||||
})
|
||||
|
||||
it("should have correct aliases", () => {
|
||||
|
||||
+144
-30
@@ -2,6 +2,7 @@
|
||||
* Cline CLI - TypeScript implementation with React Ink
|
||||
*/
|
||||
|
||||
import { spawn } from "node:child_process"
|
||||
import { exit } from "node:process"
|
||||
import type { ApiProvider } from "@shared/api"
|
||||
import { Command } from "commander"
|
||||
@@ -34,6 +35,7 @@ import { CliWebviewProvider } from "./controllers/CliWebviewProvider"
|
||||
import { isAuthConfigured } from "./utils/auth"
|
||||
import { restoreConsole, suppressConsoleUnlessVerbose } from "./utils/console"
|
||||
import { printInfo, printWarning } from "./utils/display"
|
||||
import { addMcpServerShortcut, type McpAddOptions } from "./utils/mcp"
|
||||
import { selectOutputMode } from "./utils/mode-selection"
|
||||
import { parseImagesFromInput, processImagePaths } from "./utils/parser"
|
||||
import { CLINE_CLI_DIR, getCliBinaryPath } from "./utils/path"
|
||||
@@ -41,6 +43,7 @@ import { readStdinIfPiped } from "./utils/piped"
|
||||
import { runPlainTextTask } from "./utils/plain-text-task"
|
||||
import { applyProviderConfig } from "./utils/provider-config"
|
||||
import { getValidCliProviders, isValidCliProvider } from "./utils/providers"
|
||||
import { findMostRecentTaskForWorkspace } from "./utils/task-history"
|
||||
import { autoUpdateOnStartup, checkForUpdates } from "./utils/update"
|
||||
import { initializeCliContext } from "./vscode-context"
|
||||
import { CLI_LOG_FILE, shutdownEvent, window } from "./vscode-shim"
|
||||
@@ -55,9 +58,11 @@ suppressConsoleUnlessVerbose()
|
||||
interface TaskOptions {
|
||||
act?: boolean
|
||||
plan?: boolean
|
||||
kanban?: boolean
|
||||
model?: string
|
||||
verbose?: boolean
|
||||
cwd?: string
|
||||
continue?: boolean
|
||||
config?: string
|
||||
thinking?: boolean | string
|
||||
reasoningEffort?: string
|
||||
@@ -138,46 +143,43 @@ function normalizeMaxConsecutiveMistakes(value?: string): number | undefined {
|
||||
function applyTaskOptions(options: TaskOptions): void {
|
||||
// Apply mode flag
|
||||
if (options.plan) {
|
||||
StateManager.get().setGlobalState("mode", "plan")
|
||||
StateManager.get().setSessionOverride("mode", "plan")
|
||||
telemetryService.captureHostEvent("mode_flag", "plan")
|
||||
} else if (options.act) {
|
||||
StateManager.get().setGlobalState("mode", "act")
|
||||
StateManager.get().setSessionOverride("mode", "act")
|
||||
telemetryService.captureHostEvent("mode_flag", "act")
|
||||
}
|
||||
|
||||
// Apply model override if specified
|
||||
if (options.model) {
|
||||
const selectedMode = (StateManager.get().getGlobalSettingsKey("mode") || "act") as "act" | "plan"
|
||||
const selectedMode = (StateManager.get().getGlobalSettingsKey("mode") ?? "act") as "act" | "plan"
|
||||
const providerKey = selectedMode === "act" ? "actModeApiProvider" : "planModeApiProvider"
|
||||
const currentProvider = StateManager.get().getGlobalSettingsKey(providerKey) as ApiProvider
|
||||
const modelKey = getProviderModelIdKey(currentProvider, selectedMode)
|
||||
if (modelKey) {
|
||||
StateManager.get().setGlobalState(modelKey, options.model)
|
||||
StateManager.get().setSessionOverride(modelKey, options.model)
|
||||
}
|
||||
telemetryService.captureHostEvent("model_flag", options.model)
|
||||
}
|
||||
|
||||
const currentMode = (StateManager.get().getGlobalSettingsKey("mode") || "act") as "act" | "plan"
|
||||
|
||||
// Set thinking budget based on --thinking flag (boolean or number)
|
||||
let thinkingBudget = 0
|
||||
if (options.thinking) {
|
||||
if (options.thinking !== undefined) {
|
||||
let thinkingBudget = 1024
|
||||
if (typeof options.thinking === "string") {
|
||||
const parsed = Number.parseInt(options.thinking, 10)
|
||||
if (Number.isNaN(parsed) || parsed < 0) {
|
||||
printWarning(`Invalid --thinking value '${options.thinking}'. Using default 1024.`)
|
||||
thinkingBudget = 1024
|
||||
} else {
|
||||
thinkingBudget = parsed
|
||||
}
|
||||
} else {
|
||||
thinkingBudget = 1024
|
||||
}
|
||||
}
|
||||
const currentMode = (StateManager.get().getGlobalSettingsKey("mode") || "act") as "act" | "plan"
|
||||
setModeScopedState(currentMode, (mode) => {
|
||||
const thinkingKey = mode === "act" ? "actModeThinkingBudgetTokens" : "planModeThinkingBudgetTokens"
|
||||
StateManager.get().setGlobalState(thinkingKey, thinkingBudget)
|
||||
})
|
||||
if (options.thinking) {
|
||||
|
||||
setModeScopedState(currentMode, (mode) => {
|
||||
const thinkingKey = mode === "act" ? "actModeThinkingBudgetTokens" : "planModeThinkingBudgetTokens"
|
||||
StateManager.get().setSessionOverride(thinkingKey, thinkingBudget)
|
||||
})
|
||||
telemetryService.captureHostEvent("thinking_flag", "true")
|
||||
}
|
||||
|
||||
@@ -185,14 +187,14 @@ function applyTaskOptions(options: TaskOptions): void {
|
||||
if (reasoningEffort !== undefined) {
|
||||
setModeScopedState(currentMode, (mode) => {
|
||||
const reasoningKey = mode === "act" ? "actModeReasoningEffort" : "planModeReasoningEffort"
|
||||
StateManager.get().setGlobalState(reasoningKey, reasoningEffort)
|
||||
StateManager.get().setSessionOverride(reasoningKey, reasoningEffort)
|
||||
})
|
||||
telemetryService.captureHostEvent("reasoning_effort_flag", reasoningEffort)
|
||||
}
|
||||
|
||||
const maxConsecutiveMistakes = normalizeMaxConsecutiveMistakes(options.maxConsecutiveMistakes)
|
||||
if (maxConsecutiveMistakes !== undefined) {
|
||||
StateManager.get().setGlobalState("maxConsecutiveMistakes", maxConsecutiveMistakes)
|
||||
StateManager.get().setSessionOverride("maxConsecutiveMistakes", maxConsecutiveMistakes)
|
||||
telemetryService.captureHostEvent("max_consecutive_mistakes_flag", String(maxConsecutiveMistakes))
|
||||
}
|
||||
|
||||
@@ -212,12 +214,12 @@ function applyTaskOptions(options: TaskOptions): void {
|
||||
|
||||
// Set double-check completion based on flag
|
||||
if (options.doubleCheckCompletion) {
|
||||
StateManager.get().setGlobalState("doubleCheckCompletionEnabled", true)
|
||||
StateManager.get().setSessionOverride("doubleCheckCompletionEnabled", true)
|
||||
telemetryService.captureHostEvent("double_check_completion_flag", "true")
|
||||
}
|
||||
|
||||
if (options.autoCondense) {
|
||||
StateManager.get().setGlobalState("useAutoCondense", true)
|
||||
StateManager.get().setSessionOverride("useAutoCondense", true)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -249,6 +251,36 @@ function getPlainTextModeReason(options: TaskOptions): string {
|
||||
return getModeSelection(options).reason
|
||||
}
|
||||
|
||||
function getNpxCommand(): string {
|
||||
return process.platform === "win32" ? "npx.cmd" : "npx"
|
||||
}
|
||||
|
||||
function runKanbanAlias(): void {
|
||||
const child = spawn(getNpxCommand(), ["-y", "kanban", "--agent", "cline"], {
|
||||
stdio: "inherit",
|
||||
})
|
||||
|
||||
child.on("error", () => {
|
||||
printWarning("Failed to run 'npx kanban --agent cline'. Make sure npx is installed and available in PATH.")
|
||||
exit(1)
|
||||
})
|
||||
|
||||
child.on("close", (code) => {
|
||||
exit(code ?? 1)
|
||||
})
|
||||
}
|
||||
|
||||
async function addMcpServer(name: string, targetOrCommand: string[] = [], options: McpAddOptions): Promise<void> {
|
||||
try {
|
||||
const result = await addMcpServerShortcut(name, targetOrCommand, options)
|
||||
const transportLabel = result.transportType === "streamableHttp" ? "http" : result.transportType
|
||||
printInfo(`Added MCP server '${result.serverName}' (${transportLabel}) to ${result.settingsPath}`)
|
||||
} catch (error) {
|
||||
printWarning(error instanceof Error ? error.message : "Failed to add MCP server.")
|
||||
exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a task in plain text mode (no Ink UI).
|
||||
* Handles auth check, task execution, cleanup, and exit.
|
||||
@@ -334,10 +366,19 @@ async function drainStdout(): Promise<void> {
|
||||
|
||||
export async function captureUnhandledException(reason: Error, context: string) {
|
||||
try {
|
||||
const errorService = ErrorService.get()
|
||||
await errorService.captureException(reason, { context })
|
||||
// dispose flushes any pending error captures to ensure they're sent before the process exits
|
||||
return errorService.dispose()
|
||||
// ErrorService may not be initialized yet (e.g., error occurred before initializeCli())
|
||||
// so we guard with a try/get pattern rather than letting ErrorService.get() throw
|
||||
let errorService: ErrorService | null = null
|
||||
try {
|
||||
errorService = ErrorService.get()
|
||||
} catch {
|
||||
// ErrorService not yet initialized; skip capture
|
||||
}
|
||||
if (errorService) {
|
||||
await errorService.captureException(reason, { context })
|
||||
// dispose flushes any pending error captures to ensure they're sent before the process exits
|
||||
return errorService.dispose()
|
||||
}
|
||||
} catch {
|
||||
// Ignore errors during shutdown to avoid an infinite loop
|
||||
Logger.info("Error capturing unhandled exception. Proceeding with shutdown.")
|
||||
@@ -396,7 +437,11 @@ function setupSignalHandlers() {
|
||||
} catch {
|
||||
// StateManager may not be initialized yet
|
||||
}
|
||||
await ErrorService.get().dispose()
|
||||
try {
|
||||
await ErrorService.get().dispose()
|
||||
} catch {
|
||||
// ErrorService may not be initialized yet
|
||||
}
|
||||
await disposeTelemetryServices()
|
||||
}
|
||||
} catch {
|
||||
@@ -554,6 +599,11 @@ async function runTask(prompt: string, options: TaskOptions & { images?: string[
|
||||
// Task without prompt starts in interactive mode
|
||||
telemetryService.captureHostEvent("task_command", prompt ? "task" : "interactive")
|
||||
|
||||
// Capture piped stdin telemetry now that HostProvider is initialized
|
||||
if (options.stdinWasPiped) {
|
||||
telemetryService.captureHostEvent("piped", "detached")
|
||||
}
|
||||
|
||||
// Apply shared task options (mode, model, thinking, yolo)
|
||||
applyTaskOptions(options)
|
||||
await StateManager.get().flushPendingState()
|
||||
@@ -826,6 +876,18 @@ program
|
||||
.option("--config <path>", "Path to Cline configuration directory")
|
||||
.action(runAuth)
|
||||
|
||||
const mcpCommand = program.command("mcp").description("Manage MCP servers")
|
||||
|
||||
mcpCommand
|
||||
.command("add")
|
||||
.description("Add an MCP server shortcut to cline_mcp_settings.json")
|
||||
.argument("<name>", "MCP server name")
|
||||
.argument("[targetOrCommand...]", "For stdio: use -- <command> [args]. For http/sse: provide <url>.")
|
||||
.option("--type <type>", "Transport type: stdio (default), http, or sse", "stdio")
|
||||
.option("-c, --cwd <path>", "Working directory for config resolution")
|
||||
.option("--config <path>", "Path to Cline configuration directory")
|
||||
.action(addMcpServer)
|
||||
|
||||
program
|
||||
.command("version")
|
||||
.description("Show Cline CLI version number")
|
||||
@@ -837,6 +899,8 @@ program
|
||||
.option("-v, --verbose", "Show verbose output")
|
||||
.action(() => checkForUpdates(CLI_VERSION))
|
||||
|
||||
program.command("kanban").description("Run npx kanban --agent cline").action(runKanbanAlias)
|
||||
|
||||
// Dev command with subcommands
|
||||
const devCommand = program.command("dev").description("Developer tools and utilities")
|
||||
|
||||
@@ -861,8 +925,8 @@ function findTaskInHistory(taskId: string): HistoryItem | null {
|
||||
* Resume an existing task by ID
|
||||
* Loads the task and optionally prefills the input with a prompt
|
||||
*/
|
||||
async function resumeTask(taskId: string, options: TaskOptions & { initialPrompt?: string }) {
|
||||
const ctx = await initializeCli({ ...options, enableAuth: true })
|
||||
async function resumeTask(taskId: string, options: TaskOptions & { initialPrompt?: string }, existingContext?: CliContext) {
|
||||
const ctx = existingContext || (await initializeCli({ ...options, enableAuth: true }))
|
||||
|
||||
// Validate task exists
|
||||
const historyItem = findTaskInHistory(taskId)
|
||||
@@ -875,6 +939,11 @@ async function resumeTask(taskId: string, options: TaskOptions & { initialPrompt
|
||||
|
||||
telemetryService.captureHostEvent("resume_task_command", options.initialPrompt ? "with_prompt" : "interactive")
|
||||
|
||||
// Capture piped stdin telemetry now that HostProvider is initialized
|
||||
if (options.stdinWasPiped) {
|
||||
telemetryService.captureHostEvent("piped", "detached")
|
||||
}
|
||||
|
||||
// Apply shared task options (mode, model, thinking, yolo)
|
||||
applyTaskOptions(options)
|
||||
await StateManager.get().flushPendingState()
|
||||
@@ -909,6 +978,20 @@ async function resumeTask(taskId: string, options: TaskOptions & { initialPrompt
|
||||
)
|
||||
}
|
||||
|
||||
async function continueTask(options: TaskOptions) {
|
||||
const ctx = await initializeCli({ ...options, enableAuth: true })
|
||||
const historyItem = findMostRecentTaskForWorkspace(StateManager.get().getGlobalStateKey("taskHistory"), ctx.workspacePath)
|
||||
|
||||
if (!historyItem) {
|
||||
printWarning(`No previous task found for ${ctx.workspacePath}`)
|
||||
printInfo("Start a new task or use 'cline history' to browse previous tasks.")
|
||||
await disposeCliContext(ctx)
|
||||
exit(1)
|
||||
}
|
||||
|
||||
return resumeTask(historyItem.id, options, ctx)
|
||||
}
|
||||
|
||||
/**
|
||||
* Show welcome prompt and wait for user input
|
||||
* If auth is not configured, show auth flow first
|
||||
@@ -967,8 +1050,20 @@ program
|
||||
.option("--auto-condense", "Enable AI-powered context compaction instead of mechanical truncation")
|
||||
.option("--hooks-dir <path>", "Path to additional hooks directory for runtime hook injection")
|
||||
.option("--acp", "Run in ACP (Agent Client Protocol) mode for editor integration")
|
||||
.option("--kanban", "Run npx kanban --agent cline")
|
||||
.option("-T, --taskId <id>", "Resume an existing task by ID")
|
||||
.option("--continue", "Resume the most recent task from the current working directory")
|
||||
.action(async (prompt, options) => {
|
||||
if (options.kanban) {
|
||||
if (prompt) {
|
||||
printWarning("Use --kanban without a prompt.")
|
||||
exit(1)
|
||||
}
|
||||
|
||||
runKanbanAlias()
|
||||
return
|
||||
}
|
||||
|
||||
// Check for ACP mode first - this takes precedence over everything else
|
||||
if (options.acp) {
|
||||
await runAcpMode({
|
||||
@@ -989,6 +1084,25 @@ program
|
||||
// stdinInput has content means stdin was piped with data
|
||||
const stdinWasPiped = stdinInput !== null
|
||||
|
||||
if (options.taskId && options.continue) {
|
||||
printWarning("Use either --taskId or --continue, not both.")
|
||||
exit(1)
|
||||
}
|
||||
|
||||
if (options.continue) {
|
||||
if (prompt) {
|
||||
printWarning("Use --continue without a prompt.")
|
||||
exit(1)
|
||||
}
|
||||
if (stdinWasPiped) {
|
||||
printWarning("Use --continue without piped input.")
|
||||
exit(1)
|
||||
}
|
||||
|
||||
await continueTask(options)
|
||||
return
|
||||
}
|
||||
|
||||
// Error if stdin was piped but empty AND no prompt was provided
|
||||
// This handles:
|
||||
// - `echo "" | cline` -> error (empty stdin, no prompt)
|
||||
@@ -1009,8 +1123,6 @@ program
|
||||
effectivePrompt = stdinInput
|
||||
}
|
||||
|
||||
telemetryService.captureHostEvent("piped", "detached")
|
||||
|
||||
// Debug: show that we received piped input
|
||||
if (options.verbose) {
|
||||
process.stderr.write(`[debug] Received ${stdinInput.length} bytes from stdin\n`)
|
||||
@@ -1037,4 +1149,6 @@ program
|
||||
})
|
||||
|
||||
// Parse and run
|
||||
program.parse()
|
||||
if (process.env.VITEST !== "true") {
|
||||
program.parse()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import * as fs from "node:fs/promises"
|
||||
import os from "node:os"
|
||||
import path from "node:path"
|
||||
import { afterEach, describe, expect, it } from "vitest"
|
||||
import { addMcpServerShortcut } from "./mcp"
|
||||
|
||||
const tempDirs: string[] = []
|
||||
|
||||
async function createTempConfigDir(): Promise<string> {
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "cline-mcp-test-"))
|
||||
tempDirs.push(dir)
|
||||
return dir
|
||||
}
|
||||
|
||||
type McpSettingsFile = {
|
||||
mcpServers: Record<string, Record<string, unknown>>
|
||||
}
|
||||
|
||||
async function readMcpSettings(configDir: string): Promise<McpSettingsFile> {
|
||||
const settingsPath = path.join(configDir, "data", "settings", "cline_mcp_settings.json")
|
||||
return JSON.parse(await fs.readFile(settingsPath, "utf-8")) as McpSettingsFile
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
for (const dir of tempDirs.splice(0, tempDirs.length)) {
|
||||
await fs.rm(dir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
describe("addMcpServerShortcut", () => {
|
||||
it("writes stdio servers with type=stdio", async () => {
|
||||
const configDir = await createTempConfigDir()
|
||||
|
||||
await addMcpServerShortcut("kanban", ["kanban", "mcp"], { config: configDir })
|
||||
const settings = await readMcpSettings(configDir)
|
||||
|
||||
expect(settings.mcpServers.kanban).toEqual({
|
||||
command: "kanban",
|
||||
args: ["mcp"],
|
||||
type: "stdio",
|
||||
})
|
||||
})
|
||||
|
||||
it("maps --type http to streamableHttp", async () => {
|
||||
const configDir = await createTempConfigDir()
|
||||
|
||||
await addMcpServerShortcut("linear", ["https://mcp.linear.app/mcp"], { config: configDir, type: "http" })
|
||||
const settings = await readMcpSettings(configDir)
|
||||
|
||||
expect(settings.mcpServers.linear).toEqual({
|
||||
url: "https://mcp.linear.app/mcp",
|
||||
type: "streamableHttp",
|
||||
})
|
||||
})
|
||||
|
||||
it("errors when URL is provided without --type http", async () => {
|
||||
const configDir = await createTempConfigDir()
|
||||
|
||||
await expect(addMcpServerShortcut("linear", ["https://mcp.linear.app/mcp"], { config: configDir })).rejects.toThrow(
|
||||
"Use --type http",
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,159 @@
|
||||
import * as fs from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
import { getMcpSettingsFilePath } from "@/core/storage/disk"
|
||||
import { ServerConfigSchema } from "@/services/mcp/schemas"
|
||||
import { initializeCliContext } from "../vscode-context"
|
||||
|
||||
export interface McpAddOptions {
|
||||
type?: string
|
||||
config?: string
|
||||
cwd?: string
|
||||
}
|
||||
|
||||
export type McpAddTransportType = "stdio" | "streamableHttp" | "sse"
|
||||
|
||||
export interface AddMcpServerResult {
|
||||
serverName: string
|
||||
transportType: McpAddTransportType
|
||||
settingsPath: string
|
||||
}
|
||||
|
||||
function normalizeMcpTransportType(value?: string): McpAddTransportType {
|
||||
const normalized = (value || "stdio").trim().toLowerCase()
|
||||
|
||||
switch (normalized) {
|
||||
case "stdio":
|
||||
return "stdio"
|
||||
case "http":
|
||||
case "streamable-http":
|
||||
case "streamablehttp":
|
||||
return "streamableHttp"
|
||||
case "sse":
|
||||
return "sse"
|
||||
default:
|
||||
throw new Error(`Invalid MCP transport type '${value}'. Valid values: stdio, http, sse.`)
|
||||
}
|
||||
}
|
||||
|
||||
function parseMcpSettings(content: string, settingsPath: string): Record<string, unknown> {
|
||||
const trimmedContent = content.trim()
|
||||
if (!trimmedContent) {
|
||||
return { mcpServers: {} }
|
||||
}
|
||||
|
||||
let parsed: unknown
|
||||
try {
|
||||
parsed = JSON.parse(content)
|
||||
} catch {
|
||||
throw new Error(`Invalid JSON in ${settingsPath}. Please fix the file and try again.`)
|
||||
}
|
||||
|
||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
||||
throw new Error(`Invalid MCP settings file at ${settingsPath}. Expected a JSON object.`)
|
||||
}
|
||||
|
||||
const settings = parsed as Record<string, unknown>
|
||||
if (settings.mcpServers === undefined) {
|
||||
settings.mcpServers = {}
|
||||
}
|
||||
|
||||
if (!settings.mcpServers || typeof settings.mcpServers !== "object" || Array.isArray(settings.mcpServers)) {
|
||||
throw new Error(`Invalid MCP settings file at ${settingsPath}. Expected 'mcpServers' to be an object.`)
|
||||
}
|
||||
|
||||
return settings
|
||||
}
|
||||
|
||||
function createMcpServerConfig(targetOrCommand: string[], transportType: McpAddTransportType): Record<string, unknown> {
|
||||
if (transportType === "stdio") {
|
||||
if (targetOrCommand.length < 1) {
|
||||
throw new Error("Missing stdio command. Example: cline mcp add kanban -- kanban mcp")
|
||||
}
|
||||
|
||||
// Guard against common mistake:
|
||||
// `cline mcp add <name> <url>` without `--type http`
|
||||
if (targetOrCommand.length === 1) {
|
||||
const [value] = targetOrCommand
|
||||
try {
|
||||
const parsedUrl = new URL(value)
|
||||
if (parsedUrl.protocol === "http:" || parsedUrl.protocol === "https:") {
|
||||
throw new Error(
|
||||
`Looks like you provided a URL for '${value}'. Use --type http, for example: cline mcp add <name> ${value} --type http`,
|
||||
)
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message.startsWith("Looks like you provided a URL")) {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const [command, ...args] = targetOrCommand
|
||||
const config: Record<string, unknown> = {
|
||||
command,
|
||||
type: "stdio",
|
||||
}
|
||||
|
||||
if (args.length > 0) {
|
||||
config.args = args
|
||||
}
|
||||
|
||||
ServerConfigSchema.parse(config)
|
||||
return config
|
||||
}
|
||||
|
||||
if (targetOrCommand.length !== 1) {
|
||||
throw new Error(
|
||||
"HTTP/SSE MCP servers require exactly one URL. Example: cline mcp add linear https://mcp.linear.app/mcp --type http",
|
||||
)
|
||||
}
|
||||
|
||||
const config = {
|
||||
url: targetOrCommand[0],
|
||||
type: transportType,
|
||||
}
|
||||
|
||||
ServerConfigSchema.parse(config)
|
||||
return config
|
||||
}
|
||||
|
||||
export async function addMcpServerShortcut(
|
||||
name: string,
|
||||
targetOrCommand: string[] = [],
|
||||
options: McpAddOptions,
|
||||
): Promise<AddMcpServerResult> {
|
||||
const trimmedName = name.trim()
|
||||
if (!trimmedName) {
|
||||
throw new Error("Server name is required.")
|
||||
}
|
||||
|
||||
const transportType = normalizeMcpTransportType(options.type)
|
||||
|
||||
const { DATA_DIR } = initializeCliContext({
|
||||
clineDir: options.config,
|
||||
workspaceDir: options.cwd || process.cwd(),
|
||||
})
|
||||
|
||||
const settingsDirectoryPath = path.join(DATA_DIR, "settings")
|
||||
await fs.mkdir(settingsDirectoryPath, { recursive: true })
|
||||
const settingsPath = await getMcpSettingsFilePath(settingsDirectoryPath)
|
||||
|
||||
const content = await fs.readFile(settingsPath, "utf-8")
|
||||
const settings = parseMcpSettings(content, settingsPath)
|
||||
const mcpServers = settings.mcpServers as Record<string, unknown>
|
||||
|
||||
if (mcpServers[trimmedName]) {
|
||||
throw new Error(`An MCP server named '${trimmedName}' already exists.`)
|
||||
}
|
||||
|
||||
const serverConfig = createMcpServerConfig(targetOrCommand, transportType)
|
||||
mcpServers[trimmedName] = serverConfig
|
||||
|
||||
await fs.writeFile(settingsPath, `${JSON.stringify(settings, null, 2)}\n`, "utf-8")
|
||||
|
||||
return {
|
||||
serverName: trimmedName,
|
||||
transportType,
|
||||
settingsPath,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import type { SlashCommandInfo } from "@shared/proto/cline/slash"
|
||||
import { describe, expect, it } from "vitest"
|
||||
import { filterCommands, getStandaloneSlashCommandToExecute } from "./slash-commands"
|
||||
|
||||
const createCommand = (name: string): SlashCommandInfo => ({
|
||||
name,
|
||||
description: `${name} command`,
|
||||
section: "default",
|
||||
cliCompatible: true,
|
||||
})
|
||||
|
||||
describe("filterCommands", () => {
|
||||
it("prioritizes exact matches ahead of fuzzy matches", () => {
|
||||
const commands = [createCommand("help"), createCommand("history"), createCommand("q")]
|
||||
|
||||
const result = filterCommands(commands, "q")
|
||||
|
||||
expect(result.map((command) => command.name)[0]).toBe("q")
|
||||
})
|
||||
|
||||
it("prioritizes prefix matches ahead of fuzzy matches", () => {
|
||||
const commands = [createCommand("history"), createCommand("help"), createCommand("exit")]
|
||||
|
||||
const result = filterCommands(commands, "hi")
|
||||
|
||||
expect(result.map((command) => command.name)[0]).toBe("history")
|
||||
})
|
||||
})
|
||||
|
||||
describe("getStandaloneSlashCommandToExecute", () => {
|
||||
it("ignores standalone execution when slash menu is visible", () => {
|
||||
expect(
|
||||
getStandaloneSlashCommandToExecute({
|
||||
prompt: "/q",
|
||||
inSlashMode: true,
|
||||
hasSlashMenu: true,
|
||||
hasPendingAsk: false,
|
||||
isSpinnerActive: false,
|
||||
}),
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it("returns standalone command when enter should execute it directly", () => {
|
||||
expect(
|
||||
getStandaloneSlashCommandToExecute({
|
||||
prompt: "/exit",
|
||||
inSlashMode: false,
|
||||
hasSlashMenu: false,
|
||||
hasPendingAsk: false,
|
||||
isSpinnerActive: false,
|
||||
}),
|
||||
).toBe("exit")
|
||||
})
|
||||
})
|
||||
@@ -4,6 +4,7 @@
|
||||
*/
|
||||
|
||||
import type { SlashCommandInfo } from "@shared/proto/cline/slash"
|
||||
import { CLI_ONLY_COMMANDS } from "@shared/slashCommands"
|
||||
import { fuzzyFilter } from "./fuzzy-search"
|
||||
|
||||
export interface SlashQueryInfo {
|
||||
@@ -17,12 +18,29 @@ export interface VisibleWindow<T> {
|
||||
startIndex: number
|
||||
}
|
||||
|
||||
export interface StandaloneSlashCommandExecutionInput {
|
||||
prompt: string
|
||||
inSlashMode: boolean
|
||||
hasSlashMenu: boolean
|
||||
hasPendingAsk: boolean
|
||||
isSpinnerActive: boolean
|
||||
}
|
||||
|
||||
export function createCliOnlySlashCommands(): SlashCommandInfo[] {
|
||||
return CLI_ONLY_COMMANDS.map((cmd) => ({
|
||||
name: cmd.name,
|
||||
description: cmd.description || "",
|
||||
section: cmd.section || "default",
|
||||
cliCompatible: true,
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate visible window for a scrollable list menu.
|
||||
* Centers the selected item in the visible window when possible.
|
||||
* Returns the visible items and the start index for selection tracking.
|
||||
*/
|
||||
export function getVisibleWindow<T>(items: T[], selectedIndex: number, maxVisible: number = 5): VisibleWindow<T> {
|
||||
export function getVisibleWindow<T>(items: T[], selectedIndex: number, maxVisible = 5): VisibleWindow<T> {
|
||||
if (items.length <= maxVisible) {
|
||||
return { items, startIndex: 0 }
|
||||
}
|
||||
@@ -91,6 +109,42 @@ export function extractSlashQuery(text: string, cursorPosition?: number): SlashQ
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect a standalone slash command (for example "/q" or "/exit")
|
||||
* that should be executed immediately when enter is pressed.
|
||||
*/
|
||||
export function getStandaloneSlashCommandName(text: string): string | null {
|
||||
const match = text.trim().match(/^\/([a-zA-Z0-9_.-]+)$/)
|
||||
return match?.[1] ?? null
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve whether pressing Enter should execute a standalone CLI slash command.
|
||||
* This keeps ChatView's key handling deterministic and easy to test.
|
||||
*/
|
||||
export function getStandaloneSlashCommandToExecute({
|
||||
prompt,
|
||||
inSlashMode,
|
||||
hasSlashMenu,
|
||||
hasPendingAsk,
|
||||
isSpinnerActive,
|
||||
}: StandaloneSlashCommandExecutionInput): string | null {
|
||||
const standaloneSlashCommand = getStandaloneSlashCommandName(prompt)
|
||||
if (!standaloneSlashCommand) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (hasPendingAsk || isSpinnerActive) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (inSlashMode && hasSlashMenu) {
|
||||
return null
|
||||
}
|
||||
|
||||
return standaloneSlashCommand
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter commands using fuzzy matching
|
||||
*/
|
||||
@@ -98,7 +152,26 @@ export function filterCommands(commands: SlashCommandInfo[], query: string): Sla
|
||||
if (!query) {
|
||||
return commands
|
||||
}
|
||||
return fuzzyFilter(commands, query, (cmd) => cmd.name)
|
||||
|
||||
const normalizedQuery = query.toLowerCase()
|
||||
const exactMatches: SlashCommandInfo[] = []
|
||||
const prefixMatches: SlashCommandInfo[] = []
|
||||
const remaining: SlashCommandInfo[] = []
|
||||
|
||||
for (const command of commands) {
|
||||
const normalizedName = command.name.toLowerCase()
|
||||
if (normalizedName === normalizedQuery) {
|
||||
exactMatches.push(command)
|
||||
continue
|
||||
}
|
||||
if (normalizedName.startsWith(normalizedQuery)) {
|
||||
prefixMatches.push(command)
|
||||
continue
|
||||
}
|
||||
remaining.push(command)
|
||||
}
|
||||
|
||||
return [...exactMatches, ...prefixMatches, ...fuzzyFilter(remaining, query, (cmd) => cmd.name)]
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import { describe, expect, it } from "vitest"
|
||||
import { findMostRecentTaskForWorkspace } from "./task-history"
|
||||
|
||||
describe("findMostRecentTaskForWorkspace", () => {
|
||||
it("returns the newest matching task for the workspace", () => {
|
||||
const result = findMostRecentTaskForWorkspace(
|
||||
[
|
||||
{
|
||||
id: "older",
|
||||
ts: 100,
|
||||
task: "Older task",
|
||||
tokensIn: 0,
|
||||
tokensOut: 0,
|
||||
totalCost: 0,
|
||||
cwdOnTaskInitialization: "/repo",
|
||||
},
|
||||
{
|
||||
id: "newer",
|
||||
ts: 200,
|
||||
task: "Newer task",
|
||||
tokensIn: 0,
|
||||
tokensOut: 0,
|
||||
totalCost: 0,
|
||||
cwdOnTaskInitialization: "/repo",
|
||||
},
|
||||
],
|
||||
"/repo",
|
||||
)
|
||||
|
||||
expect(result?.id).toBe("newer")
|
||||
})
|
||||
|
||||
it("falls back to shadowGitConfigWorkTree for older tasks", () => {
|
||||
const result = findMostRecentTaskForWorkspace(
|
||||
[
|
||||
{
|
||||
id: "legacy",
|
||||
ts: 200,
|
||||
task: "Legacy task",
|
||||
tokensIn: 0,
|
||||
tokensOut: 0,
|
||||
totalCost: 0,
|
||||
shadowGitConfigWorkTree: "/repo",
|
||||
},
|
||||
],
|
||||
"/repo",
|
||||
)
|
||||
|
||||
expect(result?.id).toBe("legacy")
|
||||
})
|
||||
|
||||
it("returns null when there is no match", () => {
|
||||
const result = findMostRecentTaskForWorkspace(
|
||||
[
|
||||
{
|
||||
id: "other",
|
||||
ts: 200,
|
||||
task: "Other task",
|
||||
tokensIn: 0,
|
||||
tokensOut: 0,
|
||||
totalCost: 0,
|
||||
cwdOnTaskInitialization: "/other",
|
||||
},
|
||||
],
|
||||
"/repo",
|
||||
)
|
||||
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,27 @@
|
||||
import { HistoryItem } from "@shared/HistoryItem"
|
||||
import { arePathsEqual } from "@/utils/path"
|
||||
|
||||
export function findMostRecentTaskForWorkspace(
|
||||
taskHistory: HistoryItem[] | undefined,
|
||||
workspacePath: string,
|
||||
): HistoryItem | null {
|
||||
if (!taskHistory?.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
[...taskHistory]
|
||||
.filter((item) => {
|
||||
if (!item.ts || !item.task) {
|
||||
return false
|
||||
}
|
||||
|
||||
return Boolean(
|
||||
(item.cwdOnTaskInitialization && arePathsEqual(item.cwdOnTaskInitialization, workspacePath)) ||
|
||||
(item.shadowGitConfigWorkTree && arePathsEqual(item.shadowGitConfigWorkTree, workspacePath)),
|
||||
)
|
||||
})
|
||||
.sort((a, b) => b.ts - a.ts)
|
||||
.at(0) ?? null
|
||||
)
|
||||
}
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 8.9 MiB After Width: | Height: | Size: 8.9 MiB |
Binary file not shown.
|
Before Width: | Height: | Size: 6.2 MiB After Width: | Height: | Size: 6.1 MiB |
@@ -63,6 +63,9 @@ cline
|
||||
|
||||
# Start a task directly
|
||||
cline "your prompt here"
|
||||
|
||||
# Resume the latest task for the current directory
|
||||
cline --continue
|
||||
```
|
||||
|
||||
**Options:**
|
||||
@@ -77,6 +80,7 @@ cline "your prompt here"
|
||||
| `--thinking` | Enable extended thinking with a 1024 token budget. |
|
||||
| `--json` | Output messages as JSON (one object per line). Forces plain text mode. |
|
||||
| `--timeout <seconds>` | Maximum execution time before the task is stopped. |
|
||||
| `--continue` | Resume the most recent task from the current working directory. |
|
||||
|
||||
**Mode Behavior:**
|
||||
|
||||
|
||||
@@ -180,13 +180,23 @@ Cline CLI supports [MCP (Model Context Protocol)](/mcp/mcp-overview) servers, gi
|
||||
|
||||
### Setting Up MCP Servers
|
||||
|
||||
To configure MCP servers for the CLI, create or edit the settings file at:
|
||||
You can add MCP servers from the CLI:
|
||||
|
||||
```bash
|
||||
# STDIO server
|
||||
cline mcp add kanban -- kanban mcp
|
||||
|
||||
# Remote HTTP server
|
||||
cline mcp add linear https://mcp.linear.app/mcp --type http
|
||||
```
|
||||
|
||||
These commands update:
|
||||
|
||||
```
|
||||
~/.cline/data/settings/cline_mcp_settings.json
|
||||
```
|
||||
|
||||
The file uses the same JSON format as the VS Code extension:
|
||||
You can still edit this file directly. It uses the same JSON format as the VS Code extension:
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -207,7 +217,7 @@ The file uses the same JSON format as the VS Code extension:
|
||||
For the full configuration reference including STDIO and SSE transport types, see [Adding and Configuring MCP Servers](/mcp/adding-and-configuring-servers).
|
||||
|
||||
<Note>
|
||||
The CLI does not yet have a `/mcp` slash command for managing MCP servers interactively. For now, you'll need to edit the `cline_mcp_settings.json` file directly.
|
||||
The CLI does not yet have a `/mcp` slash command for interactive management inside the terminal UI. Use `cline mcp add` or edit `cline_mcp_settings.json` directly.
|
||||
</Note>
|
||||
|
||||
### Custom Config Directory
|
||||
|
||||
@@ -18,18 +18,22 @@ Fireworks AI is a leading infrastructure platform for generative AI that focuses
|
||||
|
||||
Cline supports the following Fireworks AI models:
|
||||
|
||||
- `accounts/fireworks/models/kimi-k2-instruct-0905` (Default) - Kimi K2 with 262K context, prompt caching ($0.60/$2.50 per 1M tokens)
|
||||
- `accounts/fireworks/models/qwen3-235b-a22b-instruct-2507` - Latest Qwen3 thinking model (256K context, $0.22/$0.88 per 1M tokens)
|
||||
- `accounts/fireworks/models/qwen3-coder-480b-a35b-instruct` - Qwen3's most agentic code model (256K context, $0.45/$1.80 per 1M tokens)
|
||||
- `accounts/fireworks/models/deepseek-r1-0528` - DeepSeek R1 reasoning model (160K context, $3.00/$8.00 per 1M tokens)
|
||||
- `accounts/fireworks/models/deepseek-v3` - DeepSeek V3 general-purpose model (128K context, $0.90/$0.90 per 1M tokens)
|
||||
- `accounts/fireworks/models/kimi-k2p5` (Default) - Kimi K2.5 flagship agentic model with multimodal support (262K context, prompt caching, $0.60/$3.00 per 1M tokens)
|
||||
- `accounts/fireworks/models/qwen3-vl-30b-a3b-thinking` - Qwen3-VL reasoning model with image support (262K context, prompt caching, $0.15/$0.60 per 1M tokens)
|
||||
- `accounts/fireworks/models/qwen3-vl-30b-a3b-instruct` - Qwen3-VL instruct model with image support (262K context, $0.15/$0.60 per 1M tokens)
|
||||
- `accounts/fireworks/models/deepseek-v3p2` - DeepSeek V3.2 model (164K context, prompt caching, $0.56/$1.68 per 1M tokens)
|
||||
- `accounts/fireworks/models/glm-4p7` - GLM-4.7 model (203K context, prompt caching, $0.60/$2.20 per 1M tokens)
|
||||
- `accounts/fireworks/models/glm-5` - GLM-5 model (203K context, prompt caching, $1.00/$3.20 per 1M tokens)
|
||||
- `accounts/fireworks/models/minimax-m2p5` - MiniMax M2.5 model (197K context, prompt caching, $0.30/$1.20 per 1M tokens)
|
||||
- `accounts/fireworks/models/minimax-m2p1` - MiniMax M2.1 model (197K context, prompt caching, $0.30/$1.20 per 1M tokens)
|
||||
- `accounts/fireworks/models/gpt-oss-120b` - OpenAI gpt-oss-120b model (131K context, prompt caching, $0.15/$0.60 per 1M tokens)
|
||||
|
||||
### Configuration in Cline
|
||||
|
||||
1. **Open Cline Settings:** Click the settings icon (⚙️) in the Cline panel.
|
||||
2. **Select Provider:** Choose "Fireworks" from the "API Provider" dropdown.
|
||||
3. **Enter API Key:** Paste your Fireworks API key into the "Fireworks API Key" field.
|
||||
4. **Enter Model ID:** Specify the model you want to use (e.g., "accounts/fireworks/models/llama-v3p1-70b-instruct").
|
||||
4. **Enter Model ID:** Specify the model you want to use (e.g., "accounts/fireworks/models/kimi-k2p5").
|
||||
5. **Configure Tokens:** Optionally set max completion tokens and context window size.
|
||||
|
||||
### Fireworks AI's Performance Focus
|
||||
|
||||
@@ -5,7 +5,7 @@ description: "Learn how to configure and use Oracle Code Assist with Cline. Acce
|
||||
|
||||
Oracle Code Assist provides AI-powered coding assistance through Oracle Cloud Infrastructure (OCI) Generative AI service.
|
||||
|
||||
**Website:** [https://www.oracle.com/artificial-intelligence/code-assist/](https://www.oracle.com/artificial-intelligence/code-assist/)
|
||||
**Website:** [https://www.oracle.com/application-development/code-assist/](https://www.oracle.com/application-development/code-assist/)
|
||||
|
||||
### Getting Started
|
||||
|
||||
|
||||
@@ -56,15 +56,10 @@
|
||||
- قم بتحديث الاختبارات الحالية إذا كانت تغييراتك تؤثر عليها
|
||||
- تضمين كل من اختبارات الوحدة واختبارات التكامل حيثما كان ذلك مناسبًا
|
||||
|
||||
4. **إدارة الإصدار مع Changesets**
|
||||
4. **ملاحظات الإصدار وسجل التغييرات**
|
||||
|
||||
- أنشئ changeset لأي تغييرات واجهة المستخدم باستخدام `npm run changeset`
|
||||
- اختر زيادة الإصدار المناسبة:
|
||||
- `major` للتغييرات الكبيرة (1.0.0 → 2.0.0)
|
||||
- `minor` للميزات الجديدة (1.0.0 → 1.1.0)
|
||||
- `patch` لإصلاحات الأخطاء (1.0.0 → 1.0.1)
|
||||
- اكتب رسائل changeset واضحة ووصفية تشرح التأثير
|
||||
- لا تتطلب التغييرات في الوثائق فقط changesets
|
||||
- لا يحتاج المساهمون إلى إنشاء ملفات changelog-entry ضمن PR.
|
||||
- يتولى فريق الصيانة إدارة إصدار النسخ وتنسيق سجل التغييرات أثناء عملية الإصدار.
|
||||
|
||||
5. **إرشادات الالتزام (Commit Guidelines)**
|
||||
|
||||
@@ -90,4 +85,4 @@
|
||||
|
||||
من خلال إرسال طلب سحب، فإنك توافق على أن مساهماتك سيتم ترخيصها بنفس ترخيص المشروع ([Apache 2.0](LICENSE)).
|
||||
|
||||
تذكر: المساهمة في Cline لا تقتصر فقط على كتابة الكود - إنها تتعلق بأن تكون جزءًا من مجتمع يُشكل مستقبل التطوير بمساعدة الذكاء الاصطناعي. لنبنِ شيئًا رائعًا معًا! 🚀
|
||||
تذكر: المساهمة في Cline لا تقتصر فقط على كتابة الكود - إنها تتعلق بأن تكون جزءًا من مجتمع يُشكل مستقبل التطوير بمساعدة الذكاء الاصطناعي. لنبنِ شيئًا رائعًا معًا! 🚀
|
||||
|
||||
+5
-17
@@ -163,27 +163,15 @@
|
||||
<details>
|
||||
<summary>إنشاء طلب سحب (Pull Request)</summary>
|
||||
|
||||
1. قبل إنشاء PR، قم بإنشاء إدخال للتغييرات:
|
||||
```bash
|
||||
npm run changeset
|
||||
```
|
||||
سيطلب منك تحديد:
|
||||
- نوع التغيير (رئيسي، ثانوي، إصلاح)
|
||||
- `رئيسي` → تغييرات غير متوافقة (1.0.0 → 2.0.0)
|
||||
- `ثانوي` → ميزات جديدة (1.0.0 → 1.1.0)
|
||||
- `إصلاح` → إصلاحات للأخطاء (1.0.0 → 1.0.1)
|
||||
- وصف التغييرات التي قمت بها
|
||||
1. قم بعمل commit لتغييراتك.
|
||||
|
||||
2. قم بحفظ التغييرات وملف `.changeset` الذي تم إنشاؤه
|
||||
2. ادفع فرعك وأنشئ PR على GitHub. سيقوم CI بـ:
|
||||
- تشغيل الاختبارات والفحوصات
|
||||
|
||||
3. ادفع فرعك وأنشئ PR على GitHub. سيقوم CI بـ:
|
||||
- تشغيل الاختبارات والفحوصات
|
||||
- سيقوم Changesetbot بإنشاء تعليق يوضح تأثير الإصدار
|
||||
- عند الدمج مع الفرع الرئيسي، سيقوم Changesetbot بإنشاء PR لحزم الإصدار
|
||||
- عند دمج PR لحزم الإصدار، سيتم نشر إصدار جديد
|
||||
3. يتولى فريق الصيانة إدارة إصدار النسخ وتنسيق سجل التغييرات أثناء عملية الإصدار.
|
||||
|
||||
</details>
|
||||
|
||||
## الرخصة
|
||||
|
||||
[Apache 2.0 © 2025 Cline Bot Inc.](./LICENSE)
|
||||
[Apache 2.0 © 2025 Cline Bot Inc.](./LICENSE)
|
||||
|
||||
+4
-16
@@ -146,24 +146,12 @@ Cline이 작업을 진행하는 동안 확장 프로그램은 각 단계에서
|
||||
<details>
|
||||
<summary>Pull Request 생성 방법</summary>
|
||||
|
||||
1. PR을 만들기 전, 변경 사항을 기록하는 changeset 항목을 생성:
|
||||
```bash
|
||||
npm run changeset
|
||||
```
|
||||
이후 프롬프트에서 다음 정보를 입력하세요:
|
||||
- 변경 유형 (major, minor, patch)
|
||||
- `major` → 호환되지 않는 변경 (1.0.0 → 2.0.0)
|
||||
- `minor` → 새로운 기능 추가 (1.0.0 → 1.1.0)
|
||||
- `patch` → 버그 수정 (1.0.0 → 1.0.1)
|
||||
- 변경 사항 설명 입력
|
||||
1. 변경 사항을 커밋하세요.
|
||||
|
||||
2. 변경 사항과 생성된 `.changeset` 파일을 커밋 후 브랜치를 푸시하고 GitHub에서 PR을 생성하세요.
|
||||
|
||||
3. 브랜치를 푸시하고 GitHub에서 PR을 생성하세요. CI가 다음과 같은 작업을 수행합니다:
|
||||
2. 브랜치를 푸시하고 GitHub에서 PR을 생성하세요. CI가 다음과 같은 작업을 수행합니다:
|
||||
- 테스트 및 코드 검증 실행
|
||||
- Changesetbot이 버전 변경 영향을 보여주는 코멘트를 생성
|
||||
- 브랜치가 메인에 머지되면, Changesetbot이 버전 패키지 PR을 생성
|
||||
- 버전 패키지 PR이 머지되면, 새로운 릴리즈가 게시됨
|
||||
|
||||
3. 버전 관리 및 변경 로그 정리는 릴리스 과정에서 메인테이너가 처리합니다.
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
@@ -52,14 +52,9 @@
|
||||
- 若您的變更影響現有測試,請更新測試
|
||||
- 適當時包含單元測試與整合測試
|
||||
|
||||
4. **使用 Changesets 管理版本**
|
||||
- 使用 `npm run changeset` 為任何面向使用者的變更建立 changeset
|
||||
- 選擇適當的版本升級:
|
||||
- `major` 重大變更 (1.0.0 → 2.0.0)
|
||||
- `minor` 新功能 (1.0.0 → 1.1.0)
|
||||
- `patch` 錯誤修正 (1.0.0 → 1.0.1)
|
||||
- 撰寫清晰且描述性的 changeset 訊息,說明影響
|
||||
- 僅文件變更不需建立 changeset
|
||||
4. **版本與變更日誌說明**
|
||||
- 貢獻者不需要在 PR 中建立 changelog-entry 檔案。
|
||||
- 維護者會在發版流程中處理版本管理與變更日誌整理。
|
||||
|
||||
5. **提交指引**
|
||||
- 撰寫清晰且描述性的提交訊息
|
||||
|
||||
+4
-18
@@ -162,26 +162,12 @@ Cline 可以直接在您的編輯器中建立和編輯檔案,並顯示變更
|
||||
<details>
|
||||
<summary>建立 Pull Request</summary>
|
||||
|
||||
1. 在建立 PR 前,產生一個 changeset 項目:
|
||||
1. 提交您的變更。
|
||||
|
||||
```bash
|
||||
npm run changeset
|
||||
```
|
||||
|
||||
這會提示您填寫:
|
||||
- 變更類型(major、minor、patch)
|
||||
- `major` → 重大變更(1.0.0 → 2.0.0)
|
||||
- `minor` → 新功能(1.0.0 → 1.1.0)
|
||||
- `patch` → 錯誤修正(1.0.0 → 1.0.1)
|
||||
- 您的變更說明
|
||||
|
||||
2. 提交您的變更和產生的 `.changeset` 檔案
|
||||
|
||||
3. 推送您的分支並在 GitHub 上建立 PR。我們的 CI 會:
|
||||
2. 推送您的分支並在 GitHub 上建立 PR。我們的 CI 會:
|
||||
- 執行測試和檢查
|
||||
- Changesetbot 會建立一個顯示版本影響的評論
|
||||
- 當合併到 main 時,changesetbot 會建立一個 Version Packages PR
|
||||
- 當 Version Packages PR 合併時,就會發布新版本
|
||||
|
||||
3. 版本管理與變更日誌整理會由維護者在發版流程中處理。
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
Generated
+1077
-27
File diff suppressed because it is too large
Load Diff
+3
-1
@@ -2,7 +2,7 @@
|
||||
"name": "claude-dev",
|
||||
"displayName": "Cline",
|
||||
"description": "Autonomous coding agent right in your IDE, capable of creating/editing files, running commands, using the browser, and more with your permission every step of the way.",
|
||||
"version": "3.71.0",
|
||||
"version": "3.73.0",
|
||||
"icon": "assets/icons/icon.png",
|
||||
"workspaces": [
|
||||
".",
|
||||
@@ -428,6 +428,7 @@
|
||||
"test:e2e:build": "vsce package --allow-package-secrets sendgrid --out dist/e2e.vsix",
|
||||
"test:e2e": "playwright install && npm run test:e2e:build && node src/test/e2e/utils/build.mjs && playwright test",
|
||||
"test:e2e:optimal": "npm run test:e2e:build && node src/test/e2e/utils/build.mjs && playwright test",
|
||||
"test:e2e:cli:tui": "cd tests/e2e/cli && tui-test",
|
||||
"test:e2e:ui": "npx tsx scripts/interactive-playwright.ts",
|
||||
"install:all": "npm install && cd webview-ui && npm install",
|
||||
"dev:webview": "cd webview-ui && npm run dev",
|
||||
@@ -460,6 +461,7 @@
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "^2.3.14",
|
||||
"@bufbuild/buf": "^1.54.0",
|
||||
"@microsoft/tui-test": "0.0.1",
|
||||
"@types/better-sqlite3": "^7.6.13",
|
||||
"@types/chai": "^5.0.1",
|
||||
"@types/clone-deep": "^4.0.4",
|
||||
|
||||
@@ -460,6 +460,7 @@ enum ApiProvider {
|
||||
AIHUBMIX = 38;
|
||||
NOUSRESEARCH = 39;
|
||||
OPENAI_CODEX = 40;
|
||||
WANDB = 41;
|
||||
}
|
||||
|
||||
enum ApiFormat {
|
||||
@@ -598,6 +599,7 @@ message ModelsApiConfiguration {
|
||||
optional string aihubmix_app_code = 84;
|
||||
optional string nous_research_api_key = 85;
|
||||
optional bool azure_identity = 86;
|
||||
optional string wandb_api_key = 87;
|
||||
|
||||
// Plan mode configurations
|
||||
optional ApiProvider plan_mode_api_provider = 100;
|
||||
|
||||
@@ -104,6 +104,7 @@ message Secrets {
|
||||
optional string oca_refresh_token = 42;
|
||||
optional string mcp_o_auth_secrets = 43;
|
||||
optional string cline_api_key = 44;
|
||||
optional string wandb_api_key = 50;
|
||||
optional string openai_codex_oauth_credentials = 48;
|
||||
}
|
||||
|
||||
@@ -279,11 +280,11 @@ message Settings {
|
||||
optional bool worktrees_enabled = 172;
|
||||
optional bool auto_approve_all_toggled = 174;
|
||||
optional bool double_check_completion_enabled = 176;
|
||||
map<string, string> open_ai_headers = 177;
|
||||
optional string plan_mode_cline_model_id = 178;
|
||||
optional OpenRouterModelInfo plan_mode_cline_model_info = 179;
|
||||
optional string act_mode_cline_model_id = 180;
|
||||
optional OpenRouterModelInfo act_mode_cline_model_info = 181;
|
||||
map<string, string> open_ai_headers = 177;
|
||||
}
|
||||
|
||||
message State {
|
||||
|
||||
+32
-21
@@ -1,7 +1,8 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import chalk from "chalk"
|
||||
import { execSync } from "child_process"
|
||||
import { execFileSync, execSync } from "child_process"
|
||||
import fsSync from "fs"
|
||||
import * as fs from "fs/promises"
|
||||
import { globby } from "globby"
|
||||
import { createRequire } from "module"
|
||||
@@ -12,7 +13,20 @@ import { main as generateHostBridgeClient } from "./generate-host-bridge-client.
|
||||
import { main as generateProtoBusSetup } from "./generate-protobus-setup.mjs"
|
||||
|
||||
const require = createRequire(import.meta.url)
|
||||
const PROTOC = path.join(require.resolve("grpc-tools"), "../bin/protoc")
|
||||
const isWindows = process.platform === "win32"
|
||||
const GRPC_TOOLS_PROTOC = path.join(require.resolve("grpc-tools"), "../bin", isWindows ? "protoc.exe" : "protoc")
|
||||
// Legacy compatibility: some older/local Windows setups provision protoc into tmp-protoc.
|
||||
// Prefer that path when present, but fall back to the grpc-tools bundled binary used by CI/npm installs.
|
||||
const LEGACY_WINDOWS_PROTOC = path.resolve("tmp-protoc/bin/protoc.exe")
|
||||
const PROTOC = isWindows && fsSync.existsSync(LEGACY_WINDOWS_PROTOC) ? LEGACY_WINDOWS_PROTOC : GRPC_TOOLS_PROTOC
|
||||
|
||||
if (!fsSync.existsSync(PROTOC)) {
|
||||
const windowsHint = isWindows
|
||||
? ` Neither ${LEGACY_WINDOWS_PROTOC} nor the grpc-tools bundled protoc at ${GRPC_TOOLS_PROTOC} exists.`
|
||||
: ""
|
||||
console.error(chalk.red(`protoc not found at ${PROTOC}.${windowsHint}`))
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const PROTO_DIR = path.resolve("proto")
|
||||
const TS_OUT_DIR = path.resolve("src/shared/proto")
|
||||
@@ -20,7 +34,6 @@ const GRPC_JS_OUT_DIR = path.resolve("src/generated/grpc-js")
|
||||
const NICE_JS_OUT_DIR = path.resolve("src/generated/nice-grpc")
|
||||
const DESCRIPTOR_OUT_DIR = path.resolve("dist-standalone/proto")
|
||||
|
||||
const isWindows = process.platform === "win32"
|
||||
const TS_PROTO_PLUGIN = isWindows
|
||||
? path.resolve("node_modules/.bin/protoc-gen-ts_proto.cmd") // Use the .bin directory path for Windows
|
||||
: require.resolve("ts-proto/protoc-gen-ts_proto")
|
||||
@@ -62,16 +75,16 @@ async function compileProtos() {
|
||||
tsProtoc(NICE_JS_OUT_DIR, protoFiles, ["outputServices=nice-grpc,useExactTypes=false", ...TS_PROTO_OPTIONS])
|
||||
|
||||
const descriptorFile = path.join(DESCRIPTOR_OUT_DIR, "descriptor_set.pb")
|
||||
const descriptorProtocCommand = [
|
||||
PROTOC,
|
||||
`--proto_path="${PROTO_DIR}"`,
|
||||
`--descriptor_set_out="${descriptorFile}"`,
|
||||
const descriptorProtocArgs = [
|
||||
`--proto_path=${PROTO_DIR}`,
|
||||
`--descriptor_set_out=${descriptorFile}`,
|
||||
"--include_imports",
|
||||
...protoFiles,
|
||||
].join(" ")
|
||||
]
|
||||
try {
|
||||
log_verbose(chalk.cyan("Generating descriptor set..."))
|
||||
execSync(descriptorProtocCommand, { stdio: "inherit" })
|
||||
log_verbose(`${PROTOC} ${descriptorProtocArgs.join(" ")}`)
|
||||
execFileSync(PROTOC, descriptorProtocArgs, { stdio: "inherit" })
|
||||
} catch (error) {
|
||||
console.error(chalk.red("Error generating descriptor set for proto file:"), error)
|
||||
process.exit(1)
|
||||
@@ -81,20 +94,18 @@ async function compileProtos() {
|
||||
log_verbose(chalk.green(`TypeScript files generated in: ${TS_OUT_DIR}`))
|
||||
}
|
||||
|
||||
async function tsProtoc(outDir, protoFiles, protoOptions) {
|
||||
// Build the protoc command with proper path handling for cross-platform
|
||||
const command = [
|
||||
PROTOC,
|
||||
`--proto_path="${PROTO_DIR}"`,
|
||||
`--plugin=protoc-gen-ts_proto="${TS_PROTO_PLUGIN}"`,
|
||||
`--ts_proto_out="${outDir}"`,
|
||||
`--ts_proto_opt=${protoOptions.join(",")} `,
|
||||
...protoFiles.map((s) => `"${s}"`),
|
||||
].join(" ")
|
||||
function tsProtoc(outDir, protoFiles, protoOptions) {
|
||||
const args = [
|
||||
`--proto_path=${PROTO_DIR}`,
|
||||
`--plugin=protoc-gen-ts_proto=${TS_PROTO_PLUGIN}`,
|
||||
`--ts_proto_out=${outDir}`,
|
||||
`--ts_proto_opt=${protoOptions.join(",")}`,
|
||||
...protoFiles,
|
||||
]
|
||||
try {
|
||||
log_verbose(chalk.cyan(`Generating TypeScript code in ${outDir} for:\n${protoFiles.join("\n")}...`))
|
||||
log_verbose(command)
|
||||
execSync(command, { stdio: "inherit" })
|
||||
log_verbose(`${PROTOC} ${args.join(" ")}`)
|
||||
execFileSync(PROTOC, args, { stdio: "inherit" })
|
||||
} catch (error) {
|
||||
console.error(chalk.red("Error generating TypeScript for proto files:"), error)
|
||||
process.exit(1)
|
||||
|
||||
@@ -230,6 +230,40 @@ function snakeToCamel(str) {
|
||||
return str.replace(/_([a-z])/g, (_, letter) => letter.toUpperCase())
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a property key to the camelCase form parsed from proto field names.
|
||||
* Example: "openai-codex-oauth-credentials" -> "openaiCodexOauthCredentials".
|
||||
* We do this before looking up existing field numbers so regenerated proto fields
|
||||
* reuse their old numbers instead of being treated as new fields.
|
||||
*/
|
||||
function normalizePropertyNameForProtoLookup(fieldName) {
|
||||
return snakeToCamel(toProtoFieldName(fieldName))
|
||||
}
|
||||
|
||||
/**
|
||||
* Fail fast when two distinct property keys collapse to the same normalized lookup key.
|
||||
* Collisions make field-number preservation ambiguous and can cause proto renumbering.
|
||||
*/
|
||||
function assertNoNormalizedFieldNameCollisions(fields) {
|
||||
const normalizedToOriginalNames = new Map()
|
||||
|
||||
for (const field of fields) {
|
||||
const normalizedName = normalizePropertyNameForProtoLookup(field.name)
|
||||
const names = normalizedToOriginalNames.get(normalizedName) ?? new Set()
|
||||
names.add(field.name)
|
||||
normalizedToOriginalNames.set(normalizedName, names)
|
||||
}
|
||||
|
||||
for (const [normalizedName, names] of normalizedToOriginalNames) {
|
||||
if (names.size > 1) {
|
||||
const sortedNames = [...names].sort()
|
||||
throw new Error(
|
||||
`Field-name collision after proto normalization: ${sortedNames.join(", ")} all normalize to "${normalizedName}". Rename one field to keep proto field-number mapping unambiguous.`,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse field numbers from an existing proto message definition
|
||||
* Returns a map of camelCase field names to their field numbers
|
||||
@@ -248,7 +282,8 @@ function parseProtoMessageFieldNumbers(protoContent, messageName) {
|
||||
const messageBody = match[1]
|
||||
|
||||
// Match field definitions: optional/required/repeated type name = number;
|
||||
const fieldRegex = /(?:optional|required|repeated)?\s*\w+\s+(\w+)\s*=\s*(\d+)\s*;/g
|
||||
// Supports scalar/message types and map fields.
|
||||
const fieldRegex = /(?:optional|required|repeated)?\s*(?:map<[^>]+>|[\w.]+)\s+(\w+)\s*=\s*(\d+)\s*;/g
|
||||
const matches = messageBody.matchAll(fieldRegex)
|
||||
|
||||
for (const fieldMatch of matches) {
|
||||
@@ -287,6 +322,8 @@ function assignFieldNumbers(fields, existingNumbers, startNumber = 1) {
|
||||
const result = {}
|
||||
let nextNumber = startNumber
|
||||
|
||||
assertNoNormalizedFieldNameCollisions(fields)
|
||||
|
||||
// Find the highest existing number
|
||||
for (const num of Object.values(existingNumbers)) {
|
||||
if (num >= nextNumber) {
|
||||
@@ -296,8 +333,21 @@ function assignFieldNumbers(fields, existingNumbers, startNumber = 1) {
|
||||
|
||||
// Preserve existing assignments
|
||||
for (const field of fields) {
|
||||
if (existingNumbers[field.name] !== undefined) {
|
||||
result[field.name] = existingNumbers[field.name]
|
||||
// Normalize before lookup so keys like "openai-codex-oauth-credentials"
|
||||
// reuse existing proto numbers instead of being treated as new fields.
|
||||
const normalizedFieldName = normalizePropertyNameForProtoLookup(field.name)
|
||||
const normalizedFieldNumber = existingNumbers[normalizedFieldName]
|
||||
const rawFieldNumber = existingNumbers[field.name]
|
||||
|
||||
if (normalizedFieldNumber !== undefined && rawFieldNumber !== undefined && normalizedFieldNumber !== rawFieldNumber) {
|
||||
throw new Error(
|
||||
`Ambiguous proto field-number mapping for "${field.name}": normalized key "${normalizedFieldName}" -> ${normalizedFieldNumber}, raw key "${field.name}" -> ${rawFieldNumber}.`,
|
||||
)
|
||||
}
|
||||
|
||||
const existingFieldNumber = normalizedFieldNumber ?? rawFieldNumber
|
||||
if (existingFieldNumber !== undefined) {
|
||||
result[field.name] = existingFieldNumber
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -42,6 +42,7 @@ import { TogetherHandler } from "./providers/together"
|
||||
import { VercelAIGatewayHandler } from "./providers/vercel-ai-gateway"
|
||||
import { VertexHandler } from "./providers/vertex"
|
||||
import { VsCodeLmHandler } from "./providers/vscode-lm"
|
||||
import { WandbHandler } from "./providers/wandb"
|
||||
import { XAIHandler } from "./providers/xai"
|
||||
import { ZAiHandler } from "./providers/zai"
|
||||
import { ApiStream, ApiStreamUsageChunk } from "./transform/stream"
|
||||
@@ -97,6 +98,7 @@ function createHandlerForProvider(
|
||||
reasoningEffort: mode === "plan" ? options.planModeReasoningEffort : options.actModeReasoningEffort,
|
||||
thinkingBudgetTokens:
|
||||
mode === "plan" ? options.planModeThinkingBudgetTokens : options.actModeThinkingBudgetTokens,
|
||||
enableParallelToolCalling: options.enableParallelToolCalling,
|
||||
})
|
||||
case "bedrock":
|
||||
return new AwsBedrockHandler({
|
||||
@@ -271,6 +273,7 @@ function createHandlerForProvider(
|
||||
openRouterProviderSorting: options.openRouterProviderSorting,
|
||||
openRouterModelId: clineModelId,
|
||||
openRouterModelInfo: clineModelInfo,
|
||||
enableParallelToolCalling: options.enableParallelToolCalling,
|
||||
})
|
||||
}
|
||||
case "litellm":
|
||||
@@ -450,6 +453,12 @@ function createHandlerForProvider(
|
||||
nousResearchApiKey: options.nousResearchApiKey,
|
||||
apiModelId: mode === "plan" ? options.planModeNousResearchModelId : options.actModeNousResearchModelId,
|
||||
})
|
||||
case "wandb":
|
||||
return new WandbHandler({
|
||||
onRetryAttempt: options.onRetryAttempt,
|
||||
wandbApiKey: options.wandbApiKey,
|
||||
apiModelId: mode === "plan" ? options.planModeApiModelId : options.actModeApiModelId,
|
||||
})
|
||||
default:
|
||||
return new AnthropicHandler({
|
||||
onRetryAttempt: options.onRetryAttempt,
|
||||
|
||||
@@ -226,6 +226,136 @@ describe("ClaudeCodeHandler", () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe("error handling", () => {
|
||||
it("should not crash when assistant message has empty content array", async () => {
|
||||
const runClaudeCodeModule = await import("@/integrations/claude-code/run")
|
||||
const runClaudeCodeStub = sandbox.stub(runClaudeCodeModule, "runClaudeCode")
|
||||
|
||||
async function* mockGenerator() {
|
||||
yield {
|
||||
type: "assistant",
|
||||
message: {
|
||||
content: [], // empty content — triggered TypeError in older code
|
||||
usage: {
|
||||
input_tokens: 10,
|
||||
output_tokens: 0,
|
||||
},
|
||||
stop_reason: "end_turn",
|
||||
},
|
||||
}
|
||||
|
||||
yield {
|
||||
type: "result",
|
||||
result: {},
|
||||
total_cost_usd: 0,
|
||||
}
|
||||
}
|
||||
|
||||
runClaudeCodeStub.returns(mockGenerator() as any)
|
||||
|
||||
const chunks: any[] = []
|
||||
// Should not throw
|
||||
for await (const chunk of handler.createMessage("system", [{ role: "user", content: "hi" }])) {
|
||||
chunks.push(chunk)
|
||||
}
|
||||
|
||||
const usageChunk = chunks.find((c) => c.type === "usage")
|
||||
usageChunk.should.be.ok()
|
||||
usageChunk.inputTokens.should.equal(10)
|
||||
})
|
||||
|
||||
it("should throw when result has is_error=true (e.g. rate limit with no assistant message)", async () => {
|
||||
const runClaudeCodeModule = await import("@/integrations/claude-code/run")
|
||||
const runClaudeCodeStub = sandbox.stub(runClaudeCodeModule, "runClaudeCode")
|
||||
|
||||
async function* mockGenerator() {
|
||||
yield {
|
||||
type: "system",
|
||||
subtype: "init",
|
||||
apiKeySource: "none",
|
||||
}
|
||||
|
||||
yield {
|
||||
type: "system",
|
||||
subtype: "rate_limit_event",
|
||||
message: "Rate limit hit",
|
||||
retryAfterSeconds: 30,
|
||||
}
|
||||
|
||||
// No assistant message — CLI hit rate limit and gave up
|
||||
yield {
|
||||
type: "result",
|
||||
subtype: "error",
|
||||
is_error: true,
|
||||
result: "Rate limit exceeded",
|
||||
total_cost_usd: 0,
|
||||
duration_ms: 1000,
|
||||
duration_api_ms: 500,
|
||||
num_turns: 0,
|
||||
session_id: "test",
|
||||
}
|
||||
}
|
||||
|
||||
runClaudeCodeStub.returns(mockGenerator() as any)
|
||||
|
||||
let thrownError: Error | undefined
|
||||
try {
|
||||
for await (const _ of handler.createMessage("system", [{ role: "user", content: "hi" }])) {
|
||||
// consume
|
||||
}
|
||||
} catch (err) {
|
||||
thrownError = err as Error
|
||||
}
|
||||
|
||||
thrownError!.message.should.containEql("Rate limit exceeded")
|
||||
})
|
||||
|
||||
it("should ignore rate_limit_event system messages without throwing", async () => {
|
||||
const runClaudeCodeModule = await import("@/integrations/claude-code/run")
|
||||
const runClaudeCodeStub = sandbox.stub(runClaudeCodeModule, "runClaudeCode")
|
||||
|
||||
async function* mockGenerator() {
|
||||
yield {
|
||||
type: "system",
|
||||
subtype: "init",
|
||||
apiKeySource: "none",
|
||||
}
|
||||
|
||||
// Newer Claude Code CLI emits this during rate limiting
|
||||
yield {
|
||||
type: "system",
|
||||
subtype: "rate_limit_event",
|
||||
message: "Rate limit hit, retrying...",
|
||||
retryAfterSeconds: 30,
|
||||
}
|
||||
|
||||
yield {
|
||||
type: "assistant",
|
||||
message: {
|
||||
content: [{ type: "text", text: "Response after retry" }],
|
||||
usage: { input_tokens: 20, output_tokens: 10 },
|
||||
stop_reason: "end_turn",
|
||||
},
|
||||
}
|
||||
|
||||
yield {
|
||||
type: "result",
|
||||
result: {},
|
||||
total_cost_usd: 0,
|
||||
}
|
||||
}
|
||||
|
||||
runClaudeCodeStub.returns(mockGenerator() as any)
|
||||
|
||||
const textChunks: string[] = []
|
||||
for await (const chunk of handler.createMessage("system", [{ role: "user", content: "hi" }])) {
|
||||
if (chunk.type === "text") textChunks.push(chunk.text)
|
||||
}
|
||||
|
||||
textChunks.should.deepEqual(["Response after retry"])
|
||||
})
|
||||
})
|
||||
|
||||
describe("getModel", () => {
|
||||
it("should return the correct model when specified", () => {
|
||||
const handler = new ClaudeCodeHandler({
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import "should"
|
||||
import { openRouterDefaultModelInfo } from "@shared/api"
|
||||
import sinon from "sinon"
|
||||
import { ClineAccountService } from "@/services/account/ClineAccountService"
|
||||
import { AuthService } from "@/services/auth/AuthService"
|
||||
import { ClineHandler } from "../cline"
|
||||
|
||||
describe("ClineHandler", () => {
|
||||
@@ -14,9 +16,14 @@ describe("ClineHandler", () => {
|
||||
},
|
||||
})
|
||||
|
||||
const createHandler = (options: ConstructorParameters<typeof ClineHandler>[0]) => {
|
||||
sinon.stub(ClineAccountService, "getInstance").returns({} as any)
|
||||
sinon.stub(AuthService, "getInstance").returns({} as any)
|
||||
return new ClineHandler(options)
|
||||
}
|
||||
|
||||
it("should handle usage-only chunks when delta is missing", async () => {
|
||||
const handler = Object.create(ClineHandler.prototype) as ClineHandler
|
||||
;(handler as any).options = {}
|
||||
const handler = createHandler({})
|
||||
const fakeClient = {
|
||||
chat: {
|
||||
completions: {
|
||||
@@ -56,4 +63,31 @@ describe("ClineHandler", () => {
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it("should forward enableParallelToolCalling to OpenRouter payload", async () => {
|
||||
const handler = createHandler({ enableParallelToolCalling: true })
|
||||
const createStub = sinon.stub().resolves(createAsyncIterable([]))
|
||||
const fakeClient = {
|
||||
chat: {
|
||||
completions: {
|
||||
create: createStub,
|
||||
},
|
||||
},
|
||||
}
|
||||
sinon.stub(handler as any, "ensureClient").resolves(fakeClient as any)
|
||||
sinon.stub(handler, "getModel").returns({
|
||||
id: "openai/gpt-4o-mini",
|
||||
info: openRouterDefaultModelInfo,
|
||||
})
|
||||
|
||||
const tools = [
|
||||
{ type: "function", function: { name: "read_file", description: "", parameters: { type: "object" } } },
|
||||
] as any
|
||||
for await (const _chunk of handler.createMessage("system", [{ role: "user", content: "hi" }], tools)) {
|
||||
// drain stream
|
||||
}
|
||||
|
||||
const payload = createStub.firstCall.args[0]
|
||||
payload.parallel_tool_calls.should.equal(true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -52,4 +52,46 @@ describe("FireworksHandler", () => {
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it("should read cache hits from prompt_tokens_details when hit tokens are not present", async () => {
|
||||
const handler = new FireworksHandler({
|
||||
fireworksApiKey: "test-api-key",
|
||||
fireworksModelId: "accounts/fireworks/models/llama-v3p1-8b-instruct",
|
||||
})
|
||||
const fakeClient = {
|
||||
chat: {
|
||||
completions: {
|
||||
create: sinon.stub().resolves(
|
||||
createAsyncIterable([
|
||||
{
|
||||
choices: [{}],
|
||||
usage: {
|
||||
prompt_tokens: 60,
|
||||
completion_tokens: 12,
|
||||
prompt_tokens_details: { cached_tokens: 20 },
|
||||
prompt_cache_miss_tokens: 40,
|
||||
},
|
||||
},
|
||||
]),
|
||||
),
|
||||
},
|
||||
},
|
||||
}
|
||||
sinon.stub(handler as any, "ensureClient").returns(fakeClient as any)
|
||||
|
||||
const chunks: any[] = []
|
||||
for await (const chunk of handler.createMessage("system", [{ role: "user", content: "hi" }])) {
|
||||
chunks.push(chunk)
|
||||
}
|
||||
|
||||
chunks.should.deepEqual([
|
||||
{
|
||||
type: "usage",
|
||||
inputTokens: 60,
|
||||
outputTokens: 12,
|
||||
cacheReadTokens: 20,
|
||||
cacheWriteTokens: 40,
|
||||
},
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
import "should"
|
||||
import sinon from "sinon"
|
||||
import { GeminiHandler } from "../gemini"
|
||||
|
||||
describe("GeminiHandler", () => {
|
||||
afterEach(() => {
|
||||
sinon.restore()
|
||||
})
|
||||
|
||||
const createAsyncIterable = (data: any[] = []) => ({
|
||||
[Symbol.asyncIterator]: async function* () {
|
||||
yield* data
|
||||
},
|
||||
})
|
||||
|
||||
it("caps maxOutputTokens to 8192 for Flash models", async () => {
|
||||
const handler = new GeminiHandler({
|
||||
geminiApiKey: "test-api-key",
|
||||
apiModelId: "gemini-2.5-flash",
|
||||
})
|
||||
|
||||
const generateContentStream = sinon.stub().resolves(
|
||||
createAsyncIterable([
|
||||
{
|
||||
responseId: "resp-1",
|
||||
usageMetadata: {
|
||||
promptTokenCount: 10,
|
||||
candidatesTokenCount: 20,
|
||||
cachedContentTokenCount: 0,
|
||||
thoughtsTokenCount: 0,
|
||||
},
|
||||
},
|
||||
]),
|
||||
)
|
||||
sinon.stub(handler as any, "ensureClient").returns({
|
||||
models: { generateContentStream },
|
||||
} as any)
|
||||
|
||||
for await (const _chunk of handler.createMessage("system", [{ role: "user", content: "hi" }] as any)) {
|
||||
// Consume stream to trigger request execution.
|
||||
}
|
||||
|
||||
const requestArgs = generateContentStream.firstCall.args[0] as Record<string, any>
|
||||
requestArgs.config.should.have.property("maxOutputTokens", 8_192)
|
||||
})
|
||||
|
||||
it("does not set maxOutputTokens for non-Flash models", async () => {
|
||||
const handler = new GeminiHandler({
|
||||
geminiApiKey: "test-api-key",
|
||||
apiModelId: "gemini-2.5-pro",
|
||||
})
|
||||
|
||||
const generateContentStream = sinon.stub().resolves(
|
||||
createAsyncIterable([
|
||||
{
|
||||
responseId: "resp-2",
|
||||
usageMetadata: {
|
||||
promptTokenCount: 10,
|
||||
candidatesTokenCount: 20,
|
||||
cachedContentTokenCount: 0,
|
||||
thoughtsTokenCount: 0,
|
||||
},
|
||||
},
|
||||
]),
|
||||
)
|
||||
sinon.stub(handler as any, "ensureClient").returns({
|
||||
models: { generateContentStream },
|
||||
} as any)
|
||||
|
||||
for await (const _chunk of handler.createMessage("system", [{ role: "user", content: "hi" }] as any)) {
|
||||
// Consume stream to trigger request execution.
|
||||
}
|
||||
|
||||
const requestArgs = generateContentStream.firstCall.args[0] as Record<string, any>
|
||||
requestArgs.config.should.not.have.property("maxOutputTokens")
|
||||
})
|
||||
|
||||
it("should emit unique tool call IDs when multiple function calls share one responseId", async () => {
|
||||
const handler = new GeminiHandler({
|
||||
geminiApiKey: "test-api-key",
|
||||
})
|
||||
|
||||
const fakeClient = {
|
||||
models: {
|
||||
generateContentStream: sinon.stub().resolves(
|
||||
createAsyncIterable([
|
||||
{
|
||||
responseId: "resp_1",
|
||||
candidates: [
|
||||
{
|
||||
content: {
|
||||
parts: [
|
||||
{
|
||||
functionCall: {
|
||||
name: "read_file",
|
||||
args: { path: ".nvmrc" },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
responseId: "resp_1",
|
||||
candidates: [
|
||||
{
|
||||
content: {
|
||||
parts: [
|
||||
{
|
||||
functionCall: {
|
||||
name: "read_file",
|
||||
args: { path: ".gitattributes" },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
]),
|
||||
),
|
||||
},
|
||||
}
|
||||
sinon.stub(handler as any, "ensureClient").returns(fakeClient as any)
|
||||
|
||||
const tools = [{ name: "read_file", description: "read file", parameters: { type: "OBJECT" } }] as any
|
||||
const chunks: any[] = []
|
||||
for await (const chunk of handler.createMessage("system", [{ role: "user", content: "hi" }], tools)) {
|
||||
if (chunk.type === "tool_calls") {
|
||||
chunks.push(chunk)
|
||||
}
|
||||
}
|
||||
|
||||
chunks.should.have.length(2)
|
||||
chunks[0].tool_call.function.id.should.equal("resp_1-tool-0")
|
||||
chunks[1].tool_call.function.id.should.equal("resp_1-tool-1")
|
||||
chunks[0].tool_call.call_id.should.equal(chunks[0].tool_call.function.id)
|
||||
chunks[1].tool_call.call_id.should.equal(chunks[1].tool_call.function.id)
|
||||
JSON.parse(chunks[0].tool_call.function.arguments).path.should.equal(".nvmrc")
|
||||
JSON.parse(chunks[1].tool_call.function.arguments).path.should.equal(".gitattributes")
|
||||
})
|
||||
|
||||
it("should preserve Gemini-provided functionCall.id when present", async () => {
|
||||
const handler = new GeminiHandler({
|
||||
geminiApiKey: "test-api-key",
|
||||
})
|
||||
|
||||
const fakeClient = {
|
||||
models: {
|
||||
generateContentStream: sinon.stub().resolves(
|
||||
createAsyncIterable([
|
||||
{
|
||||
responseId: "resp_2",
|
||||
candidates: [
|
||||
{
|
||||
content: {
|
||||
parts: [
|
||||
{
|
||||
functionCall: {
|
||||
id: "call_alpha",
|
||||
name: "read_file",
|
||||
args: { path: ".nvmrc" },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
]),
|
||||
),
|
||||
},
|
||||
}
|
||||
sinon.stub(handler as any, "ensureClient").returns(fakeClient as any)
|
||||
|
||||
const tools = [{ name: "read_file", description: "read file", parameters: { type: "OBJECT" } }] as any
|
||||
const chunks: any[] = []
|
||||
for await (const chunk of handler.createMessage("system", [{ role: "user", content: "hi" }], tools)) {
|
||||
if (chunk.type === "tool_calls") {
|
||||
chunks.push(chunk)
|
||||
}
|
||||
}
|
||||
|
||||
chunks.should.have.length(1)
|
||||
chunks[0].tool_call.function.id.should.equal("call_alpha")
|
||||
chunks[0].tool_call.call_id.should.equal("call_alpha")
|
||||
JSON.parse(chunks[0].tool_call.function.arguments).path.should.equal(".nvmrc")
|
||||
})
|
||||
})
|
||||
@@ -14,6 +14,8 @@ describe("OpenRouterHandler", () => {
|
||||
},
|
||||
})
|
||||
|
||||
const tools = [{ type: "function", function: { name: "read_file", description: "", parameters: { type: "object" } } }] as any
|
||||
|
||||
it("should handle usage-only chunks when delta is missing", async () => {
|
||||
const handler = new OpenRouterHandler({
|
||||
openRouterApiKey: "test-api-key",
|
||||
@@ -57,4 +59,58 @@ describe("OpenRouterHandler", () => {
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
type ParallelToolCallsTestCase = {
|
||||
modelId: string
|
||||
enableParallelToolCalling: boolean
|
||||
expectedParallelToolCalls: boolean
|
||||
}
|
||||
|
||||
const parallelToolCallsTestCases: ParallelToolCallsTestCase[] = [
|
||||
{
|
||||
modelId: "openai/gpt-4o-mini",
|
||||
enableParallelToolCalling: true,
|
||||
expectedParallelToolCalls: true,
|
||||
},
|
||||
{
|
||||
modelId: "openai/gpt-4o-mini",
|
||||
enableParallelToolCalling: false,
|
||||
expectedParallelToolCalls: false,
|
||||
},
|
||||
{
|
||||
modelId: "google/gemini-3-flash-preview",
|
||||
enableParallelToolCalling: true,
|
||||
expectedParallelToolCalls: true,
|
||||
},
|
||||
]
|
||||
|
||||
for (const testCase of parallelToolCallsTestCases) {
|
||||
const settingLabel = testCase.enableParallelToolCalling ? "enabled" : "disabled"
|
||||
it(`should set parallel_tool_calls=${testCase.expectedParallelToolCalls} for ${testCase.modelId} when setting is ${settingLabel}`, async () => {
|
||||
const handler = new OpenRouterHandler({
|
||||
openRouterApiKey: "test-api-key",
|
||||
enableParallelToolCalling: testCase.enableParallelToolCalling,
|
||||
})
|
||||
const createStub = sinon.stub().resolves(createAsyncIterable([]))
|
||||
const fakeClient = {
|
||||
chat: {
|
||||
completions: {
|
||||
create: createStub,
|
||||
},
|
||||
},
|
||||
}
|
||||
sinon.stub(handler as any, "ensureClient").returns(fakeClient as any)
|
||||
sinon.stub(handler, "getModel").returns({
|
||||
id: testCase.modelId,
|
||||
info: openRouterDefaultModelInfo,
|
||||
})
|
||||
|
||||
for await (const _chunk of handler.createMessage("system", [{ role: "user", content: "hi" }], tools)) {
|
||||
// drain stream
|
||||
}
|
||||
|
||||
const payload = createStub.firstCall.args[0]
|
||||
payload.parallel_tool_calls.should.equal(testCase.expectedParallelToolCalls)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import "should"
|
||||
import { openAiModelInfoSaneDefaults, wandbDefaultModelId, wandbModels } from "@shared/api"
|
||||
import { WandbHandler } from "../wandb"
|
||||
|
||||
describe("WandbHandler", () => {
|
||||
it("returns known catalog model metadata when model id is recognized", () => {
|
||||
const modelId = "meta-llama/Llama-3.3-70B-Instruct"
|
||||
const handler = new WandbHandler({
|
||||
wandbApiKey: "test-api-key",
|
||||
apiModelId: modelId,
|
||||
})
|
||||
|
||||
const model = handler.getModel()
|
||||
|
||||
model.id.should.equal(modelId)
|
||||
model.info.should.deepEqual(wandbModels[modelId])
|
||||
})
|
||||
|
||||
it("passes through an explicit unknown model id instead of silently falling back", () => {
|
||||
const unknownModelId = "moonshotai/Kimi-K2.5"
|
||||
const handler = new WandbHandler({
|
||||
wandbApiKey: "test-api-key",
|
||||
apiModelId: unknownModelId,
|
||||
})
|
||||
|
||||
const model = handler.getModel()
|
||||
|
||||
model.id.should.equal(unknownModelId)
|
||||
model.info.should.deepEqual(openAiModelInfoSaneDefaults)
|
||||
})
|
||||
|
||||
it("uses the default W&B model when no model id is configured", () => {
|
||||
const handler = new WandbHandler({
|
||||
wandbApiKey: "test-api-key",
|
||||
})
|
||||
|
||||
const model = handler.getModel()
|
||||
|
||||
model.id.should.equal(wandbDefaultModelId)
|
||||
model.info.should.deepEqual(wandbModels[wandbDefaultModelId])
|
||||
})
|
||||
})
|
||||
@@ -2,6 +2,7 @@ import { filterMessagesForClaudeCode } from "@/integrations/claude-code/message-
|
||||
import { runClaudeCode } from "@/integrations/claude-code/run"
|
||||
import { ClaudeCodeModelId, claudeCodeDefaultModelId, claudeCodeModels } from "@/shared/api"
|
||||
import { ClineStorageMessage } from "@/shared/messages/content"
|
||||
import { Logger } from "@/shared/services/Logger"
|
||||
import { type ApiHandler, CommonApiHandlerOptions } from ".."
|
||||
import { withRetry } from "../retry"
|
||||
import { type ApiStream, ApiStreamUsageChunk } from "../transform/stream"
|
||||
@@ -58,20 +59,47 @@ export class ClaudeCodeHandler implements ApiHandler {
|
||||
continue
|
||||
}
|
||||
|
||||
if (chunk.type === "system" && chunk.subtype === "init") {
|
||||
// Based on my tests, subscription usage sets the `apiKeySource` to "none"
|
||||
isPaidUsage = chunk.apiKeySource !== "none"
|
||||
// Handle system init messages
|
||||
if (chunk.type === "system" && "subtype" in chunk) {
|
||||
if (chunk.subtype === "init") {
|
||||
// Based on my tests, subscription usage sets the `apiKeySource` to "none"
|
||||
isPaidUsage = (chunk as any).apiKeySource !== "none"
|
||||
}
|
||||
// Also handles legacy rate_limit_event format (type: "system", subtype: "rate_limit_event")
|
||||
// by falling through — no special handling needed.
|
||||
continue
|
||||
}
|
||||
|
||||
// Handle rate_limit_event (newer CLI format: top-level type)
|
||||
if (chunk.type === "rate_limit_event") {
|
||||
// Rate limit events are informational. Log them but don't yield anything.
|
||||
// If the rate limit blocks the response, the stream will end without
|
||||
// assistant messages and the task loop will handle the empty response.
|
||||
Logger.log("Claude Code rate limit event:", JSON.stringify(chunk))
|
||||
continue
|
||||
}
|
||||
|
||||
// Skip user messages (tool results from Claude Code's own tool execution)
|
||||
if (chunk.type === "user") {
|
||||
continue
|
||||
}
|
||||
|
||||
if (chunk.type === "assistant" && "message" in chunk) {
|
||||
const message = chunk.message
|
||||
|
||||
if (message.stop_reason !== null) {
|
||||
const content = "text" in message.content[0] ? message.content[0] : undefined
|
||||
// Check for error field on the message (newer CLI format)
|
||||
if (message.error) {
|
||||
const firstContent = message.content?.[0]
|
||||
const errorText = firstContent && "text" in firstContent ? firstContent.text : undefined
|
||||
throw new Error(errorText ?? `Claude Code error: ${message.error}`)
|
||||
}
|
||||
|
||||
const isError = content && content.text.startsWith(`API Error`)
|
||||
if (isError) {
|
||||
if (message.stop_reason !== null) {
|
||||
const firstContent = message.content?.[0]
|
||||
const content = firstContent && "text" in firstContent ? firstContent : undefined
|
||||
|
||||
// Check if content exists before accessing its properties
|
||||
if (content && content.text.startsWith(`API Error`)) {
|
||||
// Error messages are formatted as: `API Error: <<status code>> <<json>>`
|
||||
const errorMessageStart = content.text.indexOf("{")
|
||||
const errorMessage = content.text.slice(errorMessageStart)
|
||||
@@ -126,6 +154,21 @@ export class ClaudeCodeHandler implements ApiHandler {
|
||||
},
|
||||
}
|
||||
break
|
||||
default: {
|
||||
// Handle unknown content block types gracefully.
|
||||
// Newer Anthropic models or CLI versions may introduce new content types
|
||||
// (e.g., server_tool_use, mcp_tool_use). Log them instead of silently dropping.
|
||||
const unknownBlock = content as { type: string; text?: string }
|
||||
Logger.warn(`Unhandled content type in Claude Code response: ${unknownBlock.type}`)
|
||||
// If the unknown block has a text-like field, try to yield it as text
|
||||
if (typeof unknownBlock.text === "string") {
|
||||
yield {
|
||||
type: "text",
|
||||
text: unknownBlock.text,
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -142,10 +185,24 @@ export class ClaudeCodeHandler implements ApiHandler {
|
||||
}
|
||||
|
||||
if (chunk.type === "result" && "result" in chunk) {
|
||||
if (chunk.is_error) {
|
||||
throw new Error(`Claude Code returned an error: ${chunk.result}`)
|
||||
}
|
||||
|
||||
usage.totalCost = isPaidUsage ? chunk.total_cost_usd : 0
|
||||
|
||||
yield usage
|
||||
continue
|
||||
}
|
||||
|
||||
// ErrorMessage — log it explicitly and skip
|
||||
if ((chunk as any).type === "error") {
|
||||
Logger.warn("Claude Code emitted an error-type chunk:", JSON.stringify(chunk))
|
||||
continue
|
||||
}
|
||||
|
||||
// Any completely unrecognized chunk type — log and skip
|
||||
Logger.warn(`Unrecognized Claude Code chunk type: ${(chunk as any).type}`)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -28,6 +28,7 @@ interface ClineHandlerOptions extends CommonApiHandlerOptions {
|
||||
openRouterModelInfo?: ModelInfo
|
||||
clineAccountId?: string
|
||||
clineApiKey?: string
|
||||
enableParallelToolCalling?: boolean
|
||||
}
|
||||
|
||||
const CLINE_FREE_MODELS = ["minimax/minimax-m2.5", "kwaipilot/kat-coder-pro", "z-ai/glm-5"]
|
||||
@@ -121,6 +122,7 @@ export class ClineHandler implements ApiHandler {
|
||||
this.options.thinkingBudgetTokens,
|
||||
this.options.openRouterProviderSorting,
|
||||
tools,
|
||||
this.options.enableParallelToolCalling,
|
||||
)
|
||||
|
||||
const toolCallProcessor = new ToolCallProcessor()
|
||||
|
||||
@@ -83,14 +83,20 @@ export class FireworksHandler implements ApiHandler {
|
||||
}
|
||||
|
||||
if (chunk.usage) {
|
||||
const usage = chunk.usage as OpenAI.CompletionUsage & {
|
||||
prompt_cache_hit_tokens?: number
|
||||
prompt_cache_miss_tokens?: number
|
||||
prompt_tokens_details?: {
|
||||
cached_tokens?: number
|
||||
}
|
||||
}
|
||||
yield {
|
||||
type: "usage",
|
||||
inputTokens: chunk.usage.prompt_tokens || 0, // (deepseek reports total input AND cache reads/writes, see context caching: https://api-docs.deepseek.com/guides/kv_cache) where the input tokens is the sum of the cache hits/misses, while anthropic reports them as separate tokens. This is important to know for 1) context management truncation algorithm, and 2) cost calculation (NOTE: we report both input and cache stats but for now set input price to 0 since all the cost calculation will be done using cache hits/misses)
|
||||
outputTokens: chunk.usage.completion_tokens || 0,
|
||||
// @ts-expect-error-next-line
|
||||
cacheReadTokens: chunk.usage.prompt_cache_hit_tokens || 0,
|
||||
// @ts-expect-error-next-line
|
||||
cacheWriteTokens: chunk.usage.prompt_cache_miss_tokens || 0,
|
||||
inputTokens: usage.prompt_tokens || 0,
|
||||
outputTokens: usage.completion_tokens || 0,
|
||||
// Fireworks can return cache hits either as prompt_cache_hit_tokens or prompt_tokens_details.cached_tokens.
|
||||
cacheReadTokens: usage.prompt_cache_hit_tokens ?? usage.prompt_tokens_details?.cached_tokens ?? 0,
|
||||
cacheWriteTokens: usage.prompt_cache_miss_tokens || 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
ThinkingLevel,
|
||||
} from "@google/genai"
|
||||
import { GeminiModelId, geminiDefaultModelId, geminiModels, ModelInfo } from "@shared/api"
|
||||
import { GEMINI_FLASH_MAX_OUTPUT_TOKENS, isGeminiFlashModel } from "@utils/model-utils"
|
||||
import { buildExternalBasicHeaders } from "@/services/EnvUtils"
|
||||
import { telemetryService } from "@/services/telemetry"
|
||||
import { ClineStorageMessage } from "@/shared/messages/content"
|
||||
@@ -45,6 +46,18 @@ function mapReasoningEffortToGeminiThinkingLevel(effort: string): ThinkingLevel
|
||||
}
|
||||
}
|
||||
|
||||
function getGeminiMaxOutputTokens(modelId: string, modelMaxTokens?: number): number | undefined {
|
||||
if (!isGeminiFlashModel(modelId)) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
if (modelMaxTokens && modelMaxTokens > 0) {
|
||||
return Math.min(modelMaxTokens, GEMINI_FLASH_MAX_OUTPUT_TOKENS)
|
||||
}
|
||||
|
||||
return GEMINI_FLASH_MAX_OUTPUT_TOKENS
|
||||
}
|
||||
|
||||
/**
|
||||
* Handler for Google's Gemini API with optimized caching strategy and accurate cost accounting.
|
||||
*
|
||||
@@ -136,6 +149,9 @@ export class GeminiHandler implements ApiHandler {
|
||||
const client = this.ensureClient()
|
||||
const { id: modelId, info } = this.getModel()
|
||||
const contents = messages.map(convertAnthropicMessageToGemini)
|
||||
// Gemini may emit multiple function calls under the same responseId and without functionCall.id.
|
||||
// Track a local sequence so each emitted tool call has a stable unique ID.
|
||||
const responseToolCallCount = new Map<string, number>()
|
||||
|
||||
// Configure thinking budget/level if supported
|
||||
const _thinkingBudget = this.options.thinkingBudgetTokens ?? 0
|
||||
@@ -152,6 +168,7 @@ export class GeminiHandler implements ApiHandler {
|
||||
}
|
||||
|
||||
// Set up base generation config
|
||||
const maxOutputTokens = getGeminiMaxOutputTokens(modelId, info.maxTokens)
|
||||
const requestConfig: GenerateContentConfig = {
|
||||
// Add base URL if configured
|
||||
httpOptions: this.options.geminiBaseUrl ? { baseUrl: this.options.geminiBaseUrl } : undefined,
|
||||
@@ -159,6 +176,7 @@ export class GeminiHandler implements ApiHandler {
|
||||
// Set temperature (default to 0)
|
||||
// Gemini 3 recommends 1.0
|
||||
temperature: info.temperature ?? 1,
|
||||
...(maxOutputTokens !== undefined ? { maxOutputTokens } : {}),
|
||||
}
|
||||
|
||||
// Add thinking config only if the model supports it
|
||||
@@ -210,6 +228,7 @@ export class GeminiHandler implements ApiHandler {
|
||||
|
||||
let isFirstSdkChunk = true
|
||||
for await (const chunk of result) {
|
||||
const responseKey = chunk.responseId || "gemini-response"
|
||||
if (isFirstSdkChunk) {
|
||||
sdkFirstChunkTime = Date.now()
|
||||
ttftSdkMs = sdkFirstChunkTime - sdkCallStartTime
|
||||
@@ -238,12 +257,21 @@ export class GeminiHandler implements ApiHandler {
|
||||
const functionCall = part.functionCall
|
||||
const args = Object.entries(functionCall.args || {}).filter(([_key, val]) => !!val)
|
||||
if (functionCall.args && args.length > 0) {
|
||||
const existingId = functionCall.id?.trim()
|
||||
const toolCallId =
|
||||
existingId ??
|
||||
(() => {
|
||||
const sequenceNumber = responseToolCallCount.get(responseKey) ?? 0
|
||||
responseToolCallCount.set(responseKey, sequenceNumber + 1)
|
||||
return `${responseKey}-tool-${sequenceNumber}`
|
||||
})()
|
||||
yield {
|
||||
type: "tool_calls",
|
||||
id: chunk.responseId,
|
||||
tool_call: {
|
||||
call_id: toolCallId,
|
||||
function: {
|
||||
id: chunk.responseId,
|
||||
id: toolCallId,
|
||||
name: functionCall.name,
|
||||
arguments: JSON.stringify(functionCall.args),
|
||||
},
|
||||
|
||||
@@ -22,6 +22,7 @@ interface OpenRouterHandlerOptions extends CommonApiHandlerOptions {
|
||||
openRouterProviderSorting?: string
|
||||
reasoningEffort?: string
|
||||
thinkingBudgetTokens?: number
|
||||
enableParallelToolCalling?: boolean
|
||||
}
|
||||
|
||||
export class OpenRouterHandler implements ApiHandler {
|
||||
@@ -68,6 +69,7 @@ export class OpenRouterHandler implements ApiHandler {
|
||||
this.options.thinkingBudgetTokens,
|
||||
this.options.openRouterProviderSorting,
|
||||
tools,
|
||||
this.options.enableParallelToolCalling,
|
||||
)
|
||||
|
||||
let didOutputUsage = false
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
import { openAiModelInfoSaneDefaults, type ModelInfo, type WandbModelId, wandbDefaultModelId, wandbModels } from "@shared/api"
|
||||
import OpenAI from "openai"
|
||||
import type { ChatCompletionTool as OpenAITool } from "openai/resources/chat/completions"
|
||||
import { ClineStorageMessage } from "@/shared/messages/content"
|
||||
import { createOpenAIClient } from "@/shared/net"
|
||||
import { ApiHandler, CommonApiHandlerOptions } from "../index"
|
||||
import { withRetry } from "../retry"
|
||||
import { convertToOpenAiMessages } from "../transform/openai-format"
|
||||
import { ApiStream } from "../transform/stream"
|
||||
import { getOpenAIToolParams, ToolCallProcessor } from "../transform/tool-call-processor"
|
||||
|
||||
interface WandbHandlerOptions extends CommonApiHandlerOptions {
|
||||
wandbApiKey?: string
|
||||
apiModelId?: string
|
||||
}
|
||||
|
||||
export class WandbHandler implements ApiHandler {
|
||||
private client: OpenAI | undefined
|
||||
|
||||
constructor(private readonly options: WandbHandlerOptions) {}
|
||||
|
||||
private ensureClient(): OpenAI {
|
||||
if (!this.client) {
|
||||
if (!this.options.wandbApiKey) {
|
||||
throw new Error("W&B API key is required")
|
||||
}
|
||||
try {
|
||||
this.client = createOpenAIClient({
|
||||
baseURL: "https://api.inference.wandb.ai/v1",
|
||||
apiKey: this.options.wandbApiKey,
|
||||
})
|
||||
} catch (error) {
|
||||
throw new Error(`Error creating W&B Inference client: ${error instanceof Error ? error.message : String(error)}`)
|
||||
}
|
||||
}
|
||||
return this.client
|
||||
}
|
||||
|
||||
@withRetry()
|
||||
async *createMessage(systemPrompt: string, messages: ClineStorageMessage[], tools?: OpenAITool[]): ApiStream {
|
||||
const client = this.ensureClient()
|
||||
const model = this.getModel()
|
||||
|
||||
const stream = await client.chat.completions.create({
|
||||
model: model.id,
|
||||
messages: [{ role: "system", content: systemPrompt }, ...convertToOpenAiMessages(messages)],
|
||||
temperature: 0,
|
||||
stream: true,
|
||||
stream_options: { include_usage: true },
|
||||
...getOpenAIToolParams(tools),
|
||||
})
|
||||
|
||||
const toolCallProcessor = new ToolCallProcessor()
|
||||
for await (const chunk of stream) {
|
||||
const delta = chunk.choices?.[0]?.delta
|
||||
if (delta?.content) {
|
||||
yield {
|
||||
type: "text",
|
||||
text: delta.content,
|
||||
}
|
||||
}
|
||||
|
||||
if (delta && "reasoning" in delta && delta.reasoning) {
|
||||
yield {
|
||||
type: "reasoning",
|
||||
reasoning: typeof delta.reasoning === "string" ? delta.reasoning : JSON.stringify(delta.reasoning),
|
||||
}
|
||||
}
|
||||
|
||||
if (delta?.tool_calls) {
|
||||
yield* toolCallProcessor.processToolCallDeltas(delta.tool_calls)
|
||||
}
|
||||
|
||||
if (chunk.usage) {
|
||||
// W&B Inference returns prompt_tokens_details.cached_tokens in the usage chunk,
|
||||
// but does not currently offer cache-aware billing (cached tokens are billed
|
||||
// at the same rate as regular input tokens). We report inputTokens as the full
|
||||
// prompt_tokens value and do not subtract cached tokens until W&B supports
|
||||
// cache-aware pricing. This may change in a future update.
|
||||
yield {
|
||||
type: "usage",
|
||||
inputTokens: chunk.usage.prompt_tokens || 0,
|
||||
outputTokens: chunk.usage.completion_tokens || 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
getModel(): { id: string; info: ModelInfo } {
|
||||
const modelId = this.options.apiModelId?.trim()
|
||||
|
||||
if (modelId && modelId in wandbModels) {
|
||||
return { id: modelId, info: wandbModels[modelId as WandbModelId] }
|
||||
}
|
||||
|
||||
if (modelId) {
|
||||
return { id: modelId, info: openAiModelInfoSaneDefaults }
|
||||
}
|
||||
|
||||
return { id: wandbDefaultModelId, info: wandbModels[wandbDefaultModelId] }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { describe, it } from "mocha"
|
||||
import "should"
|
||||
import type { ModelInfo } from "@shared/api"
|
||||
import sinon from "sinon"
|
||||
import { createOpenRouterStream } from "../openrouter-stream"
|
||||
|
||||
describe("createOpenRouterStream", () => {
|
||||
const createAsyncIterable = () => ({
|
||||
async *[Symbol.asyncIterator]() {},
|
||||
})
|
||||
|
||||
const createClient = () => {
|
||||
const create = sinon.stub().resolves(createAsyncIterable())
|
||||
return {
|
||||
client: {
|
||||
chat: {
|
||||
completions: {
|
||||
create,
|
||||
},
|
||||
},
|
||||
},
|
||||
create,
|
||||
}
|
||||
}
|
||||
|
||||
const createModelInfo = (maxTokens: number): ModelInfo => ({
|
||||
maxTokens,
|
||||
contextWindow: 1_048_576,
|
||||
supportsImages: true,
|
||||
supportsPromptCache: false,
|
||||
})
|
||||
|
||||
it("caps Gemini Flash OpenRouter requests to 8192 max_tokens", async () => {
|
||||
const { client, create } = createClient()
|
||||
|
||||
await createOpenRouterStream(client as any, "system prompt", [{ role: "user", content: "hello" }] as any, {
|
||||
id: "google/gemini-2.5-flash",
|
||||
info: createModelInfo(65_536),
|
||||
})
|
||||
|
||||
const payload = create.firstCall.args[0] as Record<string, unknown>
|
||||
payload.should.have.property("max_tokens", 8_192)
|
||||
})
|
||||
|
||||
it("keeps lower Gemini Flash max_tokens values when already below 8192", async () => {
|
||||
const { client, create } = createClient()
|
||||
|
||||
await createOpenRouterStream(client as any, "system prompt", [{ role: "user", content: "hello" }] as any, {
|
||||
id: "google/gemini-2.5-flash",
|
||||
info: createModelInfo(4_096),
|
||||
})
|
||||
|
||||
const payload = create.firstCall.args[0] as Record<string, unknown>
|
||||
payload.should.have.property("max_tokens", 4_096)
|
||||
})
|
||||
|
||||
it("does not send max_tokens for non-Gemini models", async () => {
|
||||
const { client, create } = createClient()
|
||||
|
||||
await createOpenRouterStream(client as any, "system prompt", [{ role: "user", content: "hello" }] as any, {
|
||||
id: "anthropic/claude-sonnet-4.5",
|
||||
info: createModelInfo(64_000),
|
||||
})
|
||||
|
||||
const payload = create.firstCall.args[0] as Record<string, unknown>
|
||||
payload.should.not.have.property("max_tokens")
|
||||
})
|
||||
|
||||
it("does not send max_tokens for non-Flash Gemini models", async () => {
|
||||
const { client, create } = createClient()
|
||||
|
||||
await createOpenRouterStream(client as any, "system prompt", [{ role: "user", content: "hello" }] as any, {
|
||||
id: "google/gemini-2.5-pro",
|
||||
info: createModelInfo(65_536),
|
||||
})
|
||||
|
||||
const payload = create.firstCall.args[0] as Record<string, unknown>
|
||||
payload.should.not.have.property("max_tokens")
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,116 @@
|
||||
import "should"
|
||||
import { getOpenAIToolParams, ToolCallProcessor } from "../tool-call-processor"
|
||||
|
||||
describe("ToolCallProcessor", () => {
|
||||
it("should preserve tool call id/name for interleaved parallel deltas", () => {
|
||||
const processor = new ToolCallProcessor()
|
||||
|
||||
const firstChunk = [
|
||||
{
|
||||
index: 0,
|
||||
id: "call_a",
|
||||
function: { name: "read_file" },
|
||||
},
|
||||
{
|
||||
index: 1,
|
||||
id: "call_b",
|
||||
function: { name: "search_files" },
|
||||
},
|
||||
] as any
|
||||
|
||||
const secondChunk = [
|
||||
{
|
||||
index: 1,
|
||||
function: { arguments: '{"path":"src"}' },
|
||||
},
|
||||
{
|
||||
index: 0,
|
||||
function: { arguments: '{"path":"README.md"}' },
|
||||
},
|
||||
] as any
|
||||
|
||||
const firstResult = [...processor.processToolCallDeltas(firstChunk)]
|
||||
const secondResult = [...processor.processToolCallDeltas(secondChunk)]
|
||||
|
||||
firstResult.should.have.length(0)
|
||||
secondResult.should.have.length(2)
|
||||
// Intentionally reversed from the setup chunk: output follows incoming
|
||||
// argument-delta order, but reconstruction is correct regardless of arrival
|
||||
// order because id/name/arguments are matched by tool call index.
|
||||
const firstToolCall = secondResult[0]!.tool_call as any
|
||||
const secondToolCall = secondResult[1]!.tool_call as any
|
||||
firstToolCall.function.id.should.equal("call_b")
|
||||
firstToolCall.function.name.should.equal("search_files")
|
||||
firstToolCall.function.arguments.should.equal('{"path":"src"}')
|
||||
secondToolCall.function.id.should.equal("call_a")
|
||||
secondToolCall.function.name.should.equal("read_file")
|
||||
secondToolCall.function.arguments.should.equal('{"path":"README.md"}')
|
||||
})
|
||||
|
||||
it("should clear accumulated state on reset", () => {
|
||||
const processor = new ToolCallProcessor()
|
||||
|
||||
const setupChunk = [
|
||||
{
|
||||
index: 0,
|
||||
id: "call_reset",
|
||||
function: { name: "read_file" },
|
||||
},
|
||||
] as any
|
||||
|
||||
const argsChunk = [
|
||||
{
|
||||
index: 0,
|
||||
function: { arguments: '{"path":"after-reset"}' },
|
||||
},
|
||||
] as any
|
||||
|
||||
;[...processor.processToolCallDeltas(setupChunk)].should.have.length(0)
|
||||
processor.reset()
|
||||
;[...processor.processToolCallDeltas(argsChunk)].should.have.length(0)
|
||||
|
||||
const newSetupChunk = [
|
||||
{
|
||||
index: 0,
|
||||
id: "call_new",
|
||||
function: { name: "write_file" },
|
||||
},
|
||||
] as any
|
||||
|
||||
const newArgsChunk = [
|
||||
{
|
||||
index: 0,
|
||||
function: { arguments: '{"path":"file.txt"}' },
|
||||
},
|
||||
] as any
|
||||
|
||||
;[...processor.processToolCallDeltas(newSetupChunk)].should.have.length(0)
|
||||
;[...processor.processToolCallDeltas(newArgsChunk)].should.have.length(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe("getOpenAIToolParams", () => {
|
||||
it("should include parallel_tool_calls when enabled", () => {
|
||||
const tools = [
|
||||
{ type: "function", function: { name: "read_file", description: "", parameters: { type: "object" } } },
|
||||
] as any
|
||||
const params = getOpenAIToolParams(tools, true) as any
|
||||
|
||||
params.parallel_tool_calls.should.equal(true)
|
||||
})
|
||||
|
||||
it("should include parallel_tool_calls=false when disabled by default", () => {
|
||||
const tools = [
|
||||
{ type: "function", function: { name: "read_file", description: "", parameters: { type: "object" } } },
|
||||
] as any
|
||||
const params = getOpenAIToolParams(tools, false) as any
|
||||
|
||||
params.parallel_tool_calls.should.equal(false)
|
||||
})
|
||||
|
||||
it("should not include parallel_tool_calls when tools are absent", () => {
|
||||
const params = getOpenAIToolParams(undefined, false) as any
|
||||
|
||||
params.should.not.have.property("parallel_tool_calls")
|
||||
})
|
||||
})
|
||||
@@ -9,7 +9,12 @@ import {
|
||||
openRouterClaudeSonnet461mModelId,
|
||||
} from "@shared/api"
|
||||
import { normalizeOpenaiReasoningEffort } from "@shared/storage/types"
|
||||
import { shouldSkipReasoningForModel, supportsReasoningEffortForModel } from "@utils/model-utils"
|
||||
import {
|
||||
GEMINI_FLASH_MAX_OUTPUT_TOKENS,
|
||||
isGeminiFlashModel,
|
||||
shouldSkipReasoningForModel,
|
||||
supportsReasoningEffortForModel,
|
||||
} from "@utils/model-utils"
|
||||
import OpenAI from "openai"
|
||||
import { ChatCompletionTool } from "openai/resources/chat/completions"
|
||||
import { convertToOpenAiMessages, sanitizeGeminiMessages } from "./openai-format"
|
||||
@@ -25,6 +30,7 @@ export async function createOpenRouterStream(
|
||||
thinkingBudgetTokens?: number,
|
||||
openRouterProviderSorting?: string,
|
||||
tools?: Array<ChatCompletionTool>,
|
||||
enableParallelToolCalling?: boolean,
|
||||
) {
|
||||
// Convert Anthropic messages to OpenAI format
|
||||
let openAiMessages: OpenAI.Chat.ChatCompletionMessageParam[] = [
|
||||
@@ -180,9 +186,13 @@ export async function createOpenRouterStream(
|
||||
const includeReasoning = !shouldSkipReasoningForModel(model.id) && reasoningEffortValue !== "none"
|
||||
const reasoningPayload =
|
||||
reasoning ?? (reasoningEffortValue && reasoningEffortValue !== "none" ? { effort: reasoningEffortValue } : undefined)
|
||||
const maxTokens = isGeminiFlashModel(model.id)
|
||||
? Math.min(model.info.maxTokens || GEMINI_FLASH_MAX_OUTPUT_TOKENS, GEMINI_FLASH_MAX_OUTPUT_TOKENS)
|
||||
: undefined
|
||||
|
||||
const requestPayload: Record<string, unknown> = {
|
||||
model: model.id,
|
||||
...(maxTokens ? { max_tokens: maxTokens } : {}),
|
||||
temperature: temperature,
|
||||
top_p: topP,
|
||||
messages: openAiMessages,
|
||||
@@ -193,7 +203,7 @@ export async function createOpenRouterStream(
|
||||
...(openRouterProviderSorting && !providerPreferences ? { provider: { sort: openRouterProviderSorting } } : {}),
|
||||
...(providerPreferences ? { provider: providerPreferences } : {}),
|
||||
...(isClaude1m ? { provider: { order: ["anthropic", "google-vertex/global"], allow_fallbacks: false } } : {}),
|
||||
...getOpenAIToolParams(tools),
|
||||
...getOpenAIToolParams(tools, !!enableParallelToolCalling),
|
||||
}
|
||||
|
||||
// @ts-expect-error-next-line
|
||||
|
||||
@@ -12,10 +12,10 @@ import type { ApiStreamToolCallsChunk } from "./stream"
|
||||
* and yields properly formatted tool call chunks when arguments are received.
|
||||
*/
|
||||
export class ToolCallProcessor {
|
||||
private lastToolCall: { id: string; name: string }
|
||||
private toolCallStateByIndex: Map<number, { id: string; name: string }>
|
||||
|
||||
constructor() {
|
||||
this.lastToolCall = { id: "", name: "" }
|
||||
this.toolCallStateByIndex = new Map()
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -30,28 +30,32 @@ export class ToolCallProcessor {
|
||||
return
|
||||
}
|
||||
|
||||
for (const toolCallDelta of toolCallDeltas) {
|
||||
for (const [fallbackIndex, toolCallDelta] of toolCallDeltas.entries()) {
|
||||
// OpenAI-style streams include an index per tool call. Use iteration order as a fallback.
|
||||
const toolCallIndex = toolCallDelta.index ?? fallbackIndex
|
||||
const toolCallState = this.getOrCreateToolCallState(toolCallIndex)
|
||||
|
||||
// Accumulate the tool call ID if present
|
||||
if (toolCallDelta.id) {
|
||||
this.lastToolCall.id = toolCallDelta.id
|
||||
toolCallState.id = toolCallDelta.id
|
||||
}
|
||||
|
||||
// Accumulate the function name if present
|
||||
if (toolCallDelta.function?.name) {
|
||||
Logger.debug(`[ToolCallProcessor] Native Tool Called: ${toolCallDelta.function.name}`)
|
||||
this.lastToolCall.name = toolCallDelta.function.name
|
||||
toolCallState.name = toolCallDelta.function.name
|
||||
}
|
||||
|
||||
// Only yield when we have all required fields: id, name, and arguments
|
||||
if (this.lastToolCall.id && this.lastToolCall.name && toolCallDelta.function?.arguments) {
|
||||
if (toolCallState.id && toolCallState.name && toolCallDelta.function?.arguments) {
|
||||
yield {
|
||||
type: "tool_calls",
|
||||
tool_call: {
|
||||
...toolCallDelta,
|
||||
function: {
|
||||
...toolCallDelta.function,
|
||||
id: this.lastToolCall.id,
|
||||
name: this.lastToolCall.name,
|
||||
id: toolCallState.id,
|
||||
name: toolCallState.name,
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -59,29 +63,42 @@ export class ToolCallProcessor {
|
||||
}
|
||||
}
|
||||
|
||||
private getOrCreateToolCallState(index: number): { id: string; name: string } {
|
||||
const existingState = this.toolCallStateByIndex.get(index)
|
||||
if (existingState) {
|
||||
return existingState
|
||||
}
|
||||
|
||||
const initialState = { id: "", name: "" }
|
||||
this.toolCallStateByIndex.set(index, initialState)
|
||||
return initialState
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset the internal state. Call this when starting a new message.
|
||||
*/
|
||||
reset(): void {
|
||||
this.lastToolCall = { id: "", name: "" }
|
||||
this.toolCallStateByIndex.clear()
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current accumulated tool call state (useful for debugging).
|
||||
*/
|
||||
getState(): { id: string; name: string } {
|
||||
return { ...this.lastToolCall }
|
||||
getState(): Record<number, { id: string; name: string }> {
|
||||
return Object.fromEntries(this.toolCallStateByIndex.entries())
|
||||
}
|
||||
}
|
||||
|
||||
export function getOpenAIToolParams(tools?: OpenAITool[], enableParallelToolCalls: boolean = false) {
|
||||
return tools?.length
|
||||
? {
|
||||
tools,
|
||||
tool_choice: tools ? ("auto" as ChatCompletionToolChoiceOption) : undefined,
|
||||
parallel_tool_calls: enableParallelToolCalls ? true : false,
|
||||
}
|
||||
: {
|
||||
tools: undefined,
|
||||
}
|
||||
export function getOpenAIToolParams(tools?: OpenAITool[], enableParallelToolCalls = false) {
|
||||
if (!tools?.length) {
|
||||
return {
|
||||
tools: undefined,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
tools,
|
||||
tool_choice: "auto" as ChatCompletionToolChoiceOption,
|
||||
parallel_tool_calls: enableParallelToolCalls,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { ensureCacheDirectoryExists, GlobalFileNames } from "@core/storage/disk"
|
||||
import type { ModelInfo } from "@shared/api"
|
||||
import { fileExistsAtPath } from "@utils/fs"
|
||||
import { GEMINI_FLASH_MAX_OUTPUT_TOKENS, isGeminiFlashModel } from "@utils/model-utils"
|
||||
import axios from "axios"
|
||||
import cloneDeep from "clone-deep"
|
||||
import fs from "fs/promises"
|
||||
@@ -240,6 +241,13 @@ async function fetchAndCacheClineModels(): Promise<Record<string, ModelInfo>> {
|
||||
break
|
||||
}
|
||||
|
||||
if (isGeminiFlashModel(rawModel.id)) {
|
||||
modelInfo.maxTokens = Math.min(
|
||||
modelInfo.maxTokens || GEMINI_FLASH_MAX_OUTPUT_TOKENS,
|
||||
GEMINI_FLASH_MAX_OUTPUT_TOKENS,
|
||||
)
|
||||
}
|
||||
|
||||
models[rawModel.id] = modelInfo
|
||||
|
||||
// Add custom :1m model variant for Sonnet models
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { ensureCacheDirectoryExists, GlobalFileNames } from "@core/storage/disk"
|
||||
import type { ModelInfo } from "@shared/api"
|
||||
import { GEMINI_FLASH_MAX_OUTPUT_TOKENS, isGeminiFlashModel } from "@utils/model-utils"
|
||||
import axios from "axios"
|
||||
import cloneDeep from "clone-deep"
|
||||
import fs from "fs/promises"
|
||||
@@ -264,6 +265,13 @@ async function fetchAndCacheModels(controller: Controller): Promise<Record<strin
|
||||
break
|
||||
}
|
||||
|
||||
if (isGeminiFlashModel(rawModel.id)) {
|
||||
modelInfo.maxTokens = Math.min(
|
||||
modelInfo.maxTokens || GEMINI_FLASH_MAX_OUTPUT_TOKENS,
|
||||
GEMINI_FLASH_MAX_OUTPUT_TOKENS,
|
||||
)
|
||||
}
|
||||
|
||||
models[rawModel.id] = modelInfo
|
||||
|
||||
// add custom :1m model variant for sonnet
|
||||
|
||||
+3
-3
@@ -66,7 +66,7 @@
|
||||
"properties": {
|
||||
"path": {
|
||||
"type": "string",
|
||||
"description": "The path of the file to read (relative to the current working directory {{CWD}}){{MULTI_ROOT_HINT}}"
|
||||
"description": "The path of the file to read (relative to the current working directory /test/project)"
|
||||
},
|
||||
"task_progress": {
|
||||
"type": "string",
|
||||
@@ -151,7 +151,7 @@
|
||||
"properties": {
|
||||
"path": {
|
||||
"type": "string",
|
||||
"description": "The path of the directory to search in (relative to the current working directory {{CWD}}){{MULTI_ROOT_HINT}}. This directory will be recursively searched."
|
||||
"description": "The path of the directory to search in (relative to the current working directory /test/project). This directory will be recursively searched."
|
||||
},
|
||||
"regex": {
|
||||
"type": "string",
|
||||
@@ -214,7 +214,7 @@
|
||||
"properties": {
|
||||
"path": {
|
||||
"type": "string",
|
||||
"description": "The path of a directory (not a file) relative to the current working directory {{CWD}}{{MULTI_ROOT_HINT}}. Lists definitions across all source files in that directory. To inspect a single file, use read_file instead."
|
||||
"description": "The path of a directory (not a file) relative to the current working directory /test/project. Lists definitions across all source files in that directory. To inspect a single file, use read_file instead."
|
||||
},
|
||||
"task_progress": {
|
||||
"type": "string",
|
||||
|
||||
+5
-5
@@ -3,7 +3,7 @@
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "execute_command",
|
||||
"description": "Request to execute a CLI command on the system. Use this when you need to perform system operations or run specific commands to accomplish any step in the user's task. You must tailor your command to the user's system and provide a clear explanation of what the command does. For command chaining, use the appropriate chaining syntax for the user's shell. Prefer to execute complex CLI commands over creating executable scripts, as they are more flexible and easier to run. Commands will be executed in the current working directory: {{CWD}}{{MULTI_ROOT_HINT}}",
|
||||
"description": "Request to execute a CLI command on the system. Use this when you need to perform system operations or run specific commands to accomplish any step in the user's task. You must tailor your command to the user's system and provide a clear explanation of what the command does. For command chaining, use the appropriate chaining syntax for the user's shell. Prefer to execute complex CLI commands over creating executable scripts, as they are more flexible and easier to run. Commands will be executed in the current working directory: /test/project",
|
||||
"strict": false,
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
@@ -36,7 +36,7 @@
|
||||
"properties": {
|
||||
"path": {
|
||||
"type": "string",
|
||||
"description": "The path of the file to read (relative to the current working directory {{CWD}}){{MULTI_ROOT_HINT}}"
|
||||
"description": "The path of the file to read (relative to the current working directory /test/project)"
|
||||
},
|
||||
"task_progress": {
|
||||
"type": "string",
|
||||
@@ -86,7 +86,7 @@
|
||||
"properties": {
|
||||
"path": {
|
||||
"type": "string",
|
||||
"description": "The path of the directory to search in (relative to the current working directory {{CWD}}){{MULTI_ROOT_HINT}}. This directory will be recursively searched."
|
||||
"description": "The path of the directory to search in (relative to the current working directory /test/project). This directory will be recursively searched."
|
||||
},
|
||||
"regex": {
|
||||
"type": "string",
|
||||
@@ -120,7 +120,7 @@
|
||||
"properties": {
|
||||
"path": {
|
||||
"type": "string",
|
||||
"description": "The path of the directory to list contents for (relative to the current working directory {{CWD}}){{MULTI_ROOT_HINT}}"
|
||||
"description": "The path of the directory to list contents for (relative to the current working directory /test/project)"
|
||||
},
|
||||
"recursive": {
|
||||
"type": "boolean",
|
||||
@@ -149,7 +149,7 @@
|
||||
"properties": {
|
||||
"path": {
|
||||
"type": "string",
|
||||
"description": "The path of a directory (not a file) relative to the current working directory {{CWD}}{{MULTI_ROOT_HINT}}. Lists definitions across all source files in that directory. To inspect a single file, use read_file instead."
|
||||
"description": "The path of a directory (not a file) relative to the current working directory /test/project. Lists definitions across all source files in that directory. To inspect a single file, use read_file instead."
|
||||
},
|
||||
"task_progress": {
|
||||
"type": "string",
|
||||
|
||||
+3
-3
@@ -36,7 +36,7 @@
|
||||
"properties": {
|
||||
"path": {
|
||||
"type": "string",
|
||||
"description": "The path of the file to read (relative to the current working directory {{CWD}}){{MULTI_ROOT_HINT}}"
|
||||
"description": "The path of the file to read (relative to the current working directory /test/project)"
|
||||
},
|
||||
"task_progress": {
|
||||
"type": "string",
|
||||
@@ -86,7 +86,7 @@
|
||||
"properties": {
|
||||
"path": {
|
||||
"type": "string",
|
||||
"description": "The path of the directory to search in (relative to the current working directory {{CWD}}){{MULTI_ROOT_HINT}}. This directory will be recursively searched."
|
||||
"description": "The path of the directory to search in (relative to the current working directory /test/project). This directory will be recursively searched."
|
||||
},
|
||||
"regex": {
|
||||
"type": "string",
|
||||
@@ -149,7 +149,7 @@
|
||||
"properties": {
|
||||
"path": {
|
||||
"type": "string",
|
||||
"description": "The path of a directory (not a file) relative to the current working directory {{CWD}}{{MULTI_ROOT_HINT}}. Lists definitions across all source files in that directory. To inspect a single file, use read_file instead."
|
||||
"description": "The path of a directory (not a file) relative to the current working directory /test/project. Lists definitions across all source files in that directory. To inspect a single file, use read_file instead."
|
||||
},
|
||||
"task_progress": {
|
||||
"type": "string",
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
"properties": {
|
||||
"path": {
|
||||
"type": "STRING",
|
||||
"description": "The path of the file to read (relative to the current working directory {{CWD}}){{MULTI_ROOT_HINT}}"
|
||||
"description": "The path of the file to read (relative to the current working directory /test/project)"
|
||||
},
|
||||
"task_progress": {
|
||||
"type": "STRING",
|
||||
@@ -48,7 +48,7 @@
|
||||
"properties": {
|
||||
"path": {
|
||||
"type": "STRING",
|
||||
"description": "The path of the file to write to (relative to the current working directory {{CWD}}){{MULTI_ROOT_HINT}}"
|
||||
"description": "The path of the file to write to (relative to the current working directory /test/project)"
|
||||
},
|
||||
"content": {
|
||||
"type": "STRING",
|
||||
@@ -73,7 +73,7 @@
|
||||
"properties": {
|
||||
"path": {
|
||||
"type": "STRING",
|
||||
"description": "The path of the file to modify (relative to the current working directory {{CWD}})"
|
||||
"description": "The path of the file to modify (relative to the current working directory /test/project)"
|
||||
},
|
||||
"diff": {
|
||||
"type": "STRING",
|
||||
@@ -98,7 +98,7 @@
|
||||
"properties": {
|
||||
"path": {
|
||||
"type": "STRING",
|
||||
"description": "The path of the directory to search in (relative to the current working directory {{CWD}}){{MULTI_ROOT_HINT}}. This directory will be recursively searched."
|
||||
"description": "The path of the directory to search in (relative to the current working directory /test/project). This directory will be recursively searched."
|
||||
},
|
||||
"regex": {
|
||||
"type": "STRING",
|
||||
@@ -127,7 +127,7 @@
|
||||
"properties": {
|
||||
"path": {
|
||||
"type": "STRING",
|
||||
"description": "The path of the directory to list contents for (relative to the current working directory {{CWD}}){{MULTI_ROOT_HINT}}"
|
||||
"description": "The path of the directory to list contents for (relative to the current working directory /test/project)"
|
||||
},
|
||||
"recursive": {
|
||||
"type": "BOOLEAN",
|
||||
@@ -151,7 +151,7 @@
|
||||
"properties": {
|
||||
"path": {
|
||||
"type": "STRING",
|
||||
"description": "The path of the directory (relative to the current working directory {{CWD}}){{MULTI_ROOT_HINT}} to list top level source code definitions for."
|
||||
"description": "The path of a directory (not a file) relative to the current working directory /test/project. Lists definitions across all source files in that directory. To inspect a single file, use read_file instead."
|
||||
},
|
||||
"task_progress": {
|
||||
"type": "STRING",
|
||||
|
||||
+275
@@ -0,0 +1,275 @@
|
||||
You are Cline, a software engineering AI. Your mission is to execute precisely what is requested - implement exactly what was asked for, with the simplest solution that fulfills all requirements. Ask clarifying questions to ensure you understand the user's requirements and that they understand your approach before proceeding.
|
||||
|
||||
TOOL USE
|
||||
|
||||
You have access to a set of tools that are executed upon the user's approval. You may use multiple tools in a single response when the operations are independent (e.g., reading several files, searching in parallel). For dependent operations where one result informs the next, use tools sequentially. You will receive the results of all tool uses in the user's response.
|
||||
|
||||
When using tools, proceed directly with tool calls. Save explanations for the attempt_completion summary. Both attempt_completion and plan_mode_respond display to the user as assistant messages, so include your message content within the tool call itself rather than duplicating it outside.
|
||||
|
||||
====
|
||||
|
||||
ACT MODE V.S. PLAN MODE
|
||||
|
||||
In each user message, the environment_details will specify the current mode. There are two modes:
|
||||
|
||||
- ACT MODE: In this mode, you have access to all tools EXCEPT the plan_mode_respond tool.
|
||||
- In ACT MODE, you use tools to accomplish the user's task. Once you've completed the user's task, you use the attempt_completion tool to present the result of the task to the user.
|
||||
- PLAN MODE: In this special mode, you have access to the plan_mode_respond tool.
|
||||
- In PLAN MODE, the goal is to gather information and get context to create a detailed plan for accomplishing the task, which the user will review and approve before they switch you to ACT MODE to implement the solution.
|
||||
- In PLAN MODE, when you need to converse with the user or present a plan, you should use the plan_mode_respond tool to deliver your response directly.
|
||||
|
||||
## Plan Mode Workflow
|
||||
|
||||
Plan Mode is for deep analysis and strategic planning before implementation. Your behavior should be methodical and thorough - take time to understand the codebase completely before proposing any changes. You should explore the codebase until you have exhaustively collected sufficient context to fully understand the scope and nature of the changes that will need to be implemented to complete the user's request.
|
||||
|
||||
### Phase 1: Silent Investigation
|
||||
|
||||
Perform comprehensive research to build complete understanding of the codebase. Work silently - execute targeted search commands and read files without explaining what you're doing. Only ask questions when truly necessary for planning. You must strongly incorporate key words and principles from the user's input into your targeted search patterns and strategy.
|
||||
|
||||
**Research Activities:**
|
||||
- Use read_file, search_files, and list_code_definition_names extensively to understand architecture, patterns, and conventions
|
||||
- Execute targeted terminal commands to search and gather information about structure and dependencies.
|
||||
- Identify technical constraints, existing patterns, and potential risks
|
||||
- Ask targeted clarifying questions only when they will directly influence your implementation approach
|
||||
- Ensure complete coverage - before presenting a plan, you should identify all related functions, classes, calls, and methods that are involved or affected by the proposed changes.
|
||||
|
||||
### Phase 2: Plan Presentation
|
||||
|
||||
Once research is complete, use plan_mode_respond to present your detailed plan. Follow this required structure:
|
||||
|
||||
**Required Plan Format:**
|
||||
|
||||
1. **Overview** (1-3 paragraphs)
|
||||
Detailed but concise summary of the approach and why it's the right solution.
|
||||
|
||||
2. **Key Changes** (bulleted list)
|
||||
Main files/components to be modified or created, with one-line descriptions of changes.
|
||||
|
||||
3. **Implementation Steps** (numbered list)
|
||||
Break down the work into 4-40 concrete, actionable steps that will be executed in Act Mode. Be specific about what each step accomplishes. Each step should be specific to a function, class, or file, depending on the total scope of the task you are planning.
|
||||
|
||||
4. **Technical Considerations** (bulleted list)
|
||||
Important architectural decisions, trade-offs, edge cases, or risks to be aware of during implementation.
|
||||
|
||||
5. **Success Criteria** (bulleted list)
|
||||
Define what "done" looks like - how to verify the implementation works correctly.
|
||||
|
||||
**Formatting Guidelines:**
|
||||
- Use clear markdown with headers, lists, and inline `code` formatting for technical terms
|
||||
- Keep descriptions detailed, but at a reasonable length for a technical conversation.
|
||||
- Include simple ASCII diagrams or mermaid diagrams only if they genuinely clarify complex relationships
|
||||
- Balance detail with brevity for scannable content
|
||||
|
||||
### Phase 3: Collaborative Refinement
|
||||
|
||||
Engage with the user to discuss the plan, answer questions, and incorporate feedback. This is a brainstorming session - be open to alternative approaches and refinements. Update the plan based on user input until consensus is reached.
|
||||
|
||||
### Phase 4: Transition to Implementation
|
||||
|
||||
Once the plan is finalized and approved, you MUST direct the user to switch to ACT MODE. In Act Mode, you'll execute the plan step-by-step as outlined. If you not specifically ask the user to switch to ACT MODE, you will not be able to implement the planned changes.
|
||||
|
||||
## Act Mode Workflow
|
||||
|
||||
During Act Mode, focus on efficient execution:
|
||||
|
||||
1. Execute the established plan step-by-step
|
||||
2. Provide periodic progress updates indicating which step you're working on
|
||||
3. Use tools directly - save explanations for the attempt_completion summary
|
||||
4. Test each feature after implementation to verify it works correctly
|
||||
5. Verify with the user that the feature works as expected before using attempt_completion
|
||||
6. Use attempt_completion when confirmed complete, including your summary within the tool call itself
|
||||
|
||||
====
|
||||
|
||||
CAPABILITIES
|
||||
|
||||
- You have access to tools that let you execute CLI commands on the user's computer, list files, view source code definitions, regex search, use the browser, read and edit files, and ask follow-up questions. These tools help you effectively accomplish a wide range of tasks, such as writing code, making edits or improvements to existing files, understanding the current state of a project, performing system operations, and much more.
|
||||
- When the user initially gives you a task, a recursive list of all filepaths in the current working directory ('/test/project') will be included in environment_details. This provides an overview of the project's file structure, offering key insights into the project from directory/file names (how developers conceptualize and organize their code) and file extensions (the language used). This can also guide decision-making on which files to explore further. If you need to further explore directories such as outside the current working directory, you can use the list_files tool. If you pass 'true' for the recursive parameter, it will list files recursively. Otherwise, it will list files at the top level, which is better suited for generic directories where you don't necessarily need the nested structure, like the Desktop.
|
||||
- You can use search_files to perform regex searches across files in a specified directory, outputting context-rich results that include surrounding lines. This is particularly useful for understanding code patterns, finding specific implementations, or identifying areas that need refactoring.
|
||||
- You can use the list_code_definition_names tool to get an overview of source code definitions for all files at the top level of a specified directory. This can be particularly useful when you need to understand the broader context and relationships between certain parts of the code. You may need to call this tool multiple times to understand various parts of the codebase related to the task.
|
||||
- For example, when asked to make edits or improvements you might analyze the file structure in the initial environment_details to get an overview of the project, then use list_code_definition_names to get further insight using source code definitions for files located in relevant directories, then read_file to examine the contents of relevant files, analyze the code and suggest improvements or make necessary edits, then use the replace_in_file tool to implement changes. If you refactored code that could affect other parts of the codebase, you could use search_files to ensure you update other files as needed.
|
||||
- You can use the execute_command tool to run commands on the user's computer whenever you feel it can help accomplish the user's task. When you need to execute a CLI command, you must provide a clear explanation of what the command does. Prefer to execute complex CLI commands over creating executable scripts, since they are more flexible and easier to run. Prefer non-interactive commands when possible: use flags to disable pagers (e.g., '--no-pager'), auto-confirm prompts (e.g., '-y' when safe), provide input via flags/arguments rather than stdin, suppress interactive behavior, etc. For commands that may fail, consider redirecting stderr to stdout (e.g., `command 2>&1`) so you can see error messages in the output. For long-running commands, the user may keep them running in the background and you will be kept updated on their status along the way. Each command you execute is run in a new terminal instance.
|
||||
- You can use the browser_action tool to interact with websites (including html files and locally running development servers) through a Puppeteer-controlled browser when you feel it is necessary in accomplishing the user's task. This tool is particularly useful for web development tasks as it allows you to launch a browser, navigate to pages, interact with elements through clicks and keyboard input, and capture the results through screenshots and console logs. This tool may be useful at key stages of web development tasks-such as after implementing new features, making substantial changes, when troubleshooting issues, or to verify the result of your work. You can analyze the provided screenshots to ensure correct rendering or identify errors, and review console logs for runtime issues.
|
||||
- For example, if asked to add a component to a react website, you might create the necessary files, use execute_command to run the site locally, then use browser_action to launch the browser, navigate to the local server, and verify the component renders & functions correctly before closing the browser.
|
||||
- You have access to MCP servers that may provide additional tools and resources. Each server may provide different capabilities that you can use to accomplish tasks more effectively.
|
||||
|
||||
====
|
||||
|
||||
EDITING FILES
|
||||
|
||||
You have access to two tools for working with files: **write_to_file** and **replace_in_file**. Understanding their roles and selecting the right one for the job will help ensure efficient and accurate modifications.
|
||||
|
||||
# write_to_file
|
||||
|
||||
## Purpose
|
||||
|
||||
- Create a new file, or overwrite the entire contents of an existing file.
|
||||
|
||||
## When to Use
|
||||
|
||||
- Initial file creation, such as when scaffolding a new project.
|
||||
- Overwriting large boilerplate files where you want to replace the entire content at once.
|
||||
- When the complexity or number of changes would make replace_in_file unwieldy or error-prone.
|
||||
- When you need to completely restructure a file's content or change its fundamental organization.
|
||||
|
||||
## Important Considerations
|
||||
|
||||
- Using write_to_file requires providing the file's complete final content.
|
||||
- If you only need to make small changes to an existing file, consider using replace_in_file instead to avoid unnecessarily rewriting the entire file.
|
||||
- While write_to_file should not be your default choice, don't hesitate to use it when the situation truly calls for it.
|
||||
|
||||
# replace_in_file
|
||||
|
||||
## Purpose
|
||||
|
||||
- Make targeted edits to specific parts of an existing file without overwriting the entire file.
|
||||
|
||||
## When to Use
|
||||
|
||||
- Small, localized changes like updating a few lines, function implementations, changing variable names, modifying a section of text, etc.
|
||||
- Targeted improvements where only specific portions of the file's content needs to be altered.
|
||||
- Especially useful for long files where much of the file will remain unchanged.
|
||||
|
||||
## Advantages
|
||||
|
||||
- More efficient for minor edits, since you don't need to supply the entire file content.
|
||||
- Reduces the chance of errors that can occur when overwriting large files.
|
||||
|
||||
## Critical Rules for replace_in_file
|
||||
|
||||
1. **SEARCH content must match EXACTLY**: The content in SEARCH blocks must match the file character-for-character, including all whitespace, indentation, and line endings.
|
||||
2. **Include complete lines only**: Each line in a SEARCH block must be complete from start to end. Never truncate lines mid-way through as this will cause matching failures.
|
||||
3. **Match first occurrence only**: Each SEARCH/REPLACE block will only replace the first matching occurrence found in the file.
|
||||
4. **Use multiple blocks for multiple changes**: If you need to make several changes, include multiple unique SEARCH/REPLACE blocks in the order they appear in the file.
|
||||
5. **Keep blocks concise**: Include just enough lines to uniquely identify the section to change. Break large edits into smaller, focused blocks.
|
||||
6. **Proper formatting**: Each block must follow this exact format:
|
||||
```
|
||||
------- SEARCH
|
||||
[exact content to find]
|
||||
=======
|
||||
[new content to replace with]
|
||||
+++++++ REPLACE
|
||||
```
|
||||
7. **To delete code**: Use an empty REPLACE section.
|
||||
8. **To move code**: Use two blocks (one to delete from original location, one to insert at new location).
|
||||
|
||||
# Choosing the Appropriate Tool
|
||||
|
||||
- **Default to replace_in_file** for most changes. It's the safer, more precise option that minimizes potential issues.
|
||||
- **Use write_to_file** when:
|
||||
- Creating new files
|
||||
- The changes are so extensive that using replace_in_file would be more complex or risky
|
||||
- You need to completely reorganize or restructure a file
|
||||
- The file is relatively small and the changes affect most of its content
|
||||
- You're generating boilerplate or template files
|
||||
|
||||
# Auto-formatting Considerations
|
||||
|
||||
- After using either write_to_file or replace_in_file, the user's editor may automatically format the file
|
||||
- This auto-formatting may modify the file contents, for example:
|
||||
- Breaking single lines into multiple lines
|
||||
- Adjusting indentation to match project style (e.g. 2 spaces vs 4 spaces vs tabs)
|
||||
- Converting single quotes to double quotes (or vice versa based on project preferences)
|
||||
- Organizing imports (e.g. sorting, grouping by type)
|
||||
- Adding/removing trailing commas in objects and arrays
|
||||
- Enforcing consistent brace style (e.g. same-line vs new-line)
|
||||
- Standardizing semicolon usage (adding or removing based on style)
|
||||
- The write_to_file and replace_in_file tool responses will include the final state of the file after any auto-formatting
|
||||
- Use this final state as your reference point for any subsequent edits. This is ESPECIALLY important when crafting SEARCH blocks for replace_in_file which require the content to match what's in the file exactly.
|
||||
|
||||
# Workflow Tips
|
||||
|
||||
1. Before editing, assess the scope of your changes and decide which tool to use.
|
||||
2. For targeted edits, apply replace_in_file with carefully crafted SEARCH/REPLACE blocks. If you need multiple changes, stack multiple SEARCH/REPLACE blocks within a single replace_in_file call.
|
||||
3. IMPORTANT: When you determine that you need to make several changes to the same file, prefer to use a single replace_in_file call with multiple SEARCH/REPLACE blocks. DO NOT make multiple successive replace_in_file calls for the same file. For example, if adding a component to a file, use one call with separate blocks for the import statement and component usage.
|
||||
4. For major overhauls or initial file creation, rely on write_to_file.
|
||||
5. Once the file has been edited, the system will provide you with the final state of the modified file. Use this updated content as the reference point for any subsequent SEARCH/REPLACE operations, since it reflects any auto-formatting or user-applied changes.
|
||||
|
||||
By thoughtfully selecting between write_to_file and replace_in_file, you can make your file editing process smoother, safer, and more efficient.
|
||||
|
||||
====
|
||||
|
||||
UPDATING TASK PROGRESS
|
||||
|
||||
You can track and communicate your progress on the overall task using the task_progress parameter supported by every tool call. Using task_progress ensures you remain on task, and stay focused on completing the user's objective. This parameter can be used in any mode, and with any tool call.
|
||||
|
||||
- When switching from PLAN MODE to ACT MODE, you must create a comprehensive todo list for the task using the task_progress parameter
|
||||
- Todo list updates should be done silently using the task_progress parameter - do not announce these updates to the user
|
||||
- Use standard Markdown checklist format: "- [ ]" for incomplete items and "- [x]" for completed items
|
||||
- Keep items focused on meaningful progress milestones rather than minor technical details. The checklist should not be so granular that minor implementation details clutter the progress tracking.
|
||||
- For simple tasks, short checklists with even a single item are acceptable. For complex tasks, avoid making the checklist too long or verbose.
|
||||
- If you are creating this checklist for the first time, and the tool use completes the first step in the checklist, make sure to mark it as completed in your task_progress parameter.
|
||||
- Provide the whole checklist of steps you intend to complete in the task, and keep the checkboxes updated as you make progress. It's okay to rewrite this checklist as needed if it becomes invalid due to scope changes or new information.
|
||||
- If a checklist is being used, be sure to update it any time a step has been completed.
|
||||
- The system will automatically include todo list context in your prompts when appropriate - these reminders are important.
|
||||
|
||||
====
|
||||
|
||||
FEEDBACK
|
||||
|
||||
When user is providing you with feedback on how you could improve, you can let the user know to report new issue using the '/reportbug' slash command.
|
||||
|
||||
====
|
||||
|
||||
RULES
|
||||
|
||||
- The current working directory is `/test/project` - this is the directory where all the tools will be executed from.
|
||||
- When executing terminal commands, new terminals always open in the workspace directory. Use relative paths or chain commands with proper shell operators (e.g., `cd path && command` to change directory and run a command together).
|
||||
- When executing commands, do not assume success when expected output is missing or incomplete. Treat the result as unverified and run follow-up checks (for example checking exit status, verifying files with `test` and `ls`, or validating content with `grep` and `wc`) before proceeding. If output is still unavailable after reasonable checks and you need it to continue, use the ask_followup_question tool to request the user to copy and paste it back to you.
|
||||
- When multiple operations are independent (for example reading several files or searching in multiple directories), call multiple tools in a single response rather than one at a time. Use sequential tool calls only when later steps depend on earlier results.
|
||||
- When passing untrusted or variable text as positional command arguments, insert `--` before the positional values if they may begin with `-` (for example `my-cli -- "$value"`). This prevents the values from being parsed as options.
|
||||
- When searching, prefer the search_files tool over using grep in the terminal. If you are directly instructed to use grep, ensure your search patterns are targeted and not too vague to prevent extremely large outputs.
|
||||
- When using replace_in_file, pay careful attention to the EDITING FILES section above. The most common errors are:
|
||||
- Not matching content exactly (every character, space, and newline must match)
|
||||
- Using incomplete lines in SEARCH blocks (always include complete lines from start to end)
|
||||
- Forgetting the `+++++++ REPLACE` closing marker
|
||||
- Not listing multiple SEARCH/REPLACE blocks in the order they appear in the file
|
||||
- Using the final auto-formatted file state (provided in tool responses) as the reference for subsequent edits is critical for success
|
||||
|
||||
====
|
||||
|
||||
SYSTEM INFORMATION
|
||||
|
||||
Operating System: macOS
|
||||
IDE: TestIde
|
||||
Default Shell: /bin/zsh
|
||||
Home Directory: /Users/tester
|
||||
Current Working Directory: /Users/tester/dev/project
|
||||
|
||||
====
|
||||
|
||||
OBJECTIVE
|
||||
|
||||
You accomplish a given task iteratively, breaking it down into clear steps and working through them methodically.
|
||||
|
||||
1. Analyze the user's task and set clear, achievable goals to accomplish it. Prioritize these goals in a logical order.
|
||||
2. Work through these goals sequentially, utilizing available tools as necessary. You may call multiple independent tools in a single response to work efficiently. Each goal should correspond to a distinct step in your problem-solving process. You will be informed on the work completed and what's remaining as you go.
|
||||
3. Remember, you have extensive capabilities with access to a wide range of tools that can be used in powerful and clever ways as necessary to accomplish each goal. First, analyze the file structure provided in environment_details to gain context and insights for proceeding effectively. Then, think about which of the provided tools is the most relevant tool to accomplish the user's task. Next, go through each of the required parameters of the relevant tool and determine if the user has directly provided or given enough information to infer a value. When deciding if the parameter can be inferred, carefully consider all the context to see if it supports a specific value. If all of the required parameters are present or can be reasonably inferred, close the thinking tag and proceed with the tool use. If one of the values for a required parameter is missing, ask the user to provide the missing parameters using the ask_followup_question tool (use your tools to gather information when possible to avoid unnecessary questions). Focus on required parameters only - proceed with defaults for optional parameters.
|
||||
4. Before using attempt_completion, verify the task requirements with available tools. Confirm required output files exist, required content and format constraints are satisfied, and no forbidden extra artifacts were introduced. If checks fail, continue working until the result is verifiably correct.
|
||||
5. Once you've completed the user's task and verified the result, use the attempt_completion tool to present the result. Provide a CLI command to showcase your work when applicable (e.g., `open index.html` for web development).
|
||||
6. For non-actionable tasks, use attempt_completion to provide a clear explanation or direct answer.
|
||||
|
||||
## Working Style
|
||||
|
||||
- Be concise and direct in your communication. Use tools without preamble or explanation.
|
||||
- After implementing features, test them to ensure they work properly.
|
||||
- Provide periodic progress updates when executing multi-step plans.
|
||||
- Present messages in a clear, technical manner focusing on what was done rather than conversational acknowledgments.
|
||||
|
||||
## Core Principles
|
||||
|
||||
- Implement precisely what was requested with the fewest lines of code possible while meeting all requirements.
|
||||
- Before adding any feature or complexity, verify it was explicitly requested. When uncertain, ask clarifying questions.
|
||||
- Value precision and reliability. The simplest solution that fulfills all requirements is always preferred.
|
||||
|
||||
====
|
||||
|
||||
USER'S CUSTOM INSTRUCTIONS
|
||||
|
||||
The following additional instructions are provided by the user, and should be followed to the best of your ability without interfering with the TOOL USE guidelines.
|
||||
|
||||
Prefer TypeScript
|
||||
|
||||
Follow global rules
|
||||
|
||||
Follow local rules
|
||||
@@ -203,6 +203,7 @@ const modelTestCases = [
|
||||
{ family: ModelFamily.GEMINI_3, modelId: "gemini-3", providerId: "vertex" },
|
||||
{ family: ModelFamily.TRINITY, modelId: "arcee-ai/trinity-large-preview", providerId: "openrouter" },
|
||||
]
|
||||
const gemini3ModelTestCases = modelTestCases.filter(({ family }) => family === ModelFamily.GEMINI_3)
|
||||
|
||||
// ============================================================================
|
||||
// Tests
|
||||
@@ -279,6 +280,28 @@ describe("Prompt System Integration Tests", () => {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
describe("Gemini 3 Specific", () => {
|
||||
for (const { family, modelId, providerId } of gemini3ModelTestCases) {
|
||||
const enableNativeToolCalls = isNativeToolsFamily(family)
|
||||
it(`should include parallel tool-calling guidance for ${providerId}/${modelId} when enabled`, async function () {
|
||||
const context: SystemPromptContext = {
|
||||
...baseContext,
|
||||
providerInfo: makeProviderInfo(modelId, providerId),
|
||||
enableNativeToolCalls,
|
||||
enableParallelToolCalling: true,
|
||||
}
|
||||
|
||||
await runPromptTest(this, context, modelId, async ({ systemPrompt }) => {
|
||||
expect(systemPrompt).to.include(
|
||||
"- When multiple operations are independent (for example reading several files or searching in multiple directories), call multiple tools in a single response rather than one at a time.",
|
||||
)
|
||||
const snapshotName = `${providerId}_${modelId.replace(/[^a-zA-Z0-9]/g, "_")}-parallel-tools.snap`
|
||||
await assertSnapshot(snapshotName, systemPrompt)
|
||||
})
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe("Context-Specific Features", () => {
|
||||
|
||||
@@ -3,7 +3,7 @@ import { describe, it } from "mocha"
|
||||
import { ModelFamily } from "@/shared/prompts"
|
||||
import { ClineDefaultTool } from "@/shared/tools"
|
||||
import type { ClineToolSpec } from "../spec"
|
||||
import { toolSpecFunctionDeclarations, toolSpecInputSchema } from "../spec"
|
||||
import { toolSpecFunctionDeclarations, toolSpecFunctionDefinition, toolSpecInputSchema } from "../spec"
|
||||
import type { SystemPromptContext } from "../types"
|
||||
|
||||
const mockContext: SystemPromptContext = {
|
||||
@@ -94,3 +94,36 @@ describe("Gemini and Anthropic parameter descriptions match", () => {
|
||||
expect(geminiDesc).to.equal(anthropicDesc)
|
||||
})
|
||||
})
|
||||
|
||||
describe("native tool placeholder replacement", () => {
|
||||
it("replaces CWD and MULTI_ROOT_HINT placeholders in descriptions", () => {
|
||||
const context: SystemPromptContext = {
|
||||
...mockContext,
|
||||
isMultiRootEnabled: true,
|
||||
}
|
||||
const tool = makeTool({
|
||||
parameters: [
|
||||
{
|
||||
name: "path",
|
||||
required: true,
|
||||
instruction: "Path (relative to {{CWD}}){{MULTI_ROOT_HINT}}",
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const openAI = toolSpecFunctionDefinition(tool, context)
|
||||
const anthropic = toolSpecInputSchema(tool, context)
|
||||
const gemini = toolSpecFunctionDeclarations(tool, context)
|
||||
|
||||
const openAIDesc = ((openAI as any).function.parameters.properties.path as any).description as string
|
||||
const anthropicDesc = ((anthropic as any).input_schema.properties.path as any).description as string
|
||||
const geminiDesc = (gemini.parameters?.properties?.["path"] as any)?.description as string
|
||||
|
||||
for (const desc of [openAIDesc, anthropicDesc, geminiDesc]) {
|
||||
expect(desc).to.include("/test/project")
|
||||
expect(desc).to.include("Use @workspace:path syntax")
|
||||
expect(desc).to.not.include("{{CWD}}")
|
||||
expect(desc).to.not.include("{{MULTI_ROOT_HINT}}")
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { PromptBuilder } from "../../registry/PromptBuilder"
|
||||
import { TemplateEngine } from "../../templates/TemplateEngine"
|
||||
import type { PromptVariant, SystemPromptContext } from "../../types"
|
||||
import { MULTI_ROOT_HINT } from "../../constants"
|
||||
|
||||
export async function getToolUseToolsSection(variant: PromptVariant, context: SystemPromptContext): Promise<string> {
|
||||
const focusChainEnabled = context.focusChainSettings?.enabled
|
||||
@@ -38,4 +39,3 @@ const FOCUS_CHAIN_USAGE = `<task_progress>
|
||||
Checklist here (optional)
|
||||
</task_progress>
|
||||
`
|
||||
const MULTI_ROOT_HINT = " Use @workspace:path syntax (e.g., @frontend:src/index.ts) to specify a workspace."
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export const MULTI_ROOT_HINT = " Use @workspace:path syntax (e.g., @frontend:src/index.ts) to specify a workspace."
|
||||
@@ -4,6 +4,7 @@ import { ChatCompletionTool as OpenAITool } from "openai/resources/chat/completi
|
||||
import { FunctionTool as OpenAIResponseFunctionTool, Tool as OpenAIResponseTool } from "openai/resources/responses/responses"
|
||||
import { ModelFamily } from "@/shared/prompts"
|
||||
import type { ClineDefaultTool } from "@/shared/tools"
|
||||
import { MULTI_ROOT_HINT } from "./constants"
|
||||
import type { SystemPromptContext } from "./types"
|
||||
|
||||
export interface ClineToolSpec {
|
||||
@@ -398,13 +399,19 @@ export function toOpenAIResponsesAPITool(openAITool: OpenAITool): OpenAIResponse
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces template placeholders in description with viewport dimensions.
|
||||
* Replaces template placeholders in descriptions for native tool schemas.
|
||||
*/
|
||||
function replacer(description: string, context: SystemPromptContext): string {
|
||||
const width = context.browserSettings?.viewport?.width || 900
|
||||
const height = context.browserSettings?.viewport?.height || 600
|
||||
const cwd = context.cwd || process.cwd()
|
||||
const multiRootHint = context.isMultiRootEnabled ? MULTI_ROOT_HINT : ""
|
||||
|
||||
return description.replace("{{BROWSER_VIEWPORT_WIDTH}}", String(width)).replace("{{BROWSER_VIEWPORT_HEIGHT}}", String(height))
|
||||
return description
|
||||
.replace(/{{BROWSER_VIEWPORT_WIDTH}}/g, String(width))
|
||||
.replace(/{{BROWSER_VIEWPORT_HEIGHT}}/g, String(height))
|
||||
.replace(/{{CWD}}/g, cwd)
|
||||
.replace(/{{MULTI_ROOT_HINT}}/g, multiRootHint)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -130,7 +130,7 @@ const GEMINI_3_RULES_TEMPLATE = (context: SystemPromptContext) => `RULES
|
||||
|
||||
- The current working directory is \`{{CWD}}\` - this is the directory where all the tools will be executed from.
|
||||
- When executing terminal commands, new terminals always open in the workspace directory. Use relative paths or chain commands with proper shell operators (e.g., \`cd path && command\` to change directory and run a command together).
|
||||
- When executing commands, do not assume success when expected output is missing or incomplete. Treat the result as unverified and run follow-up checks (for example checking exit status, verifying files with \`test\` and \`ls\`, or validating content with \`grep\` and \`wc\`) before proceeding.${context.yoloModeToggled !== true ? " If output is still unavailable after reasonable checks and you need it to continue, use the ask_followup_question tool to request the user to copy and paste it back to you." : ""}
|
||||
- When executing commands, do not assume success when expected output is missing or incomplete. Treat the result as unverified and run follow-up checks (for example checking exit status, verifying files with \`test\` and \`ls\`, or validating content with \`grep\` and \`wc\`) before proceeding.${context.yoloModeToggled !== true ? " If output is still unavailable after reasonable checks and you need it to continue, use the ask_followup_question tool to request the user to copy and paste it back to you." : ""}${context.enableParallelToolCalling ? "\n- When multiple operations are independent (for example reading several files or searching in multiple directories), call multiple tools in a single response rather than one at a time. Use sequential tool calls only when later steps depend on earlier results." : ""}
|
||||
- When passing untrusted or variable text as positional command arguments, insert \`--\` before the positional values if they may begin with \`-\` (for example \`my-cli -- "$value"\`). This prevents the values from being parsed as options.
|
||||
- When searching, prefer the search_files tool over using grep in the terminal. If you are directly instructed to use grep, ensure your search patterns are targeted and not too vague to prevent extremely large outputs.
|
||||
- When using replace_in_file, pay careful attention to the EDITING FILES section above. The most common errors are:
|
||||
|
||||
@@ -881,13 +881,16 @@ export class StateManager {
|
||||
|
||||
/**
|
||||
* Helper to get a setting value with override support
|
||||
* Precedence: remote config > task settings > global settings
|
||||
* Precedence: remote config > session override > task settings > global settings
|
||||
*/
|
||||
private getSettingWithOverride<K extends keyof Settings>(key: K): Settings[K] {
|
||||
const remoteValue = this.remoteConfigCache[key]
|
||||
if (remoteValue !== undefined) {
|
||||
return remoteValue
|
||||
}
|
||||
if (this.sessionOverrideCache[key] !== undefined) {
|
||||
return this.sessionOverrideCache[key]
|
||||
}
|
||||
const taskValue = this.taskStateCache[key]
|
||||
if (taskValue !== undefined) {
|
||||
return taskValue
|
||||
@@ -920,7 +923,10 @@ export class StateManager {
|
||||
// Build API handler settings object with task override support
|
||||
const settings = Object.fromEntries(ApiHandlerSettingsKeys.map((key) => [key, this.getSettingWithOverride(key)]))
|
||||
|
||||
return { ...secrets, ...settings } satisfies ApiConfiguration
|
||||
return {
|
||||
...secrets,
|
||||
...settings,
|
||||
} satisfies ApiConfiguration
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+41
-12
@@ -683,13 +683,19 @@ export class Task {
|
||||
await this.postStateToWebview()
|
||||
}
|
||||
|
||||
// Notification hook marks that Cline is waiting for user input.
|
||||
await this.runNotificationHook({
|
||||
event: "user_attention",
|
||||
source: type,
|
||||
message: text || "",
|
||||
waitingForUserInput: true,
|
||||
})
|
||||
if (type !== "command_output") {
|
||||
// command_output is a special ask used by the webview command stream UI.
|
||||
// It powers "Proceed While Running" and incremental output updates, so it is
|
||||
// not a strict approval boundary that should force external "user_attention"
|
||||
// handling. In auto-approve flows, command_output asks can still be emitted,
|
||||
// so we intentionally skip Notification hook emission for this ask type.
|
||||
await this.runNotificationHook({
|
||||
event: "user_attention",
|
||||
source: type,
|
||||
message: text || "",
|
||||
waitingForUserInput: true,
|
||||
})
|
||||
}
|
||||
|
||||
await pWaitFor(() => this.taskState.askResponse !== undefined || this.taskState.lastMessageTs !== askTs, {
|
||||
interval: 100,
|
||||
@@ -2586,7 +2592,11 @@ export class Task {
|
||||
})
|
||||
}
|
||||
|
||||
const queueUsageChunkSideEffects = (usageInputTokens: number, usageOutputTokens: number) => {
|
||||
const queueUsageChunkSideEffects = (
|
||||
usageInputTokens: number,
|
||||
usageOutputTokens: number,
|
||||
chunkOptions?: { cacheWriteTokens?: number; cacheReadTokens?: number; totalCost?: number },
|
||||
) => {
|
||||
usageChunkSideEffectsQueue = usageChunkSideEffectsQueue
|
||||
// This executes immediately after enqueue (microtask if already resolved), not at stream end.
|
||||
.then(async () => {
|
||||
@@ -2596,7 +2606,14 @@ export class Task {
|
||||
|
||||
await updateApiReqMsgFromMetrics()
|
||||
await this.postStateToWebview()
|
||||
await telemetryService.captureTokenUsage(this.ulid, usageInputTokens, usageOutputTokens, model.id)
|
||||
await telemetryService.captureTokenUsage(
|
||||
this.ulid,
|
||||
usageInputTokens,
|
||||
usageOutputTokens,
|
||||
providerId,
|
||||
model.id,
|
||||
chunkOptions,
|
||||
)
|
||||
})
|
||||
.catch((error) => {
|
||||
Logger.debug(`[Task ${this.taskId}] Failed to process usage chunk side effects: ${error}`)
|
||||
@@ -2732,7 +2749,11 @@ export class Task {
|
||||
taskMetrics.cacheWriteTokens += chunk.cacheWriteTokens ?? 0
|
||||
taskMetrics.cacheReadTokens += chunk.cacheReadTokens ?? 0
|
||||
taskMetrics.totalCost = chunk.totalCost ?? taskMetrics.totalCost
|
||||
queueUsageChunkSideEffects(chunk.inputTokens, chunk.outputTokens)
|
||||
queueUsageChunkSideEffects(chunk.inputTokens, chunk.outputTokens, {
|
||||
cacheWriteTokens: chunk.cacheWriteTokens,
|
||||
cacheReadTokens: chunk.cacheReadTokens,
|
||||
totalCost: chunk.totalCost,
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
@@ -2946,7 +2967,11 @@ export class Task {
|
||||
taskMetrics.cacheWriteTokens += apiStreamUsage.cacheWriteTokens ?? 0
|
||||
taskMetrics.cacheReadTokens += apiStreamUsage.cacheReadTokens ?? 0
|
||||
taskMetrics.totalCost = apiStreamUsage.totalCost ?? taskMetrics.totalCost
|
||||
queueUsageChunkSideEffects(apiStreamUsage.inputTokens, apiStreamUsage.outputTokens)
|
||||
queueUsageChunkSideEffects(apiStreamUsage.inputTokens, apiStreamUsage.outputTokens, {
|
||||
cacheWriteTokens: apiStreamUsage.cacheWriteTokens,
|
||||
cacheReadTokens: apiStreamUsage.cacheReadTokens,
|
||||
totalCost: apiStreamUsage.totalCost,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2961,7 +2986,11 @@ export class Task {
|
||||
}
|
||||
|
||||
// Stored the assistant API response immediately after the stream finishes in the same turn
|
||||
const assistantHasContent = assistantMessage.length > 0 || this.useNativeToolCalls
|
||||
// Check if the stream produced any content — either text or native tool calls.
|
||||
// toolUseHandler may have accumulated tool_use blocks even when useNativeToolCalls is false
|
||||
// (e.g., from Claude Code provider when the model returns native tool_use blocks).
|
||||
const hasAccumulatedToolCalls = toolUseHandler.getAllFinalizedToolUses().length > 0
|
||||
const assistantHasContent = assistantMessage.length > 0 || this.useNativeToolCalls || hasAccumulatedToolCalls
|
||||
if (assistantHasContent) {
|
||||
telemetryService.captureConversationTurnEvent(
|
||||
this.ulid,
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
import type { ToolUse } from "@core/assistant-message"
|
||||
import { formatResponse } from "@core/prompts/responses"
|
||||
import { WorkspacePathAdapter } from "@core/workspace/WorkspacePathAdapter"
|
||||
import { showSystemNotification } from "@integrations/notifications"
|
||||
import { showApprovalNotification, showSystemNotification } from "@integrations/notifications"
|
||||
import { COMMAND_REQ_APP_STRING } from "@shared/combineCommandSequences"
|
||||
import { ClineAsk } from "@shared/ExtensionMessage"
|
||||
import { arePathsEqual } from "@utils/path"
|
||||
import { telemetryService } from "@/services/telemetry"
|
||||
import { ClineDefaultTool } from "@/shared/tools"
|
||||
import type { ToolResponse } from "../../index"
|
||||
import { showNotificationForApproval } from "../../utils"
|
||||
import type { IFullyManagedTool } from "../ToolExecutorCoordinator"
|
||||
import type { ToolValidator } from "../ToolValidator"
|
||||
import type { TaskConfig } from "../types/TaskConfig"
|
||||
@@ -240,8 +239,8 @@ export class ExecuteCommandToolHandler implements IFullyManagedTool {
|
||||
)
|
||||
} else {
|
||||
// Manual approval flow
|
||||
showNotificationForApproval(
|
||||
`Cline wants to execute a command: ${actualCommand}`,
|
||||
void showApprovalNotification(
|
||||
{ message: actualCommand, requiresExplicitApproval: autoApproveSafe && requiresApprovalPerLLM },
|
||||
config.autoApprovalSettings.enableNotifications,
|
||||
)
|
||||
|
||||
|
||||
@@ -65,16 +65,40 @@ export class ListCodeDefinitionNamesToolHandler implements IFullyManagedTool {
|
||||
return await config.callbacks.sayAndCreateMissingParamError(this.name, "path")
|
||||
}
|
||||
|
||||
// Resolve the path and execute the parse operation inside a single
|
||||
// try/catch so that failures in either step (e.g. bad workspace hint,
|
||||
// non-existent directory) return a graceful tool error instead of
|
||||
// crashing the task.
|
||||
let absolutePath: string
|
||||
let displayPath: string
|
||||
let result: string
|
||||
try {
|
||||
const pathResult = resolveWorkspacePath(config, relDirPath!, "ListCodeDefinitionNamesToolHandler.execute")
|
||||
;({ absolutePath, displayPath } =
|
||||
typeof pathResult === "string" ? { absolutePath: pathResult, displayPath: relDirPath! } : pathResult)
|
||||
result = await parseSourceCodeForDefinitionsTopLevel(absolutePath, config.services.clineIgnoreController)
|
||||
} catch (error) {
|
||||
config.taskState.consecutiveMistakeCount++
|
||||
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||
return formatResponse.toolError(`Error listing code definitions: ${errorMessage}`)
|
||||
}
|
||||
|
||||
// parseSourceCodeForDefinitionsTopLevel returns error strings for file paths
|
||||
// and non-existent directories rather than throwing. Check for these error
|
||||
// conditions and increment the counter so repeated failures accumulate.
|
||||
const isErrorResult =
|
||||
result.includes("provided path is a file, not a directory") ||
|
||||
result.includes("does not exist or you do not have permission")
|
||||
|
||||
if (isErrorResult) {
|
||||
config.taskState.consecutiveMistakeCount++
|
||||
return formatResponse.toolError(result)
|
||||
}
|
||||
|
||||
// Only reset after a successful operation so repeated failures
|
||||
// accumulate toward the yolo-mode mistake limit.
|
||||
config.taskState.consecutiveMistakeCount = 0
|
||||
|
||||
// Resolve the absolute path based on multi-workspace configuration
|
||||
const pathResult = resolveWorkspacePath(config, relDirPath!, "ListCodeDefinitionNamesToolHandler.execute")
|
||||
const { absolutePath, displayPath } =
|
||||
typeof pathResult === "string" ? { absolutePath: pathResult, displayPath: relDirPath! } : pathResult
|
||||
|
||||
// Execute the actual parse source code operation
|
||||
const result = await parseSourceCodeForDefinitionsTopLevel(absolutePath, config.services.clineIgnoreController)
|
||||
|
||||
// Handle approval flow
|
||||
const sharedMessageProps = {
|
||||
tool: "listCodeDefinitionNames",
|
||||
|
||||
@@ -71,33 +71,51 @@ export class ListFilesToolHandler implements IFullyManagedTool {
|
||||
return await config.callbacks.sayAndCreateMissingParamError(this.name, "path")
|
||||
}
|
||||
|
||||
config.taskState.consecutiveMistakeCount = 0
|
||||
|
||||
// Resolve the absolute path based on multi-workspace configuration
|
||||
const pathResult = resolveWorkspacePath(config, relDirPath!, "ListFilesToolHandler.execute")
|
||||
const { absolutePath, displayPath } =
|
||||
typeof pathResult === "string" ? { absolutePath: pathResult, displayPath: relDirPath! } : pathResult
|
||||
|
||||
// Determine workspace context for telemetry
|
||||
const fallbackAbsolutePath = path.resolve(config.cwd, relDirPath ?? "")
|
||||
const workspaceContext = {
|
||||
isMultiRootEnabled: config.isMultiRootEnabled || false,
|
||||
usedWorkspaceHint: typeof pathResult !== "string", // multi-root path result indicates hint usage
|
||||
resolvedToNonPrimary: !arePathsEqual(absolutePath, fallbackAbsolutePath),
|
||||
resolutionMethod: (typeof pathResult !== "string" ? "hint" : "primary_fallback") as "hint" | "primary_fallback",
|
||||
}
|
||||
|
||||
// Check clineignore access
|
||||
// Check clineignore access before performing any IO.
|
||||
// Increment the counter so repeated attempts at blocked paths
|
||||
// accumulate toward the yolo-mode mistake limit.
|
||||
const accessValidation = this.validator.checkClineIgnorePath(relDirPath!)
|
||||
if (!accessValidation.ok) {
|
||||
config.taskState.consecutiveMistakeCount++
|
||||
if (!config.isSubagentExecution) {
|
||||
await config.callbacks.say("clineignore_error", relDirPath)
|
||||
}
|
||||
return formatResponse.toolError(formatResponse.clineIgnoreError(relDirPath!))
|
||||
}
|
||||
|
||||
// Execute the actual list files operation
|
||||
const [files, didHitLimit] = await listFiles(absolutePath, recursive, 200)
|
||||
// Resolve the path and execute the list operation inside a single
|
||||
// try/catch so that failures in either step (e.g. bad workspace hint,
|
||||
// non-existent directory) return a graceful tool error instead of
|
||||
// crashing the task.
|
||||
let absolutePath: string
|
||||
let displayPath: string
|
||||
let files: string[]
|
||||
let didHitLimit: boolean
|
||||
let usedWorkspaceHint: boolean
|
||||
try {
|
||||
const pathResult = resolveWorkspacePath(config, relDirPath!, "ListFilesToolHandler.execute")
|
||||
;({ absolutePath, displayPath } =
|
||||
typeof pathResult === "string" ? { absolutePath: pathResult, displayPath: relDirPath! } : pathResult)
|
||||
usedWorkspaceHint = typeof pathResult !== "string"
|
||||
;[files, didHitLimit] = await listFiles(absolutePath, recursive, 200)
|
||||
} catch (error) {
|
||||
config.taskState.consecutiveMistakeCount++
|
||||
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||
return formatResponse.toolError(`Error listing files: ${errorMessage}`)
|
||||
}
|
||||
|
||||
// Only reset after all validations and the core operation succeed so
|
||||
// repeated failures accumulate toward the yolo-mode mistake limit.
|
||||
config.taskState.consecutiveMistakeCount = 0
|
||||
|
||||
// Determine workspace context for telemetry
|
||||
const fallbackAbsolutePath = path.resolve(config.cwd, relDirPath ?? "")
|
||||
const workspaceContext = {
|
||||
isMultiRootEnabled: config.isMultiRootEnabled || false,
|
||||
usedWorkspaceHint,
|
||||
resolvedToNonPrimary: !arePathsEqual(absolutePath, fallbackAbsolutePath),
|
||||
resolutionMethod: (usedWorkspaceHint ? "hint" : "primary_fallback") as "hint" | "primary_fallback",
|
||||
}
|
||||
|
||||
const result = formatResponse.formatFilesList(absolutePath, files, didHitLimit, config.services.clineIgnoreController)
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import path from "node:path"
|
||||
import type { ToolUse } from "@core/assistant-message"
|
||||
import { formatResponse } from "@core/prompts/responses"
|
||||
import { getWorkspaceBasename, resolveWorkspacePath } from "@core/workspace"
|
||||
import { extractFileContent } from "@integrations/misc/extract-file-content"
|
||||
import { extractFileContent, type FileContentResult } from "@integrations/misc/extract-file-content"
|
||||
import { arePathsEqual, getReadablePath, isLocatedInWorkspace } from "@utils/path"
|
||||
import { telemetryService } from "@/services/telemetry"
|
||||
import { ClineSayTool } from "@/shared/ExtensionMessage"
|
||||
@@ -76,8 +76,6 @@ export class ReadFileToolHandler implements IFullyManagedTool {
|
||||
return formatResponse.toolError(formatResponse.clineIgnoreError(relPath!))
|
||||
}
|
||||
|
||||
config.taskState.consecutiveMistakeCount = 0
|
||||
|
||||
// Resolve the absolute path based on multi-workspace configuration
|
||||
const pathResult = resolveWorkspacePath(config, relPath!, "ReadFileToolHandler.execute")
|
||||
const { absolutePath, displayPath } =
|
||||
@@ -171,7 +169,24 @@ export class ReadFileToolHandler implements IFullyManagedTool {
|
||||
|
||||
// Execute the actual file read operation
|
||||
const supportsImages = config.api.getModel().info.supportsImages ?? false
|
||||
const fileContent = await extractFileContent(absolutePath, supportsImages)
|
||||
let fileContent: FileContentResult
|
||||
try {
|
||||
fileContent = await extractFileContent(absolutePath, supportsImages)
|
||||
} catch (error) {
|
||||
// Return a graceful tool error instead of crashing. This allows the
|
||||
// model to see the error (e.g. "File not found") and recover by
|
||||
// trying a different path, rather than terminating the entire task.
|
||||
config.taskState.consecutiveMistakeCount++
|
||||
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||
const normalizedMessage = errorMessage.startsWith("Error reading file:")
|
||||
? errorMessage
|
||||
: `Error reading file: ${errorMessage}`
|
||||
return formatResponse.toolError(normalizedMessage)
|
||||
}
|
||||
|
||||
// Only reset mistake count after a successful read, so that repeated
|
||||
// file-not-found errors accumulate toward the yolo-mode mistake limit.
|
||||
config.taskState.consecutiveMistakeCount = 0
|
||||
|
||||
// Track file read operation
|
||||
await config.services.fileContextTracker.trackFileContext(relPath!, "read_tool")
|
||||
|
||||
@@ -224,13 +224,22 @@ export class SearchFilesToolHandler implements IFullyManagedTool {
|
||||
return await config.callbacks.sayAndCreateMissingParamError(this.name, "regex")
|
||||
}
|
||||
|
||||
config.taskState.consecutiveMistakeCount = 0
|
||||
|
||||
// Parse workspace hint from the path
|
||||
const { workspaceHint, relPath: parsedPath } = parseWorkspaceInlinePath(relDirPath!)
|
||||
|
||||
// Determine which paths to search
|
||||
const searchPaths = this.determineSearchPaths(config, parsedPath, workspaceHint, relDirPath!)
|
||||
// Parse workspace hint from the path and determine search targets.
|
||||
// These can throw if the workspace configuration is invalid or the
|
||||
// path cannot be resolved, so catch and return a graceful tool error.
|
||||
let parsedPath: string
|
||||
let workspaceHint: string | undefined
|
||||
let searchPaths: ReturnType<SearchFilesToolHandler["determineSearchPaths"]>
|
||||
try {
|
||||
const parsed = parseWorkspaceInlinePath(relDirPath!)
|
||||
parsedPath = parsed.relPath
|
||||
workspaceHint = parsed.workspaceHint
|
||||
searchPaths = this.determineSearchPaths(config, parsedPath, workspaceHint, relDirPath!)
|
||||
} catch (error) {
|
||||
config.taskState.consecutiveMistakeCount++
|
||||
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||
return formatResponse.toolError(`Error resolving search path: ${errorMessage}`)
|
||||
}
|
||||
|
||||
// Determine workspace context for telemetry
|
||||
const primaryWorkspaceRoot = searchPaths[0]?.workspaceRoot
|
||||
@@ -279,6 +288,16 @@ export class SearchFilesToolHandler implements IFullyManagedTool {
|
||||
// Format and combine results
|
||||
const results = this.formatSearchResults(config, searchResults, searchPaths)
|
||||
|
||||
// Only reset after a successful operation so repeated failures
|
||||
// accumulate toward the yolo-mode mistake limit.
|
||||
// If ALL searches failed, increment the mistake counter.
|
||||
const anySucceeded = searchResults.some((result) => result.success)
|
||||
if (anySucceeded) {
|
||||
config.taskState.consecutiveMistakeCount = 0
|
||||
} else {
|
||||
config.taskState.consecutiveMistakeCount++
|
||||
}
|
||||
|
||||
// Capture workspace search pattern telemetry
|
||||
if (config.isMultiRootEnabled && config.workspaceManager) {
|
||||
const searchType = workspaceHint ? "targeted" : searchPaths.length > 1 ? "cross_workspace" : "primary_only"
|
||||
|
||||
@@ -0,0 +1,579 @@
|
||||
import { strict as assert } from "node:assert"
|
||||
import fs from "node:fs/promises"
|
||||
import os from "node:os"
|
||||
import path from "node:path"
|
||||
import { ClineDefaultTool } from "@shared/tools"
|
||||
import * as pathUtils from "@utils/path"
|
||||
import { afterEach, beforeEach, describe, it } from "mocha"
|
||||
import sinon from "sinon"
|
||||
import { TaskState } from "../../../TaskState"
|
||||
import { ToolValidator } from "../../ToolValidator"
|
||||
import type { TaskConfig } from "../../types/TaskConfig"
|
||||
import { ListCodeDefinitionNamesToolHandler } from "../ListCodeDefinitionNamesToolHandler"
|
||||
import { ListFilesToolHandler } from "../ListFilesToolHandler"
|
||||
import { SearchFilesToolHandler } from "../SearchFilesToolHandler"
|
||||
|
||||
/**
|
||||
* End-to-end tests for path-based tool handlers' error recovery.
|
||||
*
|
||||
* These exercise the actual handlers with a mock TaskConfig (following the
|
||||
* SubagentToolHandler.test.ts pattern), verifying that:
|
||||
*
|
||||
* 1. Non-existent paths return a tool error (not a thrown exception)
|
||||
* 2. consecutiveMistakeCount increments on failure
|
||||
* 3. Repeated failures accumulate across calls
|
||||
* 4. A successful operation resets consecutiveMistakeCount to 0
|
||||
* 5. Missing parameters increment the counter
|
||||
* 6. Forced exceptions from core operations are caught gracefully
|
||||
*/
|
||||
|
||||
let tmpDir: string
|
||||
|
||||
function createConfig() {
|
||||
const taskState = new TaskState()
|
||||
|
||||
const callbacks = {
|
||||
say: sinon.stub().resolves(undefined),
|
||||
ask: sinon.stub().resolves({ response: "yesButtonClicked" }),
|
||||
saveCheckpoint: sinon.stub().resolves(),
|
||||
sayAndCreateMissingParamError: sinon.stub().resolves("missing"),
|
||||
removeLastPartialMessageIfExistsWithType: sinon.stub().resolves(),
|
||||
shouldAutoApproveToolWithPath: sinon.stub().resolves(true),
|
||||
postStateToWebview: sinon.stub().resolves(),
|
||||
cancelTask: sinon.stub().resolves(),
|
||||
updateTaskHistory: sinon.stub().resolves([]),
|
||||
switchToActMode: sinon.stub().resolves(false),
|
||||
setActiveHookExecution: sinon.stub().resolves(),
|
||||
clearActiveHookExecution: sinon.stub().resolves(),
|
||||
getActiveHookExecution: sinon.stub().resolves(undefined),
|
||||
runUserPromptSubmitHook: sinon.stub().resolves({}),
|
||||
executeCommandTool: sinon.stub().resolves([false, "ok"]),
|
||||
cancelRunningCommandTool: sinon.stub().resolves(false),
|
||||
doesLatestTaskCompletionHaveNewChanges: sinon.stub().resolves(false),
|
||||
updateFCListFromToolResponse: sinon.stub().resolves(),
|
||||
shouldAutoApproveTool: sinon.stub().returns([true, true]),
|
||||
reinitExistingTaskFromId: sinon.stub().resolves(),
|
||||
applyLatestBrowserSettings: sinon.stub().resolves(undefined),
|
||||
}
|
||||
|
||||
const config = {
|
||||
taskId: "task-1",
|
||||
ulid: "ulid-1",
|
||||
cwd: tmpDir,
|
||||
mode: "act",
|
||||
strictPlanModeEnabled: false,
|
||||
yoloModeToggled: true,
|
||||
doubleCheckCompletionEnabled: false,
|
||||
vscodeTerminalExecutionMode: "backgroundExec",
|
||||
enableParallelToolCalling: true,
|
||||
isSubagentExecution: true, // skip UI calls and approval flow
|
||||
taskState,
|
||||
messageState: {},
|
||||
api: {
|
||||
getModel: () => ({ id: "test-model", info: { supportsImages: false } }),
|
||||
},
|
||||
autoApprovalSettings: {
|
||||
enableNotifications: false,
|
||||
actions: { executeSafeCommands: false, executeAllCommands: false },
|
||||
},
|
||||
autoApprover: {
|
||||
shouldAutoApproveTool: sinon.stub().returns([true, true]),
|
||||
},
|
||||
browserSettings: {},
|
||||
focusChainSettings: {},
|
||||
services: {
|
||||
stateManager: {
|
||||
getGlobalStateKey: () => undefined,
|
||||
getGlobalSettingsKey: (key: string) => {
|
||||
if (key === "mode") return "act"
|
||||
if (key === "hooksEnabled") return false
|
||||
return undefined
|
||||
},
|
||||
getApiConfiguration: () => ({
|
||||
planModeApiProvider: "openai",
|
||||
actModeApiProvider: "openai",
|
||||
}),
|
||||
},
|
||||
fileContextTracker: {
|
||||
trackFileContext: sinon.stub().resolves(),
|
||||
},
|
||||
mcpHub: {},
|
||||
browserSession: {},
|
||||
urlContentFetcher: {},
|
||||
diffViewProvider: {},
|
||||
clineIgnoreController: { validateAccess: () => true, filterPaths: (paths: string[]) => paths },
|
||||
commandPermissionController: {},
|
||||
contextManager: {},
|
||||
},
|
||||
callbacks,
|
||||
coordinator: { getHandler: sinon.stub() },
|
||||
} as unknown as TaskConfig
|
||||
|
||||
const validator = new ToolValidator({ validateAccess: () => true } as any)
|
||||
|
||||
return { config, callbacks, taskState, validator }
|
||||
}
|
||||
|
||||
// ─── ListCodeDefinitionNamesToolHandler ─────────────────────────────────────
|
||||
|
||||
describe("ListCodeDefinitionNamesToolHandler.execute – error recovery", () => {
|
||||
let sandbox: sinon.SinonSandbox
|
||||
|
||||
beforeEach(async () => {
|
||||
sandbox = sinon.createSandbox()
|
||||
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "cline-listdef-test-"))
|
||||
sandbox.stub(pathUtils, "isLocatedInWorkspace").resolves(true)
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
sandbox.restore()
|
||||
await fs.rm(tmpDir, { recursive: true, force: true }).catch(() => {})
|
||||
})
|
||||
|
||||
function makeBlock(relPath?: string) {
|
||||
return {
|
||||
type: "tool_use" as const,
|
||||
name: ClineDefaultTool.LIST_CODE_DEF,
|
||||
params: relPath !== undefined ? { path: relPath } : {},
|
||||
partial: false,
|
||||
}
|
||||
}
|
||||
|
||||
it("returns a tool result (not a thrown exception) for a non-existent directory", async () => {
|
||||
const { config, taskState, validator } = createConfig()
|
||||
const handler = new ListCodeDefinitionNamesToolHandler(validator)
|
||||
|
||||
const result = await handler.execute(config, makeBlock("no-such-dir"))
|
||||
|
||||
// parseSourceCodeForDefinitionsTopLevel returns a descriptive string for
|
||||
// non-existent directories rather than throwing. The handler now detects
|
||||
// this error condition and increments the counter.
|
||||
assert.equal(typeof result, "string")
|
||||
assert.ok((result as string).includes("does not exist or you do not have permission"))
|
||||
assert.equal(taskState.consecutiveMistakeCount, 1)
|
||||
})
|
||||
|
||||
it("returns a graceful message for a file path (not a directory)", async () => {
|
||||
const { config, taskState, validator } = createConfig()
|
||||
const handler = new ListCodeDefinitionNamesToolHandler(validator)
|
||||
|
||||
const filePath = "not-a-dir.txt"
|
||||
await fs.writeFile(path.join(tmpDir, filePath), "content")
|
||||
|
||||
const result = await handler.execute(config, makeBlock(filePath))
|
||||
|
||||
assert.equal(typeof result, "string")
|
||||
assert.ok((result as string).includes("file, not a directory"))
|
||||
assert.equal(taskState.consecutiveMistakeCount, 1)
|
||||
})
|
||||
|
||||
it("increments consecutiveMistakeCount when path parameter is missing", async () => {
|
||||
const { config, taskState, validator } = createConfig()
|
||||
const handler = new ListCodeDefinitionNamesToolHandler(validator)
|
||||
|
||||
const result = await handler.execute(config, makeBlock())
|
||||
|
||||
assert.equal(result, "missing")
|
||||
assert.equal(taskState.consecutiveMistakeCount, 1)
|
||||
})
|
||||
|
||||
it("resets consecutiveMistakeCount to 0 after a successful operation", async () => {
|
||||
const { config, taskState, validator } = createConfig()
|
||||
const handler = new ListCodeDefinitionNamesToolHandler(validator)
|
||||
|
||||
// Accumulate failures via missing params
|
||||
await handler.execute(config, makeBlock())
|
||||
await handler.execute(config, makeBlock())
|
||||
assert.equal(taskState.consecutiveMistakeCount, 2)
|
||||
|
||||
// Create a real directory and list definitions (will find none, but succeeds)
|
||||
const dirName = "real-dir"
|
||||
await fs.mkdir(path.join(tmpDir, dirName))
|
||||
|
||||
await handler.execute(config, makeBlock(dirName))
|
||||
assert.equal(taskState.consecutiveMistakeCount, 0)
|
||||
})
|
||||
|
||||
it("catches a thrown exception from the core operation and returns a tool error", async () => {
|
||||
const { config, taskState, validator } = createConfig()
|
||||
const handler = new ListCodeDefinitionNamesToolHandler(validator)
|
||||
|
||||
// Stub parseSourceCodeForDefinitionsTopLevel to throw
|
||||
const treeSitter = await import("@services/tree-sitter")
|
||||
sandbox.stub(treeSitter, "parseSourceCodeForDefinitionsTopLevel").rejects(new Error("tree-sitter crashed"))
|
||||
|
||||
const result = await handler.execute(config, makeBlock("some-dir"))
|
||||
|
||||
assert.equal(typeof result, "string")
|
||||
assert.ok((result as string).includes("Error"))
|
||||
assert.ok((result as string).includes("tree-sitter crashed"))
|
||||
assert.equal(taskState.consecutiveMistakeCount, 1)
|
||||
})
|
||||
|
||||
it("accumulates failures when the core operation throws repeatedly", async () => {
|
||||
const { config, taskState, validator } = createConfig()
|
||||
const handler = new ListCodeDefinitionNamesToolHandler(validator)
|
||||
|
||||
const treeSitter = await import("@services/tree-sitter")
|
||||
sandbox.stub(treeSitter, "parseSourceCodeForDefinitionsTopLevel").rejects(new Error("boom"))
|
||||
|
||||
await handler.execute(config, makeBlock("dir-1"))
|
||||
assert.equal(taskState.consecutiveMistakeCount, 1)
|
||||
|
||||
await handler.execute(config, makeBlock("dir-2"))
|
||||
assert.equal(taskState.consecutiveMistakeCount, 2)
|
||||
|
||||
await handler.execute(config, makeBlock("dir-3"))
|
||||
assert.equal(taskState.consecutiveMistakeCount, 3)
|
||||
})
|
||||
|
||||
it("accumulates failures for repeated non-existent directory calls", async () => {
|
||||
const { config, taskState, validator } = createConfig()
|
||||
const handler = new ListCodeDefinitionNamesToolHandler(validator)
|
||||
|
||||
await handler.execute(config, makeBlock("nonexistent-1"))
|
||||
assert.equal(taskState.consecutiveMistakeCount, 1)
|
||||
|
||||
await handler.execute(config, makeBlock("nonexistent-2"))
|
||||
assert.equal(taskState.consecutiveMistakeCount, 2)
|
||||
|
||||
await handler.execute(config, makeBlock("nonexistent-3"))
|
||||
assert.equal(taskState.consecutiveMistakeCount, 3)
|
||||
})
|
||||
|
||||
it("accumulates failures for repeated file-path (not directory) calls", async () => {
|
||||
const { config, taskState, validator } = createConfig()
|
||||
const handler = new ListCodeDefinitionNamesToolHandler(validator)
|
||||
|
||||
const filePath1 = "file1.txt"
|
||||
const filePath2 = "file2.txt"
|
||||
const filePath3 = "file3.txt"
|
||||
await fs.writeFile(path.join(tmpDir, filePath1), "content")
|
||||
await fs.writeFile(path.join(tmpDir, filePath2), "content")
|
||||
await fs.writeFile(path.join(tmpDir, filePath3), "content")
|
||||
|
||||
await handler.execute(config, makeBlock(filePath1))
|
||||
assert.equal(taskState.consecutiveMistakeCount, 1)
|
||||
|
||||
await handler.execute(config, makeBlock(filePath2))
|
||||
assert.equal(taskState.consecutiveMistakeCount, 2)
|
||||
|
||||
await handler.execute(config, makeBlock(filePath3))
|
||||
assert.equal(taskState.consecutiveMistakeCount, 3)
|
||||
})
|
||||
})
|
||||
|
||||
// ─── ListFilesToolHandler ───────────────────────────────────────────────────
|
||||
|
||||
describe("ListFilesToolHandler.execute – error recovery", () => {
|
||||
let sandbox: sinon.SinonSandbox
|
||||
|
||||
beforeEach(async () => {
|
||||
sandbox = sinon.createSandbox()
|
||||
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "cline-listfiles-test-"))
|
||||
sandbox.stub(pathUtils, "isLocatedInWorkspace").resolves(true)
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
sandbox.restore()
|
||||
await fs.rm(tmpDir, { recursive: true, force: true }).catch(() => {})
|
||||
})
|
||||
|
||||
function makeBlock(relPath?: string, recursive?: string) {
|
||||
const params: Record<string, string> = {}
|
||||
if (relPath !== undefined) params.path = relPath
|
||||
if (recursive !== undefined) params.recursive = recursive
|
||||
return {
|
||||
type: "tool_use" as const,
|
||||
name: ClineDefaultTool.LIST_FILES,
|
||||
params,
|
||||
partial: false,
|
||||
}
|
||||
}
|
||||
|
||||
it("returns a tool result (not a thrown exception) for a non-existent directory", async () => {
|
||||
const { config, taskState, validator } = createConfig()
|
||||
const handler = new ListFilesToolHandler(validator)
|
||||
|
||||
const result = await handler.execute(config, makeBlock("no-such-dir"))
|
||||
|
||||
// listFiles returns empty for non-existent directories, so the handler
|
||||
// should succeed rather than throw.
|
||||
assert.equal(typeof result, "string")
|
||||
assert.equal(taskState.consecutiveMistakeCount, 0)
|
||||
})
|
||||
|
||||
it("increments consecutiveMistakeCount when path parameter is missing", async () => {
|
||||
const { config, taskState, validator } = createConfig()
|
||||
const handler = new ListFilesToolHandler(validator)
|
||||
|
||||
const result = await handler.execute(config, makeBlock())
|
||||
|
||||
assert.equal(result, "missing")
|
||||
assert.equal(taskState.consecutiveMistakeCount, 1)
|
||||
})
|
||||
|
||||
it("repeated missing-param failures accumulate", async () => {
|
||||
const { config, taskState, validator } = createConfig()
|
||||
const handler = new ListFilesToolHandler(validator)
|
||||
|
||||
await handler.execute(config, makeBlock())
|
||||
await handler.execute(config, makeBlock())
|
||||
await handler.execute(config, makeBlock())
|
||||
assert.equal(taskState.consecutiveMistakeCount, 3)
|
||||
})
|
||||
|
||||
it("resets consecutiveMistakeCount to 0 after a successful list", async () => {
|
||||
const { config, taskState, validator } = createConfig()
|
||||
const handler = new ListFilesToolHandler(validator)
|
||||
|
||||
// Accumulate failures
|
||||
await handler.execute(config, makeBlock())
|
||||
await handler.execute(config, makeBlock())
|
||||
assert.equal(taskState.consecutiveMistakeCount, 2)
|
||||
|
||||
// Create a real directory with a file
|
||||
const dirName = "real-dir"
|
||||
await fs.mkdir(path.join(tmpDir, dirName))
|
||||
await fs.writeFile(path.join(tmpDir, dirName, "file.txt"), "content")
|
||||
|
||||
const result = await handler.execute(config, makeBlock(dirName))
|
||||
assert.equal(typeof result, "string")
|
||||
assert.ok((result as string).includes("file.txt"))
|
||||
assert.equal(taskState.consecutiveMistakeCount, 0)
|
||||
})
|
||||
|
||||
it("catches a thrown exception from listFiles and returns a tool error", async () => {
|
||||
const { config, taskState, validator } = createConfig()
|
||||
const handler = new ListFilesToolHandler(validator)
|
||||
|
||||
// Stub listFiles to throw
|
||||
const listFilesModule = await import("@services/glob/list-files")
|
||||
sandbox.stub(listFilesModule, "listFiles").rejects(new Error("cwd must be a directory"))
|
||||
|
||||
const result = await handler.execute(config, makeBlock("some-dir"))
|
||||
|
||||
assert.equal(typeof result, "string")
|
||||
assert.ok((result as string).includes("Error"))
|
||||
assert.ok((result as string).includes("cwd must be a directory"))
|
||||
assert.equal(taskState.consecutiveMistakeCount, 1)
|
||||
})
|
||||
|
||||
it("accumulates failures when listFiles throws repeatedly", async () => {
|
||||
const { config, taskState, validator } = createConfig()
|
||||
const handler = new ListFilesToolHandler(validator)
|
||||
|
||||
const listFilesModule = await import("@services/glob/list-files")
|
||||
sandbox.stub(listFilesModule, "listFiles").rejects(new Error("boom"))
|
||||
|
||||
await handler.execute(config, makeBlock("dir-1"))
|
||||
assert.equal(taskState.consecutiveMistakeCount, 1)
|
||||
|
||||
await handler.execute(config, makeBlock("dir-2"))
|
||||
assert.equal(taskState.consecutiveMistakeCount, 2)
|
||||
|
||||
await handler.execute(config, makeBlock("dir-3"))
|
||||
assert.equal(taskState.consecutiveMistakeCount, 3)
|
||||
})
|
||||
|
||||
it("increments consecutiveMistakeCount on clineignore denial", async () => {
|
||||
const { config, taskState } = createConfig()
|
||||
// Create a validator whose clineIgnoreController blocks all paths
|
||||
const blockingValidator = new ToolValidator({ validateAccess: () => false } as any)
|
||||
const handler = new ListFilesToolHandler(blockingValidator)
|
||||
|
||||
const result = await handler.execute(config, makeBlock("blocked-dir"))
|
||||
|
||||
assert.equal(typeof result, "string")
|
||||
assert.ok((result as string).includes("clineignore"))
|
||||
assert.equal(taskState.consecutiveMistakeCount, 1)
|
||||
})
|
||||
|
||||
it("accumulates clineignore denials across repeated calls", async () => {
|
||||
const { config, taskState } = createConfig()
|
||||
const blockingValidator = new ToolValidator({ validateAccess: () => false } as any)
|
||||
const handler = new ListFilesToolHandler(blockingValidator)
|
||||
|
||||
await handler.execute(config, makeBlock("blocked-1"))
|
||||
await handler.execute(config, makeBlock("blocked-2"))
|
||||
await handler.execute(config, makeBlock("blocked-3"))
|
||||
assert.equal(taskState.consecutiveMistakeCount, 3)
|
||||
})
|
||||
})
|
||||
|
||||
// ─── SearchFilesToolHandler ─────────────────────────────────────────────────
|
||||
|
||||
describe("SearchFilesToolHandler.execute – error recovery", () => {
|
||||
let sandbox: sinon.SinonSandbox
|
||||
|
||||
beforeEach(async () => {
|
||||
sandbox = sinon.createSandbox()
|
||||
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "cline-search-test-"))
|
||||
sandbox.stub(pathUtils, "isLocatedInWorkspace").resolves(true)
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
sandbox.restore()
|
||||
await fs.rm(tmpDir, { recursive: true, force: true }).catch(() => {})
|
||||
})
|
||||
|
||||
function makeBlock(relPath?: string, regex?: string, filePattern?: string) {
|
||||
const params: Record<string, string> = {}
|
||||
if (relPath !== undefined) params.path = relPath
|
||||
if (regex !== undefined) params.regex = regex
|
||||
if (filePattern !== undefined) params.file_pattern = filePattern
|
||||
return {
|
||||
type: "tool_use" as const,
|
||||
name: ClineDefaultTool.SEARCH,
|
||||
params,
|
||||
partial: false,
|
||||
}
|
||||
}
|
||||
|
||||
it("returns a tool result (not a thrown exception) for a non-existent directory", async () => {
|
||||
const { config, taskState, validator } = createConfig()
|
||||
const handler = new SearchFilesToolHandler(validator)
|
||||
|
||||
const result = await handler.execute(config, makeBlock("no-such-dir", "pattern"))
|
||||
|
||||
// regexSearchFiles throws for non-existent directories, executeSearch catches it,
|
||||
// returns success=false, and the handler increments consecutiveMistakeCount.
|
||||
assert.equal(typeof result, "string")
|
||||
assert.equal(taskState.consecutiveMistakeCount, 1)
|
||||
})
|
||||
|
||||
it("increments consecutiveMistakeCount when path parameter is missing", async () => {
|
||||
const { config, taskState, validator } = createConfig()
|
||||
const handler = new SearchFilesToolHandler(validator)
|
||||
|
||||
const result = await handler.execute(config, makeBlock(undefined, "pattern"))
|
||||
|
||||
assert.equal(result, "missing")
|
||||
assert.equal(taskState.consecutiveMistakeCount, 1)
|
||||
})
|
||||
|
||||
it("increments consecutiveMistakeCount when regex parameter is missing", async () => {
|
||||
const { config, taskState, validator } = createConfig()
|
||||
const handler = new SearchFilesToolHandler(validator)
|
||||
|
||||
const result = await handler.execute(config, makeBlock("some-dir"))
|
||||
|
||||
assert.equal(result, "missing")
|
||||
assert.equal(taskState.consecutiveMistakeCount, 1)
|
||||
})
|
||||
|
||||
it("repeated missing-param failures accumulate", async () => {
|
||||
const { config, taskState, validator } = createConfig()
|
||||
const handler = new SearchFilesToolHandler(validator)
|
||||
|
||||
await handler.execute(config, makeBlock()) // missing path
|
||||
await handler.execute(config, makeBlock("dir")) // missing regex
|
||||
await handler.execute(config, makeBlock()) // missing path again
|
||||
assert.equal(taskState.consecutiveMistakeCount, 3)
|
||||
})
|
||||
|
||||
it("resets consecutiveMistakeCount to 0 after a successful search", async () => {
|
||||
const { config, taskState, validator } = createConfig()
|
||||
const handler = new SearchFilesToolHandler(validator)
|
||||
|
||||
// Accumulate failures
|
||||
await handler.execute(config, makeBlock())
|
||||
await handler.execute(config, makeBlock("dir"))
|
||||
assert.equal(taskState.consecutiveMistakeCount, 2)
|
||||
|
||||
// Stub regexSearchFiles to return a successful result
|
||||
const ripgrepModule = await import("@services/ripgrep")
|
||||
sandbox.stub(ripgrepModule, "regexSearchFiles").resolves("Found 0 results.\n\n")
|
||||
|
||||
// Search in tmpDir (exists, will find 0 results but should succeed)
|
||||
const result = await handler.execute(config, makeBlock(".", "nonexistent-pattern-xyz"))
|
||||
assert.equal(typeof result, "string")
|
||||
assert.equal(taskState.consecutiveMistakeCount, 0)
|
||||
})
|
||||
|
||||
it("catches a thrown exception from path resolution and returns a tool error", async () => {
|
||||
const { config, taskState, validator } = createConfig()
|
||||
const handler = new SearchFilesToolHandler(validator)
|
||||
|
||||
// Stub determineSearchPaths to throw (simulating a bad workspace config)
|
||||
sandbox.stub(handler as any, "determineSearchPaths").throws(new Error("invalid workspace hint"))
|
||||
|
||||
const result = await handler.execute(config, makeBlock("some-dir", "pattern"))
|
||||
|
||||
assert.equal(typeof result, "string")
|
||||
assert.ok((result as string).includes("Error"))
|
||||
assert.ok((result as string).includes("invalid workspace hint"))
|
||||
assert.equal(taskState.consecutiveMistakeCount, 1)
|
||||
})
|
||||
|
||||
it("accumulates failures when path resolution throws repeatedly", async () => {
|
||||
const { config, taskState, validator } = createConfig()
|
||||
const handler = new SearchFilesToolHandler(validator)
|
||||
|
||||
sandbox.stub(handler as any, "determineSearchPaths").throws(new Error("boom"))
|
||||
|
||||
await handler.execute(config, makeBlock("dir-1", "pat"))
|
||||
assert.equal(taskState.consecutiveMistakeCount, 1)
|
||||
|
||||
await handler.execute(config, makeBlock("dir-2", "pat"))
|
||||
assert.equal(taskState.consecutiveMistakeCount, 2)
|
||||
|
||||
await handler.execute(config, makeBlock("dir-3", "pat"))
|
||||
assert.equal(taskState.consecutiveMistakeCount, 3)
|
||||
})
|
||||
|
||||
it("increments consecutiveMistakeCount when regexSearchFiles throws", async () => {
|
||||
const { config, taskState, validator } = createConfig()
|
||||
const handler = new SearchFilesToolHandler(validator)
|
||||
|
||||
// Stub regexSearchFiles to throw
|
||||
const ripgrepModule = await import("@services/ripgrep")
|
||||
sandbox.stub(ripgrepModule, "regexSearchFiles").rejects(new Error("ripgrep crashed"))
|
||||
|
||||
const result = await handler.execute(config, makeBlock(".", "pattern"))
|
||||
|
||||
// The search failed, so consecutiveMistakeCount should increment
|
||||
assert.equal(typeof result, "string")
|
||||
assert.equal(taskState.consecutiveMistakeCount, 1)
|
||||
})
|
||||
|
||||
it("accumulates failures when regexSearchFiles throws repeatedly", async () => {
|
||||
const { config, taskState, validator } = createConfig()
|
||||
const handler = new SearchFilesToolHandler(validator)
|
||||
|
||||
const ripgrepModule = await import("@services/ripgrep")
|
||||
sandbox.stub(ripgrepModule, "regexSearchFiles").rejects(new Error("boom"))
|
||||
|
||||
await handler.execute(config, makeBlock("dir-1", "pat"))
|
||||
assert.equal(taskState.consecutiveMistakeCount, 1)
|
||||
|
||||
await handler.execute(config, makeBlock("dir-2", "pat"))
|
||||
assert.equal(taskState.consecutiveMistakeCount, 2)
|
||||
|
||||
await handler.execute(config, makeBlock("dir-3", "pat"))
|
||||
assert.equal(taskState.consecutiveMistakeCount, 3)
|
||||
})
|
||||
|
||||
it("resets consecutiveMistakeCount after regexSearchFiles failure is followed by success", async () => {
|
||||
const { config, taskState, validator } = createConfig()
|
||||
const handler = new SearchFilesToolHandler(validator)
|
||||
|
||||
const ripgrepModule = await import("@services/ripgrep")
|
||||
const stub = sandbox.stub(ripgrepModule, "regexSearchFiles")
|
||||
|
||||
// First two calls fail
|
||||
stub.onFirstCall().rejects(new Error("boom"))
|
||||
stub.onSecondCall().rejects(new Error("boom"))
|
||||
// Third call succeeds
|
||||
stub.onThirdCall().resolves("Found 0 results.\n\n")
|
||||
|
||||
await handler.execute(config, makeBlock("dir-1", "pat"))
|
||||
assert.equal(taskState.consecutiveMistakeCount, 1)
|
||||
|
||||
await handler.execute(config, makeBlock("dir-2", "pat"))
|
||||
assert.equal(taskState.consecutiveMistakeCount, 2)
|
||||
|
||||
await handler.execute(config, makeBlock("dir-3", "pat"))
|
||||
assert.equal(taskState.consecutiveMistakeCount, 0)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,189 @@
|
||||
import { strict as assert } from "node:assert"
|
||||
import fs from "node:fs/promises"
|
||||
import os from "node:os"
|
||||
import path from "node:path"
|
||||
import { ClineDefaultTool } from "@shared/tools"
|
||||
import * as pathUtils from "@utils/path"
|
||||
import { afterEach, beforeEach, describe, it } from "mocha"
|
||||
import sinon from "sinon"
|
||||
import { TaskState } from "../../../TaskState"
|
||||
import { ToolValidator } from "../../ToolValidator"
|
||||
import type { TaskConfig } from "../../types/TaskConfig"
|
||||
import { ReadFileToolHandler } from "../ReadFileToolHandler"
|
||||
|
||||
/**
|
||||
* End-to-end tests for ReadFileToolHandler.execute().
|
||||
*
|
||||
* These exercise the actual handler with a mock TaskConfig (following the
|
||||
* SubagentToolHandler.test.ts pattern), verifying that:
|
||||
*
|
||||
* 1. Reading a non-existent file returns a tool error (not a thrown exception)
|
||||
* 2. consecutiveMistakeCount increments on failure
|
||||
* 3. Repeated failures accumulate (the counter is NOT reset before the read)
|
||||
* 4. A successful read resets consecutiveMistakeCount to 0
|
||||
* 5. Missing path parameter increments the counter
|
||||
*/
|
||||
|
||||
let tmpDir: string
|
||||
|
||||
function createConfig() {
|
||||
const taskState = new TaskState()
|
||||
|
||||
const callbacks = {
|
||||
say: sinon.stub().resolves(undefined),
|
||||
ask: sinon.stub().resolves({ response: "yesButtonClicked" }),
|
||||
saveCheckpoint: sinon.stub().resolves(),
|
||||
sayAndCreateMissingParamError: sinon.stub().resolves("missing"),
|
||||
removeLastPartialMessageIfExistsWithType: sinon.stub().resolves(),
|
||||
shouldAutoApproveToolWithPath: sinon.stub().resolves(true),
|
||||
postStateToWebview: sinon.stub().resolves(),
|
||||
cancelTask: sinon.stub().resolves(),
|
||||
updateTaskHistory: sinon.stub().resolves([]),
|
||||
switchToActMode: sinon.stub().resolves(false),
|
||||
setActiveHookExecution: sinon.stub().resolves(),
|
||||
clearActiveHookExecution: sinon.stub().resolves(),
|
||||
getActiveHookExecution: sinon.stub().resolves(undefined),
|
||||
runUserPromptSubmitHook: sinon.stub().resolves({}),
|
||||
executeCommandTool: sinon.stub().resolves([false, "ok"]),
|
||||
cancelRunningCommandTool: sinon.stub().resolves(false),
|
||||
doesLatestTaskCompletionHaveNewChanges: sinon.stub().resolves(false),
|
||||
updateFCListFromToolResponse: sinon.stub().resolves(),
|
||||
shouldAutoApproveTool: sinon.stub().returns([true, true]),
|
||||
reinitExistingTaskFromId: sinon.stub().resolves(),
|
||||
applyLatestBrowserSettings: sinon.stub().resolves(undefined),
|
||||
}
|
||||
|
||||
const config = {
|
||||
taskId: "task-1",
|
||||
ulid: "ulid-1",
|
||||
cwd: tmpDir,
|
||||
mode: "act",
|
||||
strictPlanModeEnabled: false,
|
||||
yoloModeToggled: true,
|
||||
doubleCheckCompletionEnabled: false,
|
||||
vscodeTerminalExecutionMode: "backgroundExec",
|
||||
enableParallelToolCalling: true,
|
||||
isSubagentExecution: true, // skip UI calls and approval flow
|
||||
taskState,
|
||||
messageState: {},
|
||||
api: {
|
||||
getModel: () => ({ id: "test-model", info: { supportsImages: false } }),
|
||||
},
|
||||
autoApprovalSettings: {
|
||||
enableNotifications: false,
|
||||
actions: { executeSafeCommands: false, executeAllCommands: false },
|
||||
},
|
||||
autoApprover: {
|
||||
shouldAutoApproveTool: sinon.stub().returns([true, true]),
|
||||
},
|
||||
browserSettings: {},
|
||||
focusChainSettings: {},
|
||||
services: {
|
||||
stateManager: {
|
||||
getGlobalStateKey: () => undefined,
|
||||
getGlobalSettingsKey: (key: string) => {
|
||||
if (key === "mode") return "act"
|
||||
if (key === "hooksEnabled") return false
|
||||
return undefined
|
||||
},
|
||||
getApiConfiguration: () => ({
|
||||
planModeApiProvider: "openai",
|
||||
actModeApiProvider: "openai",
|
||||
}),
|
||||
},
|
||||
fileContextTracker: {
|
||||
trackFileContext: sinon.stub().resolves(),
|
||||
},
|
||||
mcpHub: {},
|
||||
browserSession: {},
|
||||
urlContentFetcher: {},
|
||||
diffViewProvider: {},
|
||||
clineIgnoreController: { validateAccess: () => true },
|
||||
commandPermissionController: {},
|
||||
contextManager: {},
|
||||
},
|
||||
callbacks,
|
||||
coordinator: { getHandler: sinon.stub() },
|
||||
} as unknown as TaskConfig
|
||||
|
||||
const validator = new ToolValidator({ validateAccess: () => true } as any)
|
||||
|
||||
return { config, callbacks, taskState, validator }
|
||||
}
|
||||
|
||||
function makeBlock(relPath?: string) {
|
||||
return {
|
||||
type: "tool_use" as const,
|
||||
name: ClineDefaultTool.FILE_READ,
|
||||
params: relPath !== undefined ? { path: relPath } : {},
|
||||
partial: false,
|
||||
}
|
||||
}
|
||||
|
||||
describe("ReadFileToolHandler.execute – file not found", () => {
|
||||
let sandbox: sinon.SinonSandbox
|
||||
|
||||
beforeEach(async () => {
|
||||
sandbox = sinon.createSandbox()
|
||||
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "cline-read-test-"))
|
||||
sandbox.stub(pathUtils, "isLocatedInWorkspace").resolves(true)
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
sandbox.restore()
|
||||
await fs.rm(tmpDir, { recursive: true, force: true }).catch(() => {})
|
||||
})
|
||||
|
||||
it("returns a tool error (not a thrown exception) for a non-existent file", async () => {
|
||||
const { config, taskState, validator } = createConfig()
|
||||
const handler = new ReadFileToolHandler(validator)
|
||||
|
||||
const result = await handler.execute(config, makeBlock("no-such-file.py"))
|
||||
|
||||
assert.equal(typeof result, "string")
|
||||
assert.ok((result as string).includes("File not found"))
|
||||
assert.equal(taskState.consecutiveMistakeCount, 1)
|
||||
})
|
||||
|
||||
it("increments consecutiveMistakeCount on each failure without resetting", async () => {
|
||||
const { config, taskState, validator } = createConfig()
|
||||
const handler = new ReadFileToolHandler(validator)
|
||||
|
||||
await handler.execute(config, makeBlock("ghost-1.py"))
|
||||
assert.equal(taskState.consecutiveMistakeCount, 1)
|
||||
|
||||
await handler.execute(config, makeBlock("ghost-2.py"))
|
||||
assert.equal(taskState.consecutiveMistakeCount, 2)
|
||||
|
||||
await handler.execute(config, makeBlock("ghost-3.py"))
|
||||
assert.equal(taskState.consecutiveMistakeCount, 3)
|
||||
})
|
||||
|
||||
it("resets consecutiveMistakeCount to 0 after a successful read", async () => {
|
||||
const { config, taskState, validator } = createConfig()
|
||||
const handler = new ReadFileToolHandler(validator)
|
||||
|
||||
// Accumulate two failures
|
||||
await handler.execute(config, makeBlock("ghost-1.py"))
|
||||
await handler.execute(config, makeBlock("ghost-2.py"))
|
||||
assert.equal(taskState.consecutiveMistakeCount, 2)
|
||||
|
||||
// Create a real file and read it
|
||||
const realFile = "real-file.txt"
|
||||
await fs.writeFile(path.join(tmpDir, realFile), "hello world")
|
||||
|
||||
const result = await handler.execute(config, makeBlock(realFile))
|
||||
assert.equal(result, "hello world")
|
||||
assert.equal(taskState.consecutiveMistakeCount, 0)
|
||||
})
|
||||
|
||||
it("increments consecutiveMistakeCount when path parameter is missing", async () => {
|
||||
const { config, taskState, validator } = createConfig()
|
||||
const handler = new ReadFileToolHandler(validator)
|
||||
|
||||
const result = await handler.execute(config, makeBlock())
|
||||
|
||||
assert.equal(result, "missing")
|
||||
assert.equal(taskState.consecutiveMistakeCount, 1)
|
||||
})
|
||||
})
|
||||
@@ -1,17 +1,12 @@
|
||||
import { ApiHandler } from "@core/api"
|
||||
import { execSync } from "child_process"
|
||||
import { showSystemNotification } from "@/integrations/notifications"
|
||||
import { showApprovalNotification } from "@/integrations/notifications"
|
||||
import { ClineApiReqCancelReason, ClineApiReqInfo } from "@/shared/ExtensionMessage"
|
||||
import { calculateApiCostAnthropic } from "@/utils/cost"
|
||||
import { MessageStateHandler } from "./message-state"
|
||||
|
||||
export const showNotificationForApproval = (message: string, notificationsEnabled: boolean) => {
|
||||
if (notificationsEnabled) {
|
||||
showSystemNotification({
|
||||
subtitle: "Approval Required",
|
||||
message,
|
||||
})
|
||||
}
|
||||
void showApprovalNotification({ message }, notificationsEnabled)
|
||||
}
|
||||
|
||||
type UpdateApiReqMsgParams = {
|
||||
|
||||
@@ -160,23 +160,31 @@ Anthropic is aware of this issue and is considering a fix: https://github.com/an
|
||||
|
||||
// We want the model to use our custom tool format instead of built-in tools.
|
||||
// Disabling built-in tools prevents tool-only responses and ensures text output.
|
||||
// This list must be kept in sync with the tools reported by `claude --output-format stream-json`.
|
||||
const claudeCodeTools = [
|
||||
"Task",
|
||||
"TaskOutput",
|
||||
"Bash",
|
||||
"Glob",
|
||||
"Grep",
|
||||
"LS",
|
||||
"exit_plan_mode",
|
||||
"Read",
|
||||
"Edit",
|
||||
"MultiEdit",
|
||||
"Write",
|
||||
"NotebookRead",
|
||||
"NotebookEdit",
|
||||
"WebFetch",
|
||||
"TodoRead",
|
||||
"TodoWrite",
|
||||
"WebSearch",
|
||||
"TaskStop",
|
||||
"AskUserQuestion",
|
||||
"Skill",
|
||||
"EnterPlanMode",
|
||||
"ExitPlanMode",
|
||||
"EnterWorktree",
|
||||
"ExitWorktree",
|
||||
"CronCreate",
|
||||
"CronDelete",
|
||||
"CronList",
|
||||
"ToolSearch",
|
||||
].join(",")
|
||||
|
||||
const CLAUDE_CODE_TIMEOUT = 600000 // 10 minutes
|
||||
|
||||
@@ -11,7 +11,10 @@ type InitMessage = {
|
||||
|
||||
type AssistantMessage = {
|
||||
type: "assistant"
|
||||
message: Anthropic.Messages.Message
|
||||
message: Anthropic.Messages.Message & {
|
||||
// Newer Claude Code CLI versions may include an error field on the message
|
||||
error?: string
|
||||
}
|
||||
session_id: string
|
||||
}
|
||||
|
||||
@@ -19,9 +22,36 @@ type ErrorMessage = {
|
||||
type: "error"
|
||||
}
|
||||
|
||||
// Older CLI versions emit rate_limit_event as a system subtype:
|
||||
// { type: "system", subtype: "rate_limit_event", message?: string, retryAfterSeconds?: number }
|
||||
type LegacyRateLimitEvent = {
|
||||
type: "system"
|
||||
subtype: "rate_limit_event"
|
||||
message?: string
|
||||
retryAfterSeconds?: number
|
||||
}
|
||||
|
||||
// Newer Claude Code CLI versions (2.1+) emit rate_limit_event as a top-level type.
|
||||
type RateLimitEvent = {
|
||||
type: "rate_limit_event"
|
||||
rate_limit_info?: Record<string, unknown>
|
||||
}
|
||||
|
||||
// User messages can appear in the stream when Claude Code executes tools
|
||||
// and returns tool results. These should be ignored by Cline since we manage
|
||||
// our own tool execution.
|
||||
type UserMessage = {
|
||||
type: "user"
|
||||
message: {
|
||||
role: "user"
|
||||
content: unknown[]
|
||||
}
|
||||
session_id: string
|
||||
}
|
||||
|
||||
type ResultMessage = {
|
||||
type: "result"
|
||||
subtype: "success"
|
||||
subtype: "success" | "error" | "error_max_turns"
|
||||
total_cost_usd: number
|
||||
is_error: boolean
|
||||
duration_ms: number
|
||||
@@ -31,4 +61,11 @@ type ResultMessage = {
|
||||
session_id: string
|
||||
}
|
||||
|
||||
export type ClaudeCodeMessage = InitMessage | AssistantMessage | ErrorMessage | ResultMessage
|
||||
export type ClaudeCodeMessage =
|
||||
| InitMessage
|
||||
| LegacyRateLimitEvent
|
||||
| AssistantMessage
|
||||
| ErrorMessage
|
||||
| ResultMessage
|
||||
| RateLimitEvent
|
||||
| UserMessage
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
import { expect } from "chai"
|
||||
import { afterEach, describe, it } from "mocha"
|
||||
import * as sinon from "sinon"
|
||||
import * as notificationsModule from ".."
|
||||
|
||||
function decodePowerShellEncodedCommand(encoded: string): string {
|
||||
return Buffer.from(encoded, "base64").toString("utf16le")
|
||||
}
|
||||
|
||||
function createResolvedExecaStub() {
|
||||
return sinon.stub().resolves({} as Record<string, never>)
|
||||
}
|
||||
|
||||
describe("notifications", () => {
|
||||
afterEach(() => {
|
||||
sinon.restore()
|
||||
notificationsModule.setNotificationExecaForTesting(null)
|
||||
notificationsModule.setNotificationPlatformForTesting(null)
|
||||
})
|
||||
|
||||
it("builds a Windows toast script using single-quoted literals and DOM text assignment", () => {
|
||||
const script = notificationsModule.buildWindowsToastNotificationScript({
|
||||
subtitle: "Approval Required",
|
||||
message: "$(Start-Process calc)",
|
||||
})
|
||||
|
||||
expect(script).to.contain("$message = '$(Start-Process calc)'")
|
||||
expect(script).to.contain("$textNodes.Item(1).InnerText = $message")
|
||||
expect(script).to.not.contain('<text id="2">$(Start-Process calc)</text>')
|
||||
expect(script).to.not.contain('$template = @"')
|
||||
})
|
||||
|
||||
it("escapes single quotes for PowerShell single-quoted strings", () => {
|
||||
expect(notificationsModule.escapePowerShellSingleQuotedString("don't")).to.equal("don''t")
|
||||
})
|
||||
|
||||
it("escapes single quotes in Windows subtitle and message assignments", () => {
|
||||
const script = notificationsModule.buildWindowsToastNotificationScript({
|
||||
subtitle: "don't ask twice",
|
||||
message: "it's fine",
|
||||
})
|
||||
|
||||
expect(script).to.contain("$subtitle = 'don''t ask twice'")
|
||||
expect(script).to.contain("$message = 'it''s fine'")
|
||||
})
|
||||
|
||||
it("escapes quotes and backslashes for macOS notifications", async () => {
|
||||
const execaStub = createResolvedExecaStub()
|
||||
notificationsModule.setNotificationExecaForTesting(
|
||||
execaStub as unknown as Parameters<typeof notificationsModule.setNotificationExecaForTesting>[0],
|
||||
)
|
||||
const platformStub = sinon.stub().returns("darwin")
|
||||
notificationsModule.setNotificationPlatformForTesting(
|
||||
platformStub as unknown as Parameters<typeof notificationsModule.setNotificationPlatformForTesting>[0],
|
||||
)
|
||||
|
||||
await notificationsModule.showSystemNotification({
|
||||
title: 'Cline "Agent"',
|
||||
subtitle: "Path C:\\temp",
|
||||
message: 'He said "hello"',
|
||||
})
|
||||
|
||||
sinon.assert.calledOnce(execaStub)
|
||||
expect(execaStub.firstCall.args[0]).to.equal("osascript")
|
||||
const script = (execaStub.firstCall.args[1] as string[])[1]
|
||||
expect(script).to.contain('display notification "He said \\"hello\\""')
|
||||
expect(script).to.contain('with title "Cline \\"Agent\\""')
|
||||
expect(script).to.contain('subtitle "Path C:\\\\temp"')
|
||||
})
|
||||
|
||||
it("passes title and combined subtitle/message to notify-send on Linux", async () => {
|
||||
const execaStub = createResolvedExecaStub()
|
||||
notificationsModule.setNotificationExecaForTesting(
|
||||
execaStub as unknown as Parameters<typeof notificationsModule.setNotificationExecaForTesting>[0],
|
||||
)
|
||||
const platformStub = sinon.stub().returns("linux")
|
||||
notificationsModule.setNotificationPlatformForTesting(
|
||||
platformStub as unknown as Parameters<typeof notificationsModule.setNotificationPlatformForTesting>[0],
|
||||
)
|
||||
|
||||
await notificationsModule.showSystemNotification({ title: "Cline", subtitle: "Approval Required", message: "test" })
|
||||
|
||||
sinon.assert.calledOnce(execaStub)
|
||||
expect(execaStub.firstCall.args[0]).to.equal("notify-send")
|
||||
expect(execaStub.firstCall.args[1]).to.deep.equal(["Cline", "Approval Required\ntest"])
|
||||
})
|
||||
|
||||
it("creates explicit approval marker only when required", () => {
|
||||
expect(
|
||||
notificationsModule.createApprovalNotificationMessage({ message: "npm install", requiresExplicitApproval: true }),
|
||||
).to.equal("npm install (explicit approval required)")
|
||||
expect(
|
||||
notificationsModule.createApprovalNotificationMessage({ message: "npm install", requiresExplicitApproval: false }),
|
||||
).to.equal("npm install")
|
||||
})
|
||||
|
||||
it("normalizes whitespace in approval notification messages", () => {
|
||||
expect(
|
||||
notificationsModule.createApprovalNotificationMessage({
|
||||
message: "npm\n\tinstall ./pkg",
|
||||
requiresExplicitApproval: false,
|
||||
}),
|
||||
).to.equal("npm install ./pkg")
|
||||
})
|
||||
|
||||
it("routes approval notifications through platform dispatch only when enabled", async () => {
|
||||
const execaStub = createResolvedExecaStub()
|
||||
notificationsModule.setNotificationExecaForTesting(
|
||||
execaStub as unknown as Parameters<typeof notificationsModule.setNotificationExecaForTesting>[0],
|
||||
)
|
||||
const platformStub = sinon.stub().returns("linux")
|
||||
notificationsModule.setNotificationPlatformForTesting(
|
||||
platformStub as unknown as Parameters<typeof notificationsModule.setNotificationPlatformForTesting>[0],
|
||||
)
|
||||
|
||||
await notificationsModule.showApprovalNotification({ message: "npm install", requiresExplicitApproval: true }, false)
|
||||
sinon.assert.notCalled(execaStub)
|
||||
|
||||
await notificationsModule.showApprovalNotification({ message: "npm install", requiresExplicitApproval: true }, true)
|
||||
sinon.assert.calledOnce(execaStub)
|
||||
expect(execaStub.firstCall.args[0]).to.equal("notify-send")
|
||||
expect(execaStub.firstCall.args[1]).to.deep.equal([
|
||||
"Cline",
|
||||
"Approval Required\nnpm install (explicit approval required)",
|
||||
])
|
||||
})
|
||||
|
||||
it("abbreviates long approval notification messages while preserving the explicit approval suffix", () => {
|
||||
const message = `${"a".repeat(200)}`
|
||||
const notificationMessage = notificationsModule.createApprovalNotificationMessage({
|
||||
message,
|
||||
requiresExplicitApproval: true,
|
||||
})
|
||||
|
||||
expect(notificationMessage.length).to.be.at.most(140)
|
||||
expect(notificationMessage.endsWith(" (explicit approval required)")).to.equal(true)
|
||||
expect(notificationMessage).to.contain("…")
|
||||
})
|
||||
|
||||
it("encodes Windows PowerShell notifications before dispatch", async () => {
|
||||
const execaStub = createResolvedExecaStub()
|
||||
notificationsModule.setNotificationExecaForTesting(
|
||||
execaStub as unknown as Parameters<typeof notificationsModule.setNotificationExecaForTesting>[0],
|
||||
)
|
||||
const platformStub = sinon.stub().returns("win32")
|
||||
notificationsModule.setNotificationPlatformForTesting(
|
||||
platformStub as unknown as Parameters<typeof notificationsModule.setNotificationPlatformForTesting>[0],
|
||||
)
|
||||
|
||||
await notificationsModule.showSystemNotification({
|
||||
subtitle: "Approval Required",
|
||||
message: 'npm`install $(Start-Process calc) "quoted"',
|
||||
})
|
||||
|
||||
sinon.assert.calledOnce(execaStub)
|
||||
sinon.assert.calledOnce(platformStub)
|
||||
expect(execaStub.firstCall.args[0]).to.equal("powershell")
|
||||
const args = execaStub.firstCall.args[1] as string[]
|
||||
expect(args.slice(0, 3)).to.deep.equal(["-NoProfile", "-NonInteractive", "-EncodedCommand"])
|
||||
expect(args[3]).to.be.a("string")
|
||||
|
||||
const decodedScript = decodePowerShellEncodedCommand(args[3])
|
||||
expect(decodedScript).to.contain("$message = 'npm`install $(Start-Process calc) \"quoted\"'")
|
||||
expect(decodedScript).to.contain("$textNodes.Item(1).InnerText = $message")
|
||||
expect(decodedScript).to.not.contain("-Command")
|
||||
})
|
||||
|
||||
it("encodes PowerShell commands as UTF-16LE base64", () => {
|
||||
const encoded = notificationsModule.encodePowerShellCommand("Write-Host 'hello'")
|
||||
expect(decodePowerShellEncodedCommand(encoded)).to.equal("Write-Host 'hello'")
|
||||
})
|
||||
})
|
||||
@@ -8,44 +8,101 @@ interface NotificationOptions {
|
||||
message: string
|
||||
}
|
||||
|
||||
export interface ApprovalNotificationOptions {
|
||||
message: string
|
||||
requiresExplicitApproval?: boolean
|
||||
}
|
||||
|
||||
const EXPLICIT_APPROVAL_NOTIFICATION_SUFFIX = " (explicit approval required)"
|
||||
const MAX_APPROVAL_NOTIFICATION_LENGTH = 140
|
||||
|
||||
type ExecaFn = typeof execa
|
||||
let execaImpl: ExecaFn = execa
|
||||
let platformImpl: typeof platform = platform
|
||||
|
||||
export function setNotificationExecaForTesting(mock: ExecaFn | null): void {
|
||||
execaImpl = mock ?? execa
|
||||
}
|
||||
|
||||
export function setNotificationPlatformForTesting(mock: typeof platform | null): void {
|
||||
platformImpl = mock ?? platform
|
||||
}
|
||||
|
||||
function escapeAppleScriptString(value: string): string {
|
||||
return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')
|
||||
}
|
||||
|
||||
export function escapePowerShellSingleQuotedString(value: string): string {
|
||||
return value.replace(/'/g, "''")
|
||||
}
|
||||
|
||||
export function encodePowerShellCommand(command: string): string {
|
||||
return Buffer.from(command, "utf16le").toString("base64")
|
||||
}
|
||||
|
||||
function abbreviateNotificationMessage(message: string, maxLength: number): string {
|
||||
const normalizedMessage = message.replace(/\s+/g, " ").trim()
|
||||
if (normalizedMessage.length <= maxLength) {
|
||||
return normalizedMessage
|
||||
}
|
||||
|
||||
return `${normalizedMessage.slice(0, Math.max(0, maxLength - 1)).trimEnd()}…`
|
||||
}
|
||||
|
||||
export function createApprovalNotificationMessage(options: ApprovalNotificationOptions): string {
|
||||
const suffix = options.requiresExplicitApproval ? EXPLICIT_APPROVAL_NOTIFICATION_SUFFIX : ""
|
||||
const availableMessageLength = Math.max(0, MAX_APPROVAL_NOTIFICATION_LENGTH - suffix.length)
|
||||
const abbreviatedMessage = abbreviateNotificationMessage(options.message, availableMessageLength)
|
||||
|
||||
return `${abbreviatedMessage}${suffix}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Note: `title` is not rendered on Windows because the ToastText02 template only exposes
|
||||
* two text slots, which we use for subtitle (id=1) and message (id=2).
|
||||
*/
|
||||
export function buildWindowsToastNotificationScript(options: NotificationOptions): string {
|
||||
const { subtitle = "", message } = options
|
||||
const safeSubtitle = escapePowerShellSingleQuotedString(subtitle)
|
||||
const safeMessage = escapePowerShellSingleQuotedString(message)
|
||||
|
||||
return `
|
||||
[Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] | Out-Null
|
||||
[Windows.Data.Xml.Dom.XmlDocument, Windows.Data.Xml.Dom.XmlDocument, ContentType = WindowsRuntime] | Out-Null
|
||||
|
||||
$subtitle = '${safeSubtitle}'
|
||||
$message = '${safeMessage}'
|
||||
|
||||
$xml = New-Object Windows.Data.Xml.Dom.XmlDocument
|
||||
$xml.LoadXml('<toast><visual><binding template="ToastText02"><text id="1"></text><text id="2"></text></binding></visual></toast>')
|
||||
|
||||
$textNodes = $xml.GetElementsByTagName('text')
|
||||
$textNodes.Item(0).InnerText = $subtitle
|
||||
$textNodes.Item(1).InnerText = $message
|
||||
|
||||
$toast = [Windows.UI.Notifications.ToastNotification]::new($xml)
|
||||
[Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier('Cline').Show($toast)
|
||||
`
|
||||
}
|
||||
|
||||
async function showMacOSNotification(options: NotificationOptions): Promise<void> {
|
||||
const { title, subtitle = "", message } = options
|
||||
|
||||
const script = `display notification "${message}" with title "${title}" subtitle "${subtitle}" sound name "Tink"`
|
||||
const script = `display notification "${escapeAppleScriptString(message)}" with title "${escapeAppleScriptString(title || "")}" subtitle "${escapeAppleScriptString(subtitle)}" sound name "Tink"`
|
||||
|
||||
try {
|
||||
await execa("osascript", ["-e", script])
|
||||
await execaImpl("osascript", ["-e", script])
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to show macOS notification: ${error}`)
|
||||
}
|
||||
}
|
||||
|
||||
async function showWindowsNotification(options: NotificationOptions): Promise<void> {
|
||||
const { subtitle, message } = options
|
||||
|
||||
const script = `
|
||||
[Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] | Out-Null
|
||||
[Windows.Data.Xml.Dom.XmlDocument, Windows.Data.Xml.Dom.XmlDocument, ContentType = WindowsRuntime] | Out-Null
|
||||
|
||||
$template = @"
|
||||
<toast>
|
||||
<visual>
|
||||
<binding template="ToastText02">
|
||||
<text id="1">${subtitle}</text>
|
||||
<text id="2">${message}</text>
|
||||
</binding>
|
||||
</visual>
|
||||
</toast>
|
||||
"@
|
||||
|
||||
$xml = New-Object Windows.Data.Xml.Dom.XmlDocument
|
||||
$xml.LoadXml($template)
|
||||
$toast = [Windows.UI.Notifications.ToastNotification]::new($xml)
|
||||
[Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier("Cline").Show($toast)
|
||||
`
|
||||
const script = buildWindowsToastNotificationScript(options)
|
||||
const encodedScript = encodePowerShellCommand(script)
|
||||
|
||||
try {
|
||||
await execa("powershell", ["-Command", script])
|
||||
await execaImpl("powershell", ["-NoProfile", "-NonInteractive", "-EncodedCommand", encodedScript])
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to show Windows notification: ${error}`)
|
||||
}
|
||||
@@ -58,7 +115,7 @@ async function showLinuxNotification(options: NotificationOptions): Promise<void
|
||||
const fullMessage = subtitle ? `${subtitle}\n${message}` : message
|
||||
|
||||
try {
|
||||
await execa("notify-send", [title, fullMessage])
|
||||
await execaImpl("notify-send", [title, fullMessage])
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to show Linux notification: ${error}`)
|
||||
}
|
||||
@@ -72,22 +129,21 @@ export async function showSystemNotification(options: NotificationOptions): Prom
|
||||
throw new Error("Message is required")
|
||||
}
|
||||
|
||||
const escapedOptions = {
|
||||
const normalizedOptions = {
|
||||
...options,
|
||||
title: title.replace(/"/g, '\\"'),
|
||||
message: message.replace(/"/g, '\\"'),
|
||||
subtitle: options.subtitle?.replace(/"/g, '\\"') || "",
|
||||
title,
|
||||
subtitle: options.subtitle || "",
|
||||
}
|
||||
|
||||
switch (platform()) {
|
||||
switch (platformImpl()) {
|
||||
case "darwin":
|
||||
await showMacOSNotification(escapedOptions)
|
||||
await showMacOSNotification(normalizedOptions)
|
||||
break
|
||||
case "win32":
|
||||
await showWindowsNotification(escapedOptions)
|
||||
await showWindowsNotification(normalizedOptions)
|
||||
break
|
||||
case "linux":
|
||||
await showLinuxNotification(escapedOptions)
|
||||
await showLinuxNotification(normalizedOptions)
|
||||
break
|
||||
default:
|
||||
throw new Error("Unsupported platform")
|
||||
@@ -96,3 +152,17 @@ export async function showSystemNotification(options: NotificationOptions): Prom
|
||||
Logger.error("Could not show system notification", error)
|
||||
}
|
||||
}
|
||||
|
||||
export async function showApprovalNotification(
|
||||
options: ApprovalNotificationOptions,
|
||||
notificationsEnabled: boolean,
|
||||
): Promise<void> {
|
||||
if (!notificationsEnabled) {
|
||||
return
|
||||
}
|
||||
|
||||
await showSystemNotification({
|
||||
subtitle: "Approval Required",
|
||||
message: createApprovalNotificationMessage(options),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -5,6 +5,10 @@ import * as path from "path"
|
||||
import "should"
|
||||
import { listFiles } from "../list-files"
|
||||
|
||||
function normalizeForComparison(filePath: string): string {
|
||||
return filePath.replaceAll("\\", "/")
|
||||
}
|
||||
|
||||
describe("listFiles", () => {
|
||||
const tmpDir = path.join(os.tmpdir(), `cline-list-files-test-${Math.random().toString(36).slice(2)}`)
|
||||
|
||||
@@ -30,6 +34,6 @@ describe("listFiles", () => {
|
||||
|
||||
const [files] = await listFiles(tmpDir, false, 200)
|
||||
|
||||
files.should.containEql(nestedFile)
|
||||
files.map(normalizeForComparison).should.containEql(normalizeForComparison(nestedFile))
|
||||
})
|
||||
})
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user