mirror of
https://github.com/cline/cline.git
synced 2026-09-11 05:47:07 +08:00
Compare commits
105
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a419c53e09 | ||
|
|
1025ef4799 | ||
|
|
55f06e7807 | ||
|
|
c08144ae80 | ||
|
|
91f6741dc5 | ||
|
|
e3730afc09 | ||
|
|
2984d457b5 | ||
|
|
c0c30d5421 | ||
|
|
26bd7aa16c | ||
|
|
ae4ec70c49 | ||
|
|
d87a080ada | ||
|
|
5f78a331bb | ||
|
|
122e7737df | ||
|
|
81883870d9 | ||
|
|
faf93632f3 | ||
|
|
6ed6733c63 | ||
|
|
c9e7ea3530 | ||
|
|
6c1e37d1d5 | ||
|
|
cf9858eb85 | ||
|
|
1d300e306e | ||
|
|
e15c58db4f | ||
|
|
3cf3f376b8 | ||
|
|
c0ce9e56b1 | ||
|
|
08850c3b53 | ||
|
|
404f774a06 | ||
|
|
a36915bf8a | ||
|
|
18e78fce8d | ||
|
|
9f0776f8ad | ||
|
|
e19f66a7e1 | ||
|
|
20649c731f | ||
|
|
d8787d9023 | ||
|
|
3b517db0c6 | ||
|
|
99ce395893 | ||
|
|
2780c95fd7 | ||
|
|
195456f364 | ||
|
|
32823f315e | ||
|
|
e8e2935d2a | ||
|
|
2104c4cb9a | ||
|
|
0bfbfb944d | ||
|
|
88aabd7517 | ||
|
|
65d117fc41 | ||
|
|
48ef35de13 | ||
|
|
3832a56cc3 | ||
|
|
5167b3f0ce | ||
|
|
1da1c358ae | ||
|
|
db12a9f43d | ||
|
|
3e17baed73 | ||
|
|
b769c8be4c | ||
|
|
78cb5532c7 | ||
|
|
76bf238311 | ||
|
|
c1dc2bc91f | ||
|
|
7ee901d6f7 | ||
|
|
5c7d3f2a2a | ||
|
|
e8e5e4de86 | ||
|
|
414bf89dbb | ||
|
|
84d0e1f951 | ||
|
|
97f3349f82 | ||
|
|
1a974bd176 | ||
|
|
8921628cb1 | ||
|
|
afd24d1b3c | ||
|
|
35cbe11325 | ||
|
|
3f67f8f7b4 | ||
|
|
a1b597069a | ||
|
|
219d1bc048 | ||
|
|
972608ad7f | ||
|
|
741ce592d6 | ||
|
|
6b92a6ad2a | ||
|
|
a3f8515d02 | ||
|
|
796ef7c70e | ||
|
|
2abee48794 | ||
|
|
eb92a6dba1 | ||
|
|
7d119351b1 | ||
|
|
de987a5246 | ||
|
|
90050426df | ||
|
|
205c5676ff | ||
|
|
1c13edd395 | ||
|
|
a2a1936709 | ||
|
|
35ce6a3f26 | ||
|
|
2c4aeae4f3 | ||
|
|
0c027d2731 | ||
|
|
6cc93c124e | ||
|
|
7e5b8be28c | ||
|
|
764e901693 | ||
|
|
2cabb2ddf6 | ||
|
|
c32789f697 | ||
|
|
349a8da750 | ||
|
|
f09dab7a0b | ||
|
|
3a3ea6ee96 | ||
|
|
1c1ea0bd53 | ||
|
|
70303d8541 | ||
|
|
8ba15dfca6 | ||
|
|
6ab6a1eabc | ||
|
|
2ad4146de1 | ||
|
|
cfc2250717 | ||
|
|
730bac7f59 | ||
|
|
797ea1f607 | ||
|
|
9d59de4a4c | ||
|
|
ae67ca7a13 | ||
|
|
7e2583f40c | ||
|
|
ecca88bb98 | ||
|
|
4bb93ee5b9 | ||
|
|
4f2d7398ed | ||
|
|
bc184f346d | ||
|
|
96aea0d34b | ||
|
|
676b446d47 |
@@ -0,0 +1,128 @@
|
||||
# Debug Harness
|
||||
|
||||
HTTP-controlled debugger for the VSCode extension at `src/dev/debug-harness/server.ts`.
|
||||
|
||||
## Quick start
|
||||
|
||||
```bash
|
||||
# Build extension first if needed (protos + esbuild):
|
||||
npm run protos && IS_DEV=true node esbuild.mjs
|
||||
|
||||
# Launch (skip-build if already built):
|
||||
npx tsx src/dev/debug-harness/server.ts --skip-build --auto-launch
|
||||
|
||||
# In another terminal:
|
||||
curl localhost:19229/api -d '{"method":"status"}'
|
||||
```
|
||||
|
||||
## Data Isolation
|
||||
|
||||
The debugee runs with `CLINE_DIR=~/.cline2` by default, separate from your real `~/.cline`.
|
||||
This prevents the debugee's logout from logging out the debugger, and vice versa.
|
||||
Override with `--cline-dir /tmp/test-dir`. Check with `status()` → `clineDir`.
|
||||
|
||||
## Browser Capture & OAuth
|
||||
|
||||
The debugee runs with `CLINE_CAPTURE_BROWSER=1`, which intercepts `openExternal()` in
|
||||
`src/utils/env.ts`. URLs are captured instead of opening a real browser:
|
||||
|
||||
- Logged to `$CLINE_DIR/data/debug-captured-urls.jsonl`
|
||||
- POSTed in real-time to `/captured-url` on the harness server
|
||||
- Queryable via `oauth.captured_urls`
|
||||
|
||||
### OAuth API
|
||||
|
||||
- **`oauth.captured_urls`** `{clear?}` — URLs the debugee tried to open
|
||||
- **`oauth.read_stored_token`** — Check auth token presence in secrets.json
|
||||
- **`oauth.simulate_callback`** `{path, code?, state?, provider?, token?}` — Build vscode:// callback URI
|
||||
- **`oauth.read_captured_urls_file`** — Read on-disk JSONL of captured URLs
|
||||
|
||||
### OAuth testing flow
|
||||
|
||||
For **Cline OAuth** (SDK local callback): The SDK starts a local HTTP server, the auth URL
|
||||
is captured. To complete: open the captured URL in a real browser (it redirects back to the
|
||||
SDK's callback server), OR extract the callback port and `curl http://127.0.0.1:PORT/callback?code=...`.
|
||||
|
||||
For **MCP/Provider OAuth** (vscode:// URI): The redirect goes to a vscode:// URI.
|
||||
`oauth.simulate_callback` only *builds* the URI — it does not deliver it, and the ESM
|
||||
extension host can't `require()` the handler. To actually deliver the callback, call the
|
||||
debug-only hook via `ext.evaluate` (with `awaitPromise: true`):
|
||||
`globalThis.__clineHandleUri("vscode://saoudrizwan.claude-dev/...?code=...&state=...")`.
|
||||
It runs the same `SharedUriHandler.handleUri` as VSCode's real URI handler and exists only
|
||||
when `CLINE_CAPTURE_BROWSER` is set (the harness always sets it; never ships in prod).
|
||||
For end-to-end MCP OAuth, get a real `code` from the local MCP OAuth test server
|
||||
(`npm run dev:mcp-oauth-test-server`).
|
||||
|
||||
## Navigating Views — Use Commands, Not Clicks
|
||||
|
||||
Don't try to find/click small sidebar icons. Use VSCode commands via command palette.
|
||||
Registered in `src/registry.ts`:
|
||||
|
||||
| Command | View |
|
||||
|---------|------|
|
||||
| `cline.accountButtonClicked` | Account / sign-in |
|
||||
| `cline.historyButtonClicked` | Task history |
|
||||
| `cline.settingsButtonClicked` | Settings |
|
||||
| `cline.mcpButtonClicked` | MCP servers |
|
||||
| `cline.plusButtonClicked` | New task (chat) |
|
||||
| `cline.worktreesButtonClicked` | Worktrees |
|
||||
|
||||
```bash
|
||||
curl localhost:19229/api -d '{"method":"ui.command_palette","params":{"command":"cline.accountButtonClicked"}}'
|
||||
```
|
||||
|
||||
## Key commands
|
||||
|
||||
All via `POST localhost:19229/api` with `{"method":"...", "params":{...}}`:
|
||||
|
||||
- **`launch`** / **`shutdown`** — lifecycle
|
||||
- **`ui.screenshot`** — screenshot to `/tmp/cline-debug/`; returns `{path}` — **use `read_file` on the path to examine, do NOT `open` the file** (Preview.app covers the VSCode window)
|
||||
- **`ui.open_sidebar`** — open the Cline sidebar
|
||||
- **`ext.set_breakpoint`** `{file, line, condition?}` — breakpoint by source file (sourcemap-resolved)
|
||||
- **`ext.evaluate`** `{expression, callFrameId?}` — eval in extension host
|
||||
- **`ext.resume`** / **`ext.step_over`** / **`ext.step_into`** — stepping
|
||||
- **`ext.call_stack`** — inspect when paused
|
||||
- **`web.evaluate`** `{expression}` — eval in webview
|
||||
- **`web.post_message`** `{message}` — send postMessage to extension host via exposed vsCodeApi
|
||||
- **`wait_for_pause`** `{timeout?}` — block until breakpoint hit
|
||||
- **`ui.locator`** `{role?, testId?, text?, frame?}` — Playwright locator (auto-retries on stale sidebar frame)
|
||||
- **`ui.react_input`** `{text, selector?, clear?, submit?}` — set React textarea value via `execCommand('insertText')`; works reliably across multiple tasks
|
||||
- **`ui.send_message`** `{text, images?, files?, responseType?}` — send chat message bypassing the textarea entirely (via gRPC postMessage)
|
||||
- **`ui.command_palette`** `{command}` — run VSCode command
|
||||
|
||||
## Typical Session
|
||||
|
||||
```bash
|
||||
# 1. Launch
|
||||
curl localhost:19229/api -d '{"method":"launch","params":{"skipBuild":true}}'
|
||||
|
||||
# 2. Open sidebar + dismiss overlays (ALWAYS do this first)
|
||||
curl localhost:19229/api -d '{"method":"ui.open_sidebar"}'
|
||||
curl localhost:19229/api -d '{"method":"web.evaluate","params":{"expression":"document.querySelectorAll(\".sr-only\").forEach(el => el.parentElement?.click())"}}'
|
||||
|
||||
# 3. Navigate to view
|
||||
curl localhost:19229/api -d '{"method":"ui.command_palette","params":{"command":"cline.accountButtonClicked"}}'
|
||||
|
||||
# 4. Check captured OAuth URLs if testing auth
|
||||
curl localhost:19229/api -d '{"method":"oauth.captured_urls"}'
|
||||
|
||||
# 5. Verify
|
||||
curl localhost:19229/api -d '{"method":"ui.screenshot"}'
|
||||
```
|
||||
|
||||
## Caveats
|
||||
|
||||
- **⚠️ Dismiss promotional overlays FIRST**: On fresh launches, full-screen promo overlays block the sidebar. **Dismiss immediately after `ui.open_sidebar`**, before any other interaction or screenshot. May need to run twice:
|
||||
```bash
|
||||
curl localhost:19229/api -d '{"method": "ui.open_sidebar"}'
|
||||
curl localhost:19229/api -d '{"method": "web.evaluate", "params": {"expression": "document.querySelectorAll(\".sr-only\").forEach(el => el.parentElement?.click())"}}'
|
||||
```
|
||||
- **Screenshots — don't open the file**: `ui.screenshot` and `ui.sidebar_screenshot` save PNGs to `/tmp/cline-debug/` and return the `{path}`. Use `read_file` on that path to examine screenshots. Running `open <path>` launches Preview.app on macOS which covers the VSCode window.
|
||||
- **Scripts count = 0 after launch**: CDP connects after extension host starts, so scripts parsed during startup aren't tracked. Breakpoints still work via sourcemap resolution.
|
||||
- **Port 9230**: Extension host inspector. If another VSCode instance uses this port, the harness will fail to connect. Kill other debug instances first.
|
||||
- **macOS only** for now (Playwright Electron launch behavior).
|
||||
- **Webview CDP**: `connect_webview` may fail depending on Electron version. `web.evaluate` still works via Playwright's `frame.evaluate()` fallback.
|
||||
- **Sourcemap paths**: esbuild outputs relative paths like `../src/extension.ts` in the sourcemap. The resolver handles this, but if a file isn't found, use `ext.source_files` to see exact paths.
|
||||
- **OAuth with fake codes**: Browser capture intercepts the URL but doesn't provide a valid auth code. For real OAuth testing, open the captured URL in a browser. For unit testing, mock the token exchange.
|
||||
|
||||
See `src/dev/debug-harness/README.md` for full API reference.
|
||||
+89
-87
@@ -13,11 +13,55 @@ This file is the secret sauce for working effectively in this codebase. It captu
|
||||
**What NOT to add:** Stuff you can figure out from reading a few files, obvious patterns, or standard practices. This file should be high-signal, not comprehensive.
|
||||
|
||||
## Miscellaneous
|
||||
- Avoid provider-specific string matching / hardcoded provider branches when fixing provider/config plumbing. Prefer provider metadata, shared catalog/defaults, explicit protocol/client capabilities, or centralized normalization utilities that apply by data shape rather than `providerId === "..."`. If a provider exception seems necessary, stop and explain why instead of adding ad-hoc string matching.
|
||||
- This is a VS Code extension—check `package.json` for available scripts before trying to verify builds (e.g., `npm run compile`, not `npm run build`).
|
||||
- When creating PRs, contributors should not create changelog-entry files. Maintainers handle release versioning and changelog curation during the release process.
|
||||
- When adding new feature flags, see this PR as a reference https://github.com/cline/cline/pull/7566
|
||||
- Additional instructions about making requests: @.clinerules/network.md
|
||||
|
||||
## Searching the Codebase — Avoiding Build Output
|
||||
|
||||
Several directories contain build output or generated code that produces
|
||||
noisy or unusable results with `search_files` / `grep`:
|
||||
|
||||
| Directory | What it is | Why it's a problem |
|
||||
|-----------|-----------|-------------------|
|
||||
| `out/` | esbuild bundle output | Mirrors `src/` structure as minified JS — every search gets duplicate hits on single-line files |
|
||||
| `dist/` | Packaged extension | Entire extension bundled into one minified `extension.js` (~1 long line) |
|
||||
| `dist-standalone/` | Standalone build output | Same minification issue |
|
||||
| `src/generated/` | Generated protobuf code | Auto-generated from `proto/`; not the source of truth |
|
||||
| `src/shared/proto/` | Generated proto type defs | Auto-generated from `proto/`; not the source of truth |
|
||||
| `node_modules/` | Dependencies | Huge, not project source |
|
||||
|
||||
### How to skip build output
|
||||
|
||||
**`search_files`** — Point at `src/` (not the project root) and use `file_pattern`:
|
||||
```
|
||||
search_files(path="src/core", regex="myFunction", file_pattern="*.ts")
|
||||
```
|
||||
The `file_pattern` parameter is the most effective filter — e.g. `"*.ts"`,
|
||||
`"*.tsx"`, `"*.proto"`.
|
||||
|
||||
**`grep` directly** — Exclude build dirs and restrict to source extensions:
|
||||
```bash
|
||||
grep -rn "myFunction" src/ --include="*.ts" --exclude-dir={out,dist,node_modules,generated}
|
||||
```
|
||||
|
||||
### When you must search minified files
|
||||
|
||||
Sometimes you need to verify what got bundled (e.g., checking if a change
|
||||
made it into the build). Minified files are typically one long line, so
|
||||
normal `grep` shows the entire file as context. Use these approaches:
|
||||
|
||||
- **`grep -oP`** to extract just the match with limited surrounding context:
|
||||
```bash
|
||||
grep -oP '.{0,40}myFunction.{0,40}' dist/extension.js
|
||||
```
|
||||
- **`read_file`** on files in `out/src/` — these have source maps and are
|
||||
more readable than `dist/extension.js` (which is the fully bundled output).
|
||||
- **Source maps** — `out/src/*.js.map` and `dist/extension.js.map` can be
|
||||
used to trace minified output back to original source locations.
|
||||
|
||||
## gRPC/Protobuf Communication
|
||||
The extension and webview communicate via gRPC-like protocol over VS Code message passing.
|
||||
|
||||
@@ -48,93 +92,6 @@ The extension and webview communicate via gRPC-like protocol over VS Code messag
|
||||
- `src/core/controller/task/explainChanges.ts` - Handler implementation
|
||||
- `webview-ui/src/components/chat/ChatRow.tsx` - UI rendering
|
||||
|
||||
## Adding a New API Provider
|
||||
When adding a new provider (e.g., "openai-codex"), you must update the proto conversion layer in THREE places or the provider will silently reset to Anthropic:
|
||||
|
||||
1. `proto/cline/models.proto` - Add to the `ApiProvider` enum (e.g., `OPENAI_CODEX = 40;`)
|
||||
2. `convertApiProviderToProto()` in `src/shared/proto-conversions/models/api-configuration-conversion.ts` - Add case mapping string to proto enum
|
||||
3. `convertProtoToApiProvider()` in the same file - Add case mapping proto enum back to string
|
||||
|
||||
**Why this matters:** Without these, the provider string hits the `default` case and returns `ANTHROPIC`. The webview, provider list, and handler all work fine, but the state silently resets when it round-trips through proto serialization. No error is thrown.
|
||||
|
||||
**Other files to update when adding a provider:**
|
||||
- `src/shared/api.ts` - Add to `ApiProvider` union type, define models
|
||||
- `src/shared/providers/providers.json` - Add to provider list for dropdown
|
||||
- `src/core/api/index.ts` - Register handler in `createHandlerForProvider()`
|
||||
- `webview-ui/src/components/settings/utils/providerUtils.ts` - Add cases in `getModelsForProvider()` and `normalizeApiConfiguration()`
|
||||
- `webview-ui/src/utils/validate.ts` - Add validation case
|
||||
- `webview-ui/src/components/settings/ApiOptions.tsx` - Render provider component
|
||||
|
||||
## Responses API Providers (OpenAI Codex, OpenAI Native)
|
||||
Providers using OpenAI's Responses API require native tool calling. XML tools don't work with the Responses API.
|
||||
|
||||
**Symptoms of broken native tool calling:**
|
||||
- Tools get called multiple times (e.g., `ask_followup_question` asks the same question twice)
|
||||
- Tool arguments get duplicated or malformed
|
||||
- The model responds but tools aren't recognized
|
||||
|
||||
**Root causes to check:**
|
||||
1. **Provider missing from `isNextGenModelProvider()`** in `src/utils/model-utils.ts`. The native variant matchers (e.g., `native-gpt-5/config.ts`) call this function. If your provider isn't in the list, the matcher returns false and falls back to XML tools.
|
||||
|
||||
2. **Model missing `apiFormat: ApiFormat.OPENAI_RESPONSES`** in its model info (`src/shared/api.ts`). This property signals that the model requires native tool calling. The task runner in `src/core/task/index.ts` checks this and forces `enableNativeToolCalls: true` regardless of user settings.
|
||||
|
||||
**When adding a new Responses API provider:**
|
||||
1. Add provider to `isNextGenModelProvider()` list in `src/utils/model-utils.ts`
|
||||
2. Set `apiFormat: ApiFormat.OPENAI_RESPONSES` on all models that use the Responses API
|
||||
3. The variant matcher and task runner will handle the rest automatically
|
||||
|
||||
## Adding Tools to System Prompt
|
||||
This is tricky—multiple prompt variants and configs. **Always search for existing similar tools first and follow their pattern.** Look at the full chain from prompt definition → variant configs → handler → UI before implementing.
|
||||
|
||||
1. **Add to `ClineDefaultTool` enum** in `src/shared/tools.ts`
|
||||
2. **Tool definition** in `src/core/prompts/system-prompt/tools/` (create file like `generate_explanation.ts`)
|
||||
- Define variants for each `ModelFamily` (generic, next-gen, xs, etc.)
|
||||
- Export variants array (e.g., `export const my_tool_variants = [GENERIC, NATIVE_NEXT_GEN, XS]`)
|
||||
- **Fallback behavior**: If a variant isn't defined for a model family, `ClineToolSet.getToolByNameWithFallback()` automatically falls back to GENERIC. So you only need to export `[GENERIC]` unless the tool needs model-specific behavior.
|
||||
3. **Register in `src/core/prompts/system-prompt/tools/init.ts`** - Import and spread into `allToolVariants`
|
||||
4. **Add to variant configs** - Each model family has its own config in `src/core/prompts/system-prompt/variants/*/config.ts`. Add your tool's enum to the `.tools()` list:
|
||||
- `generic/config.ts`, `next-gen/config.ts`, `gpt-5/config.ts`, `native-gpt-5/config.ts`, `native-gpt-5-1/config.ts`, `native-next-gen/config.ts`, `gemini-3/config.ts`, `glm/config.ts`, `hermes/config.ts`, `xs/config.ts`
|
||||
- **Important**: If you add to a variant's config, make sure the tool spec exports a variant for that ModelFamily (or relies on GENERIC fallback)
|
||||
5. **Create handler** in `src/core/task/tools/handlers/`
|
||||
6. **Wire up in `ToolExecutor.ts`** if needed for execution flow
|
||||
7. **Add to tool parsing** in `src/core/assistant-message/index.ts` if needed
|
||||
8. **If tool has UI feedback**: add `ClineSay` enum in proto, update `src/shared/ExtensionMessage.ts`, update `src/shared/proto-conversions/cline-message.ts`, update `webview-ui/src/components/chat/ChatRow.tsx`
|
||||
|
||||
## Modifying System Prompt
|
||||
**Read these first:** `src/core/prompts/system-prompt/README.md`, `tools/README.md`, `__tests__/README.md`
|
||||
|
||||
System prompt is modular: **components** (reusable sections) + **variants** (model-specific configs) + **templates** (with `{{PLACEHOLDER}}` resolution).
|
||||
|
||||
**Key directories:**
|
||||
- `components/` - Shared sections: `rules.ts`, `capabilities.ts`, `editing_files.ts`, etc.
|
||||
- `variants/` - Model-specific: `generic/`, `next-gen/`, `xs/`, `gpt-5/`, `gemini-3/`, `hermes/`, `glm/`, etc.
|
||||
- `templates/` - Template engine and placeholder definitions
|
||||
|
||||
**Variant tiers (ask user which to modify):**
|
||||
- **Next-gen** (Claude 4, GPT-5, Gemini 2.5): `next-gen/`, `native-next-gen/`, `native-gpt-5/`, `native-gpt-5-1/`, `gemini-3/`, `gpt-5/`
|
||||
- **Standard** (default fallback): `generic/`
|
||||
- **Local/small models**: `xs/`, `hermes/`, `glm/`
|
||||
|
||||
**How overrides work:** Variants can override components via `componentOverrides` in their `config.ts`, or provide a custom template in `template.ts` (e.g., `next-gen/template.ts` exports `rules_template`). If no override, the shared component from `components/` is used.
|
||||
|
||||
**Example: Adding a rule to RULES section**
|
||||
1. Check if variant overrides rules: look for `rules_template` in `variants/*/template.ts` or `componentOverrides.RULES` in `config.ts`
|
||||
2. If shared: modify `components/rules.ts`
|
||||
3. If overridden: modify that variant's template
|
||||
4. XS variant is special—has heavily condensed inline content in `template.ts`
|
||||
|
||||
**After any changes, regenerate snapshots:**
|
||||
```bash
|
||||
UPDATE_SNAPSHOTS=true npm run test:unit
|
||||
```
|
||||
Snapshots live in `__tests__/__snapshots__/`. Tests validate across model families and context variations (browser, MCP, focus chain).
|
||||
|
||||
## Modifying Default Slash Commands
|
||||
Three places need updates:
|
||||
- `src/core/slash-commands/index.ts` - Command definitions
|
||||
- `src/core/prompts/commands.ts` - System prompt integration
|
||||
- `webview-ui/src/utils/slash-commands.ts` - Webview autocomplete
|
||||
|
||||
## Adding New Global State Keys
|
||||
Adding a new key to global state requires updates in multiple places. Missing any step causes silent failures.
|
||||
|
||||
@@ -199,3 +156,48 @@ const isGenerating = explanationInfo.status === "generating" && !wasCancelled
|
||||
**See also:** `BrowserSessionRow.tsx` uses similar pattern with `isLastApiReqInterrupted` and `isLastMessageResume`.
|
||||
|
||||
**Backend side:** When streaming is cancelled, clean up properly (close tabs, clear comments, etc.) by checking `taskState.abort` after the streaming function returns.
|
||||
|
||||
## Debug Harness: clear inherited VSCode/Electron env vars before launching
|
||||
|
||||
The debug harness (`apps/vscode/src/dev/debug-harness/server.ts`) launches a child
|
||||
VSCode via Playwright's `_electron.launch({ env: { ...process.env, ... } })`. If you
|
||||
run the harness from a process that was itself spawned by VSCode (e.g. the Cline
|
||||
extension host, an integrated terminal, or an agent running inside VSCode), the
|
||||
parent's VSCode/Electron env vars leak into the child and break the launch.
|
||||
|
||||
The fatal one is **`ELECTRON_RUN_AS_NODE=1`**: it makes the child VSCode binary run
|
||||
as plain Node, so it rejects every VSCode CLI flag. Symptom:
|
||||
|
||||
```
|
||||
.../Visual Studio Code.app/Contents/MacOS/Code: bad option: --extensionDevelopmentPath=...
|
||||
Error: Process failed to launch! (Playwright _electron.launch)
|
||||
```
|
||||
|
||||
This is NOT the macOS Playwright flakiness mentioned in the harness README — it's
|
||||
env inheritance. Fix: strip the inherited vars before starting the harness:
|
||||
|
||||
```bash
|
||||
env -u ELECTRON_RUN_AS_NODE -u ELECTRON_NO_ATTACH_CONSOLE \
|
||||
-u VSCODE_CLI -u VSCODE_CODE_CACHE_PATH -u VSCODE_CRASH_REPORTER_PROCESS_TYPE \
|
||||
-u VSCODE_CWD -u VSCODE_ESM_ENTRYPOINT -u VSCODE_HANDLES_UNCAUGHT_ERRORS \
|
||||
-u VSCODE_IPC_HOOK -u VSCODE_NLS_CONFIG -u VSCODE_PID -u VSCODE_L10N_BUNDLE_LOCATION \
|
||||
npx tsx src/dev/debug-harness/server.ts --auto-launch --skip-build
|
||||
```
|
||||
|
||||
Check your own env with `env | grep -iE 'electron|vscode_'` first; `ELECTRON_RUN_AS_NODE=1`
|
||||
present means you must scrub before launching.
|
||||
|
||||
Other harness notes confirmed in practice:
|
||||
- The extension host is **ESM** (`VSCODE_ESM_ENTRYPOINT`), so `ext.evaluate` has no
|
||||
`require` and module-internal functions aren't reachable as globals. To inspect
|
||||
internal builders (e.g. `buildBedrockProviderConfig`), set a breakpoint with
|
||||
`ext.set_breakpoint` and read locals via `ext.evaluate` with the paused `callFrameId`
|
||||
— don't try to `require()` the bundle.
|
||||
- `web.evaluate` wraps the expression as a single returned expression; multi-statement
|
||||
snippets must be an IIFE `(() => { ...; return x; })()`, otherwise you get
|
||||
`SyntaxError: Unexpected token ';'`.
|
||||
- Webview settings inputs are `vscode-text-field` web components with debounced React
|
||||
onChange. Setting `.value` + dispatching events via `web.evaluate` is unreliable for
|
||||
some fields; focus the inner shadow `input` then use real keystrokes (`ui.type` +
|
||||
`ui.press Tab`, or click the dropdown option) to make the value persist.
|
||||
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
# SDK Adapter
|
||||
|
||||
The VSCode extension runs on the Cline SDK (`@cline/core`, `@cline/llms`,
|
||||
`@cline/shared`) through an adapter layer in `apps/vscode/src/sdk/`. The
|
||||
webview still talks gRPC; the adapter translates between gRPC handlers and SDK
|
||||
calls. See `apps/vscode/src/dev/debug-harness/README.md` for the debug harness.
|
||||
|
||||
## Conventions
|
||||
|
||||
1. **Look up SDK APIs, don't guess.** Use `kb_search(name="sdk", query="...")`
|
||||
before implementing against an SDK surface.
|
||||
2. **Reference the pre-SDK implementation when replacing a module.** Add a
|
||||
`// Replaces classic src/core/... (see origin/main)` header and use
|
||||
`kb_search(name="cline", commit="origin/main")` or
|
||||
`git show origin/main:path` to consult the prior implementation.
|
||||
3. **Single entry point.** There is one codepath — the SDK adapter. No
|
||||
`CLINE_SDK` env flag.
|
||||
4. **Use `{appBaseUrl}`**, never hardcode `app.cline.bot`.
|
||||
5. **Avoid `as` casts.** Use explicit conversion functions with tests. The
|
||||
branded types in `apps/vscode/src/sdk/model-catalog/contracts.ts` exist so
|
||||
casts are unnecessary outside parse/compute boundaries.
|
||||
|
||||
## Debug harness
|
||||
|
||||
- **Dismiss the Kanban/promo overlay** before any debug harness interaction.
|
||||
- **Use the command palette** to navigate tabs in the debug harness.
|
||||
@@ -7,10 +7,10 @@ body:
|
||||
value: |
|
||||
**Important:** All bug reports must be reproducible using Claude Sonnet 4.5. Cline uses complex prompts so less capable models may not work as expected.
|
||||
- type: dropdown
|
||||
id: plugin-type
|
||||
id: cline-surface
|
||||
attributes:
|
||||
label: Plugin Type
|
||||
description: Which plugin are you reporting a bug for?
|
||||
label: Cline Surface
|
||||
description: Which Cline surface are you reporting a bug for?
|
||||
options:
|
||||
- VSCode Extension
|
||||
- JetBrains Plugin
|
||||
@@ -59,6 +59,18 @@ body:
|
||||
placeholder: 'e.g., cline:anthropic/claude-sonnet-4.5, gemini:gemini-2.5-pro-exp-03-25'
|
||||
validations:
|
||||
required: false
|
||||
- type: textarea
|
||||
id: ide-diagnostics
|
||||
attributes:
|
||||
label: IDE / CLI Diagnostics
|
||||
description: |
|
||||
Paste the "About" diagnostics for your Cline surface. This captures the IDE build, runtime, and host details we need.
|
||||
- VSCode Extension: open `Help → About` (Windows/Linux) or `Code → About Visual Studio Code` (macOS), then copy the info.
|
||||
- JetBrains Plugin: open `Help → About` (Windows/Linux) or `<IDE name> → About` (macOS), then click `Copy` to grab build, runtime, OS, memory, and cores.
|
||||
- CLI: there is no About dialog. Run `cline --version` and paste the output.
|
||||
placeholder: Paste the copied About info or `cline --version` output here.
|
||||
validations:
|
||||
required: false
|
||||
- type: textarea
|
||||
id: system-info
|
||||
attributes:
|
||||
|
||||
@@ -31,6 +31,9 @@ jobs:
|
||||
if: github.repository == 'cline/cline' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/dpc/sdk-migration-simpler-login')
|
||||
runs-on: ubuntu-latest
|
||||
environment: PublishNightly
|
||||
# The VS Code extension's package.json and lockfiles live under apps/vscode/
|
||||
# (the repo root has no package.json). Mirror ext-vscode-test.yml so install
|
||||
# and publish steps run in the correct workspace.
|
||||
defaults:
|
||||
run:
|
||||
working-directory: apps/vscode
|
||||
@@ -56,6 +59,10 @@ jobs:
|
||||
# Keep publish environment aligned with test workflow/tooling lockfile expectations.
|
||||
# Newer LTS (Node 24 / npm 11) can make `npm list` fail with ELSPROBLEMS during vsce packaging.
|
||||
node-version: 22
|
||||
cache: 'npm'
|
||||
cache-dependency-path: |
|
||||
apps/vscode/package-lock.json
|
||||
apps/vscode/webview-ui/package-lock.json
|
||||
|
||||
- name: Install extension dependencies
|
||||
working-directory: ${{ github.workspace }}
|
||||
|
||||
@@ -93,13 +93,13 @@ jobs:
|
||||
|
||||
- name: Install extension dependencies
|
||||
working-directory: ${{ github.workspace }}
|
||||
run: npm --prefix apps/vscode ci
|
||||
run: npm --prefix apps/vscode ci --include=optional
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Install webview-ui dependencies
|
||||
working-directory: ${{ github.workspace }}
|
||||
run: npm --prefix apps/vscode/webview-ui ci
|
||||
run: npm --prefix apps/vscode/webview-ui ci --include=optional
|
||||
|
||||
- name: Run Quality Checks (Parallel)
|
||||
run: npm run ci:check-all
|
||||
@@ -134,13 +134,13 @@ jobs:
|
||||
|
||||
- name: Install extension dependencies
|
||||
working-directory: ${{ github.workspace }}
|
||||
run: npm --prefix apps/vscode ci
|
||||
run: npm --prefix apps/vscode ci --include=optional
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Install webview-ui dependencies
|
||||
working-directory: ${{ github.workspace }}
|
||||
run: npm --prefix apps/vscode/webview-ui ci
|
||||
run: npm --prefix apps/vscode/webview-ui ci --include=optional
|
||||
|
||||
- name: Set up NPM on Windows
|
||||
if: runner.os == 'Windows'
|
||||
@@ -160,6 +160,11 @@ jobs:
|
||||
id: build_step
|
||||
run: npm run ci:build
|
||||
|
||||
- name: Vitest Suites (SDK adapter + model catalog)
|
||||
id: vitest_tests
|
||||
if: ${{ !cancelled() && steps.build_step.outcome == 'success' }}
|
||||
run: npm run test:vitest
|
||||
|
||||
- name: Unit Tests with coverage - Linux
|
||||
id: unit_tests_linux
|
||||
if: ${{ !cancelled() && steps.build_step.outcome == 'success' && runner.os == 'Linux' }}
|
||||
@@ -236,13 +241,13 @@ jobs:
|
||||
|
||||
- name: Install extension dependencies
|
||||
working-directory: ${{ github.workspace }}
|
||||
run: npm --prefix apps/vscode ci
|
||||
run: npm --prefix apps/vscode ci --include=optional
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Install webview-ui dependencies
|
||||
working-directory: ${{ github.workspace }}
|
||||
run: npm --prefix apps/vscode/webview-ui ci
|
||||
run: npm --prefix apps/vscode/webview-ui ci --include=optional
|
||||
|
||||
- name: Download ripgrep binaries
|
||||
run: npm run download-ripgrep
|
||||
@@ -252,7 +257,7 @@ jobs:
|
||||
|
||||
- name: Install testing platform dependencies
|
||||
working-directory: ${{ github.workspace }}
|
||||
run: npm --prefix apps/vscode/testing-platform ci
|
||||
run: npm --prefix apps/vscode/testing-platform ci --include=optional
|
||||
|
||||
- name: Running testing platform integration spec tests
|
||||
timeout-minutes: 7
|
||||
|
||||
@@ -17,7 +17,7 @@ jobs:
|
||||
const labels = context.payload.issue.labels.map(l => l.name);
|
||||
|
||||
// Check if JetBrains Plugin is selected
|
||||
if (body.match(/###\s*Plugin Type\s*\n+JetBrains Plugin/i)) {
|
||||
if (body.match(/###\s*Cline Surface\s*\n+JetBrains Plugin/i)) {
|
||||
if (!labels.includes('JetBrains')) {
|
||||
await github.rest.issues.addLabels({
|
||||
owner: context.repo.owner,
|
||||
@@ -29,7 +29,7 @@ jobs:
|
||||
}
|
||||
|
||||
// Check if VSCode Extension is selected
|
||||
if (body.match(/###\s*Plugin Type\s*\n+VSCode Extension/i)) {
|
||||
if (body.match(/###\s*Cline Surface\s*\n+VSCode Extension/i)) {
|
||||
if (!labels.includes('VS Code')) {
|
||||
await github.rest.issues.addLabels({
|
||||
owner: context.repo.owner,
|
||||
@@ -41,7 +41,7 @@ jobs:
|
||||
}
|
||||
|
||||
// Check if CLI is selected
|
||||
if (body.match(/###\s*Plugin Type\s*\n+CLI/i)) {
|
||||
if (body.match(/###\s*Cline Surface\s*\n+CLI/i)) {
|
||||
if (!labels.includes('CLI')) {
|
||||
await github.rest.issues.addLabels({
|
||||
owner: context.repo.owner,
|
||||
|
||||
@@ -13,6 +13,9 @@ tmp
|
||||
pnpm-lock.yaml
|
||||
|
||||
.clineignore
|
||||
.cline/enterprise
|
||||
.cline/remote-config
|
||||
**/.cline/remote-config
|
||||
.venv
|
||||
.actrc
|
||||
CLAUDE.local.md
|
||||
|
||||
+2
-1
@@ -7,4 +7,5 @@ fi
|
||||
|
||||
gitleaks git --pre-commit --redact --staged --verbose || exit 1
|
||||
|
||||
lint-staged
|
||||
cd apps/vscode && lint-staged
|
||||
|
||||
|
||||
@@ -1,5 +1,29 @@
|
||||
# Changelog
|
||||
|
||||
## [3.89.0]
|
||||
|
||||
### Added
|
||||
|
||||
- Add Claude Fable 5 model support.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fix MiniMax M3 thinking controls across gateways.
|
||||
|
||||
### Changed
|
||||
|
||||
- Clean up the Codex model list.
|
||||
|
||||
## [3.88.1]
|
||||
|
||||
### Added
|
||||
|
||||
- Add a debug section in settings for Cline testers.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Include the walkthrough markdown files in the VS Code extension package so the first-run walkthrough steps load correctly.
|
||||
|
||||
## [3.88.0]
|
||||
|
||||
### Added
|
||||
|
||||
+3
-1
@@ -1,7 +1,9 @@
|
||||
{
|
||||
"$schema": "https://biomejs.dev/schemas/2.4.5/schema.json",
|
||||
"root": false,
|
||||
"extends": ["../sdk/biome.json"],
|
||||
"extends": [
|
||||
"../sdk/biome.json"
|
||||
],
|
||||
"linter": {
|
||||
"rules": {
|
||||
"a11y": {
|
||||
|
||||
@@ -1,5 +1,28 @@
|
||||
# Cline CLI Changelog
|
||||
|
||||
## 3.0.23
|
||||
|
||||
- Fixed Vertex AI GCP settings configuration
|
||||
- Fixed the Azure Foundry API version
|
||||
- Added support for configured agents as subagent tools
|
||||
- Centralized OAuth management into the SDK
|
||||
- Fixed an error caused by disabled reasoning on Fable 5
|
||||
|
||||
## 3.0.22
|
||||
|
||||
- Added support for the Claude Fable 5 model
|
||||
- Fixed MiniMax M3 thinking controls so they route correctly across gateways
|
||||
|
||||
## 3.0.21
|
||||
|
||||
- Added a global auto-update setting that controls automatic updates on CLI startup
|
||||
- Added a Cline credits refill link
|
||||
- Fixed scrolling for inline ask-question responses
|
||||
- Fixed connector thread session routing and stale hub session handling
|
||||
- Added support for Vertex AI Application Default Credentials (ADC) with tool use
|
||||
- Fixed empty message content replay for Bedrock
|
||||
- Cleaned up the OpenAI Codex model list
|
||||
|
||||
## 3.0.20
|
||||
|
||||
- Installed plugin wrappers are now named from their source (npm package name, git repo, remote filename, official slug, or local directory) instead of an opaque hash, making installed plugins easier to identify.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@cline/cli",
|
||||
"displayName": "cline",
|
||||
"version": "3.0.20",
|
||||
"version": "3.0.23",
|
||||
"description": "Autonomous coding agent CLI - capable of creating/editing files, running commands, using the browser, and more",
|
||||
"type": "module",
|
||||
"publishConfig": {
|
||||
|
||||
+24
-59
@@ -1,11 +1,6 @@
|
||||
import type { ProviderSettings, ProviderSettingsManager } from "@cline/core";
|
||||
import { getClineEnvironmentConfig } from "@cline/shared";
|
||||
import type { OAuthCredentials } from "../commands/auth";
|
||||
import {
|
||||
getPersistedProviderApiKey,
|
||||
saveOAuthProviderSettings,
|
||||
toProviderApiKey,
|
||||
} from "../commands/auth";
|
||||
import type { ProviderSettingsManager } from "@cline/core";
|
||||
import { loginAndSaveProviderOAuthCredentials } from "@cline/core";
|
||||
import { getPersistedProviderApiKey } from "../commands/auth";
|
||||
import { writeDiagnostic } from "../utils/output";
|
||||
|
||||
/**
|
||||
@@ -30,37 +25,13 @@ export function isAcpAuthMethodId(id: string): id is AcpAuthMethodId {
|
||||
* If the OAuth flow requires interactive prompts (rare), defaults are used
|
||||
* when available; otherwise an error is thrown.
|
||||
*/
|
||||
async function performOAuthLogin(
|
||||
providerId: AcpAuthMethodId,
|
||||
existingSettings: ProviderSettings | undefined,
|
||||
): Promise<OAuthCredentials> {
|
||||
const [{ createOAuthClientCallbacks }, { default: open }, coreOAuth] =
|
||||
await Promise.all([
|
||||
import("@cline/core"),
|
||||
import("open"),
|
||||
import("@cline/core").then((m) => ({
|
||||
loginClineOAuth: m.loginClineOAuth as (input: {
|
||||
useWorkOSDeviceAuth?: boolean;
|
||||
apiBaseUrl: string;
|
||||
callbacks: {
|
||||
onAuth: (info: { url: string; instructions?: string }) => void;
|
||||
onPrompt: (prompt: {
|
||||
message: string;
|
||||
defaultValue?: string;
|
||||
}) => Promise<string>;
|
||||
onManualCodeInput?: () => Promise<string>;
|
||||
};
|
||||
}) => Promise<OAuthCredentials>,
|
||||
loginOpenAICodex: m.loginOpenAICodex as (input: {
|
||||
onAuth: (info: { url: string; instructions?: string }) => void;
|
||||
onPrompt: (prompt: {
|
||||
message: string;
|
||||
defaultValue?: string;
|
||||
}) => Promise<string>;
|
||||
onManualCodeInput?: () => Promise<string>;
|
||||
}) => Promise<OAuthCredentials>,
|
||||
})),
|
||||
]);
|
||||
async function performOAuthLogin(input: {
|
||||
providerId: AcpAuthMethodId;
|
||||
providerSettingsManager: ProviderSettingsManager;
|
||||
}): Promise<string> {
|
||||
const [{ createOAuthClientCallbacks }, { default: open }] = await Promise.all(
|
||||
[import("@cline/core"), import("open")],
|
||||
);
|
||||
|
||||
const callbacks = createOAuthClientCallbacks({
|
||||
onPrompt: ({ defaultValue }) => {
|
||||
@@ -82,18 +53,18 @@ async function performOAuthLogin(
|
||||
},
|
||||
});
|
||||
|
||||
if (providerId === "cline") {
|
||||
return coreOAuth.loginClineOAuth({
|
||||
apiBaseUrl:
|
||||
existingSettings?.baseUrl?.trim() ||
|
||||
getClineEnvironmentConfig().apiBaseUrl,
|
||||
callbacks,
|
||||
useWorkOSDeviceAuth: true,
|
||||
});
|
||||
const settings = await loginAndSaveProviderOAuthCredentials(
|
||||
input.providerSettingsManager,
|
||||
input.providerId,
|
||||
{ callbacks },
|
||||
);
|
||||
const apiKey = getPersistedProviderApiKey(input.providerId, settings);
|
||||
if (!apiKey) {
|
||||
throw new Error(
|
||||
`OAuth login did not persist credentials for ${input.providerId}`,
|
||||
);
|
||||
}
|
||||
|
||||
// openai-codex
|
||||
return coreOAuth.loginOpenAICodex(callbacks);
|
||||
return apiKey;
|
||||
}
|
||||
|
||||
export interface AcpAuthResult {
|
||||
@@ -122,16 +93,10 @@ export async function authenticateAcpProvider(
|
||||
|
||||
// Perform a fresh OAuth login.
|
||||
writeDiagnostic(`[acp/auth] Starting OAuth login for ${methodId}…`);
|
||||
const credentials = await performOAuthLogin(methodId, existing);
|
||||
|
||||
saveOAuthProviderSettings(
|
||||
const apiKey = await performOAuthLogin({
|
||||
providerId: methodId,
|
||||
providerSettingsManager,
|
||||
methodId,
|
||||
existing,
|
||||
credentials,
|
||||
);
|
||||
|
||||
const apiKey = toProviderApiKey(methodId, credentials);
|
||||
});
|
||||
writeDiagnostic(`[acp/auth] Successfully authenticated with ${methodId}`);
|
||||
return { providerId: methodId, apiKey };
|
||||
}
|
||||
|
||||
@@ -2,7 +2,37 @@ import { spawnSync } from "node:child_process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import type { ProviderSettingsManager } from "@cline/core";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { getPersistedProviderApiKey, saveOAuthProviderSettings } from "./auth";
|
||||
import {
|
||||
getPersistedProviderApiKey,
|
||||
normalizeAuthProviderId,
|
||||
parseAuthCommandArgs,
|
||||
saveOAuthProviderSettings,
|
||||
} from "./auth";
|
||||
|
||||
describe("parseAuthCommandArgs", () => {
|
||||
it("parses Azure API version quick setup option", () => {
|
||||
expect(
|
||||
parseAuthCommandArgs([
|
||||
"--provider",
|
||||
"openai-compatible",
|
||||
"--apikey",
|
||||
"key",
|
||||
"--modelid",
|
||||
"gpt-4.1",
|
||||
"--baseurl",
|
||||
"https://example.openai.azure.com/openai/deployments/gpt-4.1",
|
||||
"--azure-api-version",
|
||||
"2025-01-01-preview",
|
||||
]),
|
||||
).toMatchObject({
|
||||
explicitProvider: "openai-compatible",
|
||||
apikey: "key",
|
||||
modelid: "gpt-4.1",
|
||||
baseurl: "https://example.openai.azure.com/openai/deployments/gpt-4.1",
|
||||
azureApiVersion: "2025-01-01-preview",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("saveOAuthProviderSettings", () => {
|
||||
it("preserves existing manual apiKey while updating OAuth tokens", () => {
|
||||
@@ -67,6 +97,12 @@ describe("getPersistedProviderApiKey", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizeAuthProviderId", () => {
|
||||
it("keeps CLI-only codex shorthand in CLI parsing", () => {
|
||||
expect(normalizeAuthProviderId("codex")).toBe("openai-codex");
|
||||
});
|
||||
});
|
||||
|
||||
describe("loadAuthTuiRuntime", () => {
|
||||
it("loads OpenTUI React after provider catalog initialization", async () => {
|
||||
const cliRoot = fileURLToPath(new URL("../..", import.meta.url));
|
||||
|
||||
+40
-124
@@ -3,11 +3,13 @@ import {
|
||||
BUILT_IN_PROVIDER,
|
||||
createOAuthClientCallbacks,
|
||||
ensureCustomProvidersLoaded,
|
||||
getProviderAuthHandler,
|
||||
listLocalProviders,
|
||||
loginAndSaveProviderOAuthCredentials,
|
||||
type ProviderSettings,
|
||||
type ProviderSettingsManager,
|
||||
saveProviderOAuthCredentials,
|
||||
} from "@cline/core";
|
||||
import { getClineEnvironmentConfig } from "@cline/shared";
|
||||
import { Command } from "commander";
|
||||
import open from "open";
|
||||
import React from "react";
|
||||
@@ -37,40 +39,6 @@ const c = {
|
||||
green: "\x1b[32m",
|
||||
};
|
||||
|
||||
type CoreOAuthApi = {
|
||||
loginClineOAuth: (input: {
|
||||
apiBaseUrl: string;
|
||||
useWorkOSDeviceAuth?: boolean;
|
||||
callbacks: {
|
||||
onAuth: (info: { url: string; instructions?: string }) => void;
|
||||
onPrompt: (prompt: {
|
||||
message: string;
|
||||
defaultValue?: string;
|
||||
}) => Promise<string>;
|
||||
onManualCodeInput?: () => Promise<string>;
|
||||
};
|
||||
}) => Promise<OAuthCredentials>;
|
||||
loginOcaOAuth: (input: {
|
||||
mode?: "internal" | "external";
|
||||
callbacks: {
|
||||
onAuth: (info: { url: string; instructions?: string }) => void;
|
||||
onPrompt: (prompt: {
|
||||
message: string;
|
||||
defaultValue?: string;
|
||||
}) => Promise<string>;
|
||||
onManualCodeInput?: () => Promise<string>;
|
||||
};
|
||||
}) => Promise<OAuthCredentials>;
|
||||
loginOpenAICodex: (input: {
|
||||
onAuth: (info: { url: string; instructions?: string }) => void;
|
||||
onPrompt: (prompt: {
|
||||
message: string;
|
||||
defaultValue?: string;
|
||||
}) => Promise<string>;
|
||||
onManualCodeInput?: () => Promise<string>;
|
||||
}) => Promise<OAuthCredentials>;
|
||||
};
|
||||
|
||||
type AuthIo = {
|
||||
writeln: (text?: string) => void;
|
||||
writeErr: (text: string) => void;
|
||||
@@ -81,6 +49,7 @@ type AuthQuickSetupInput = {
|
||||
apikey: string;
|
||||
modelid: string;
|
||||
baseurl?: string;
|
||||
azureApiVersion?: string;
|
||||
};
|
||||
|
||||
type AuthCommandInput = {
|
||||
@@ -90,6 +59,7 @@ type AuthCommandInput = {
|
||||
apikey?: string;
|
||||
modelid?: string;
|
||||
baseurl?: string;
|
||||
azureApiVersion?: string;
|
||||
};
|
||||
|
||||
type ParsedAuthCommandArgs = {
|
||||
@@ -97,30 +67,10 @@ type ParsedAuthCommandArgs = {
|
||||
apikey?: string;
|
||||
modelid?: string;
|
||||
baseurl?: string;
|
||||
azureApiVersion?: string;
|
||||
parseError?: string;
|
||||
};
|
||||
|
||||
let cachedCoreOAuthApi: Promise<CoreOAuthApi> | undefined;
|
||||
|
||||
async function getCoreOAuthApi(): Promise<CoreOAuthApi> {
|
||||
if (!cachedCoreOAuthApi) {
|
||||
cachedCoreOAuthApi = import("@cline/core").then((module) => {
|
||||
const runtimeApi = module as Partial<CoreOAuthApi>;
|
||||
if (
|
||||
typeof runtimeApi.loginClineOAuth !== "function" ||
|
||||
typeof runtimeApi.loginOcaOAuth !== "function" ||
|
||||
typeof runtimeApi.loginOpenAICodex !== "function"
|
||||
) {
|
||||
throw new Error(
|
||||
"Installed @cline/core does not expose OAuth login helpers required by the CLI",
|
||||
);
|
||||
}
|
||||
return runtimeApi as CoreOAuthApi;
|
||||
});
|
||||
}
|
||||
return cachedCoreOAuthApi;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the `auth` subcommand for Commander.
|
||||
*
|
||||
@@ -137,7 +87,8 @@ export function createAuthCommand(): Command {
|
||||
.option("-p, --provider <id>", "provider id")
|
||||
.option("-k, --apikey <key>", "API key")
|
||||
.option("-m, --modelid <id>", "model id")
|
||||
.option("-b, --baseurl <url>", "base URL");
|
||||
.option("-b, --baseurl <url>", "base URL")
|
||||
.option("--azure-api-version <version>", "Azure API version");
|
||||
return cmd;
|
||||
}
|
||||
|
||||
@@ -154,6 +105,7 @@ export function parseAuthCommandArgs(args: string[]): ParsedAuthCommandArgs {
|
||||
apikey?: string;
|
||||
modelid?: string;
|
||||
baseurl?: string;
|
||||
azureApiVersion?: string;
|
||||
}>();
|
||||
const positionalProvider = cmd.args[0];
|
||||
return {
|
||||
@@ -161,6 +113,7 @@ export function parseAuthCommandArgs(args: string[]): ParsedAuthCommandArgs {
|
||||
apikey: opts.apikey,
|
||||
modelid: opts.modelid,
|
||||
baseurl: opts.baseurl,
|
||||
azureApiVersion: opts.azureApiVersion,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -200,6 +153,12 @@ async function ensureQuickSetupInputValid(
|
||||
) {
|
||||
return "base URL is only supported for OpenAI and OpenAI-compatible providers";
|
||||
}
|
||||
if (
|
||||
input.azureApiVersion?.trim() &&
|
||||
normalizedProvider !== BUILT_IN_PROVIDER.OPENAI_COMPATIBLE
|
||||
) {
|
||||
return "Azure API version is only supported for OpenAI-compatible providers";
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -209,6 +168,7 @@ function saveQuickAuthProviderSettings(input: {
|
||||
apikey: string;
|
||||
modelid: string;
|
||||
baseurl?: string;
|
||||
azureApiVersion?: string;
|
||||
}): void {
|
||||
const existing = input.providerSettingsManager.getProviderSettings(
|
||||
input.providerId,
|
||||
@@ -224,6 +184,12 @@ function saveQuickAuthProviderSettings(input: {
|
||||
if (input.baseurl?.trim()) {
|
||||
nextSettings.baseUrl = input.baseurl.trim();
|
||||
}
|
||||
if (input.azureApiVersion?.trim()) {
|
||||
nextSettings.azure = {
|
||||
...(nextSettings.azure ?? {}),
|
||||
apiVersion: input.azureApiVersion.trim(),
|
||||
};
|
||||
}
|
||||
input.providerSettingsManager.saveProviderSettings(nextSettings);
|
||||
}
|
||||
|
||||
@@ -272,64 +238,18 @@ function createOAuthCallbacks(io: AuthIo): {
|
||||
});
|
||||
}
|
||||
|
||||
async function loginWithOAuthProvider(
|
||||
providerId: string,
|
||||
existing: ProviderSettings | undefined,
|
||||
io: AuthIo,
|
||||
): Promise<OAuthCredentials> {
|
||||
const oauthApi = await getCoreOAuthApi();
|
||||
const callbacks = createOAuthCallbacks(io);
|
||||
|
||||
if (providerId === "cline") {
|
||||
return oauthApi.loginClineOAuth({
|
||||
apiBaseUrl:
|
||||
existing?.baseUrl?.trim() || getClineEnvironmentConfig().apiBaseUrl,
|
||||
useWorkOSDeviceAuth: true,
|
||||
callbacks,
|
||||
});
|
||||
}
|
||||
|
||||
if (providerId === "oca") {
|
||||
const mode = existing?.oca?.mode;
|
||||
return oauthApi.loginOcaOAuth({
|
||||
mode,
|
||||
callbacks,
|
||||
});
|
||||
}
|
||||
|
||||
if (providerId === "openai-codex") {
|
||||
return oauthApi.loginOpenAICodex(callbacks);
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`Provider "${providerId}" does not support CLI OAuth flow (supported: cline, openai-codex, oca)`,
|
||||
);
|
||||
}
|
||||
|
||||
export function saveOAuthProviderSettings(
|
||||
providerSettingsManager: ProviderSettingsManager,
|
||||
providerId: string,
|
||||
existing: ProviderSettings | undefined,
|
||||
credentials: OAuthCredentials,
|
||||
): ProviderSettings {
|
||||
const auth = {
|
||||
...(existing?.auth ?? {}),
|
||||
accessToken: toProviderApiKey(providerId, credentials),
|
||||
refreshToken: credentials.refresh,
|
||||
accountId: credentials.accountId,
|
||||
} as ProviderSettings["auth"] & { expiresAt?: number };
|
||||
auth.expiresAt = credentials.expires;
|
||||
const merged: ProviderSettings = {
|
||||
...(existing ?? {
|
||||
provider: providerId as ProviderSettings["provider"],
|
||||
}),
|
||||
provider: providerId as ProviderSettings["provider"],
|
||||
auth,
|
||||
};
|
||||
providerSettingsManager.saveProviderSettings(merged, {
|
||||
tokenSource: "oauth",
|
||||
return saveProviderOAuthCredentials({
|
||||
manager: providerSettingsManager,
|
||||
providerId,
|
||||
settings: existing,
|
||||
credentials,
|
||||
});
|
||||
return merged;
|
||||
}
|
||||
|
||||
export async function ensureOAuthProviderApiKey(input: {
|
||||
@@ -348,19 +268,14 @@ export async function ensureOAuthProviderApiKey(input: {
|
||||
selectedProviderSettings: input.existingSettings,
|
||||
};
|
||||
}
|
||||
const credentials = await loginWithOAuthProvider(
|
||||
input.providerId,
|
||||
input.existingSettings,
|
||||
input.io,
|
||||
);
|
||||
const selectedProviderSettings = saveOAuthProviderSettings(
|
||||
const selectedProviderSettings = await loginAndSaveProviderOAuthCredentials(
|
||||
input.providerSettingsManager,
|
||||
input.providerId,
|
||||
input.existingSettings,
|
||||
credentials,
|
||||
{ callbacks: createOAuthCallbacks(input.io) },
|
||||
);
|
||||
const handler = getProviderAuthHandler(input.providerId);
|
||||
return {
|
||||
apiKey: toProviderApiKey(input.providerId, credentials),
|
||||
apiKey: handler?.getApiKey(selectedProviderSettings),
|
||||
selectedProviderSettings,
|
||||
};
|
||||
}
|
||||
@@ -370,12 +285,14 @@ async function runQuickAuthSetup(input: AuthCommandInput): Promise<number> {
|
||||
const apikey = input.apikey?.trim() ?? "";
|
||||
const modelid = input.modelid?.trim() ?? "";
|
||||
const baseurl = input.baseurl?.trim();
|
||||
const azureApiVersion = input.azureApiVersion?.trim();
|
||||
const validationError = await ensureQuickSetupInputValid(
|
||||
{
|
||||
provider: providerId,
|
||||
apikey,
|
||||
modelid,
|
||||
baseurl,
|
||||
azureApiVersion,
|
||||
},
|
||||
input.providerSettingsManager,
|
||||
);
|
||||
@@ -389,6 +306,7 @@ async function runQuickAuthSetup(input: AuthCommandInput): Promise<number> {
|
||||
apikey,
|
||||
modelid,
|
||||
baseurl,
|
||||
azureApiVersion,
|
||||
});
|
||||
input.io.writeln(
|
||||
`${c.green}Provider configured:${c.reset} ${c.cyan}${providerId}${c.reset} (${modelid})`,
|
||||
@@ -473,12 +391,13 @@ export async function runAuthCommand(input: AuthCommandInput): Promise<number> {
|
||||
const hasQuickSetupFlags =
|
||||
typeof input.apikey === "string" ||
|
||||
typeof input.modelid === "string" ||
|
||||
typeof input.baseurl === "string";
|
||||
typeof input.baseurl === "string" ||
|
||||
typeof input.azureApiVersion === "string";
|
||||
|
||||
if (hasQuickSetupFlags) {
|
||||
if (!input.explicitProvider?.trim()) {
|
||||
input.io.writeErr(
|
||||
"auth quick setup requires --provider <id> when using --apikey/--modelid/--baseurl",
|
||||
"auth quick setup requires --provider <id> when using --apikey/--modelid/--baseurl/--azure-api-version",
|
||||
);
|
||||
return 1;
|
||||
}
|
||||
@@ -515,13 +434,10 @@ export async function runAuthProviderCommand(
|
||||
return 1;
|
||||
}
|
||||
try {
|
||||
const existing = providerSettingsManager.getProviderSettings(providerId);
|
||||
const credentials = await loginWithOAuthProvider(providerId, existing, io);
|
||||
saveOAuthProviderSettings(
|
||||
await loginAndSaveProviderOAuthCredentials(
|
||||
providerSettingsManager,
|
||||
providerId,
|
||||
existing,
|
||||
credentials,
|
||||
{ callbacks: createOAuthCallbacks(io) },
|
||||
);
|
||||
io.writeln(
|
||||
`${c.green}You are now logged in to ${c.cyan}${providerId}${c.reset}`,
|
||||
|
||||
@@ -168,6 +168,8 @@ export function buildKanbanSpawnOptions(
|
||||
detached: shouldDetachKanbanProcess(platform),
|
||||
...(platform === "win32" ? { shell: true } : {}),
|
||||
...options,
|
||||
// Prevent a console window from flashing on Windows.
|
||||
windowsHide: true,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -178,6 +180,8 @@ function buildKanbanInstallSpawnOptions(
|
||||
return {
|
||||
detached: false,
|
||||
stdio: "inherit",
|
||||
// Prevent a console window from flashing on Windows.
|
||||
windowsHide: true,
|
||||
...(platform === "win32" ? { shell: true } : {}),
|
||||
...options,
|
||||
};
|
||||
@@ -203,6 +207,8 @@ export function getInstalledKanbanVersion(): string | null {
|
||||
const result = spawnSync(getKanbanCommand(), ["--version"], {
|
||||
encoding: "utf8",
|
||||
shell: process.platform === "win32",
|
||||
// Prevent a console window from flashing on Windows.
|
||||
windowsHide: true,
|
||||
});
|
||||
if (result.status !== 0) {
|
||||
return null;
|
||||
|
||||
@@ -506,6 +506,8 @@ async function runCommand(
|
||||
cwd: options.cwd,
|
||||
stdio: ["ignore", "ignore", "pipe"],
|
||||
env: process.env,
|
||||
// Prevent a console window from flashing on Windows.
|
||||
windowsHide: true,
|
||||
});
|
||||
let stderr = "";
|
||||
child.stderr.on("data", (chunk) => {
|
||||
|
||||
@@ -78,6 +78,74 @@ describe("saveLocalProviderSettings", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("merges and clears Azure provider settings", () => {
|
||||
const save = vi.fn();
|
||||
const manager = {
|
||||
read: vi.fn().mockReturnValue({
|
||||
providers: {},
|
||||
}),
|
||||
write: vi.fn(),
|
||||
getFilePath: vi.fn().mockReturnValue("/tmp/providers.json"),
|
||||
getProviderSettings: vi.fn().mockReturnValue({
|
||||
provider: "openai-compatible",
|
||||
azure: {
|
||||
apiVersion: "2024-10-21",
|
||||
useIdentity: true,
|
||||
},
|
||||
}),
|
||||
saveProviderSettings: save,
|
||||
};
|
||||
|
||||
saveLocalProviderSettings(
|
||||
manager as unknown as ProviderSettingsManager,
|
||||
{
|
||||
action: "saveProviderSettings",
|
||||
providerId: "openai-compatible",
|
||||
azure: {
|
||||
apiVersion: "2025-01-01-preview",
|
||||
},
|
||||
} as SaveProviderSettingsActionRequest,
|
||||
);
|
||||
|
||||
expect(save).toHaveBeenCalledTimes(1);
|
||||
expect(save).toHaveBeenCalledWith(
|
||||
{
|
||||
provider: "openai-compatible",
|
||||
azure: {
|
||||
apiVersion: "2025-01-01-preview",
|
||||
useIdentity: true,
|
||||
},
|
||||
},
|
||||
{ setLastUsed: false },
|
||||
);
|
||||
|
||||
save.mockClear();
|
||||
manager.getProviderSettings.mockReturnValue({
|
||||
provider: "openai-compatible",
|
||||
azure: {
|
||||
apiVersion: "2025-01-01-preview",
|
||||
},
|
||||
});
|
||||
|
||||
saveLocalProviderSettings(
|
||||
manager as unknown as ProviderSettingsManager,
|
||||
{
|
||||
action: "saveProviderSettings",
|
||||
providerId: "openai-compatible",
|
||||
azure: {
|
||||
apiVersion: "",
|
||||
},
|
||||
} as SaveProviderSettingsActionRequest,
|
||||
);
|
||||
|
||||
expect(save).toHaveBeenCalledWith(
|
||||
{
|
||||
provider: "openai-compatible",
|
||||
},
|
||||
{ setLastUsed: false },
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps OAuth auth fields when updating manual apiKey", () => {
|
||||
const save = vi.fn();
|
||||
const manager = {
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { dirname, join } from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
autoUpdateOnStartup,
|
||||
checkForUpdates,
|
||||
getInstallationInfo,
|
||||
PackageManager,
|
||||
withMinimumReleaseAgeBypass,
|
||||
@@ -10,6 +12,9 @@ import {
|
||||
|
||||
const originalArgv = [...process.argv];
|
||||
const originalWrapperPath = process.env.CLINE_WRAPPER_PATH;
|
||||
const originalGlobalSettingsPath = process.env.CLINE_GLOBAL_SETTINGS_PATH;
|
||||
const originalIsDev = process.env.IS_DEV;
|
||||
const originalNoAutoUpdate = process.env.CLINE_NO_AUTO_UPDATE;
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
function createFile(path: string): string {
|
||||
@@ -32,6 +37,22 @@ describe("getInstallationInfo", () => {
|
||||
} else {
|
||||
process.env.CLINE_WRAPPER_PATH = originalWrapperPath;
|
||||
}
|
||||
if (originalGlobalSettingsPath === undefined) {
|
||||
delete process.env.CLINE_GLOBAL_SETTINGS_PATH;
|
||||
} else {
|
||||
process.env.CLINE_GLOBAL_SETTINGS_PATH = originalGlobalSettingsPath;
|
||||
}
|
||||
if (originalIsDev === undefined) {
|
||||
delete process.env.IS_DEV;
|
||||
} else {
|
||||
process.env.IS_DEV = originalIsDev;
|
||||
}
|
||||
if (originalNoAutoUpdate === undefined) {
|
||||
delete process.env.CLINE_NO_AUTO_UPDATE;
|
||||
} else {
|
||||
process.env.CLINE_NO_AUTO_UPDATE = originalNoAutoUpdate;
|
||||
}
|
||||
vi.restoreAllMocks();
|
||||
for (const dir of tempDirs.splice(0)) {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
@@ -72,6 +93,66 @@ describe("getInstallationInfo", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("auto update settings", () => {
|
||||
afterEach(() => {
|
||||
process.argv = [...originalArgv];
|
||||
if (originalWrapperPath === undefined) {
|
||||
delete process.env.CLINE_WRAPPER_PATH;
|
||||
} else {
|
||||
process.env.CLINE_WRAPPER_PATH = originalWrapperPath;
|
||||
}
|
||||
if (originalGlobalSettingsPath === undefined) {
|
||||
delete process.env.CLINE_GLOBAL_SETTINGS_PATH;
|
||||
} else {
|
||||
process.env.CLINE_GLOBAL_SETTINGS_PATH = originalGlobalSettingsPath;
|
||||
}
|
||||
if (originalIsDev === undefined) {
|
||||
delete process.env.IS_DEV;
|
||||
} else {
|
||||
process.env.IS_DEV = originalIsDev;
|
||||
}
|
||||
if (originalNoAutoUpdate === undefined) {
|
||||
delete process.env.CLINE_NO_AUTO_UPDATE;
|
||||
} else {
|
||||
process.env.CLINE_NO_AUTO_UPDATE = originalNoAutoUpdate;
|
||||
}
|
||||
vi.restoreAllMocks();
|
||||
for (const dir of tempDirs.splice(0)) {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("skips startup auto update when disabled globally", () => {
|
||||
const settingsPath = createTempFile("data/global-settings.json");
|
||||
writeFileSync(settingsPath, JSON.stringify({ autoUpdateEnabled: false }));
|
||||
process.env.CLINE_GLOBAL_SETTINGS_PATH = settingsPath;
|
||||
delete process.env.IS_DEV;
|
||||
delete process.env.CLINE_NO_AUTO_UPDATE;
|
||||
const fetchSpy = vi
|
||||
.spyOn(globalThis, "fetch")
|
||||
.mockRejectedValue(new Error("should not fetch"));
|
||||
|
||||
autoUpdateOnStartup();
|
||||
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("still lets manual update checks run when startup auto update is disabled", async () => {
|
||||
const settingsPath = createTempFile("data/global-settings.json");
|
||||
writeFileSync(settingsPath, JSON.stringify({ autoUpdateEnabled: false }));
|
||||
process.env.CLINE_GLOBAL_SETTINGS_PATH = settingsPath;
|
||||
delete process.env.CLINE_NO_AUTO_UPDATE;
|
||||
const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ version: "0.0.0" }),
|
||||
} as Response);
|
||||
|
||||
await checkForUpdates({ includeKanban: false });
|
||||
|
||||
expect(fetchSpy).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("withMinimumReleaseAgeBypass", () => {
|
||||
it("adds the package-manager-specific cooldown bypass", () => {
|
||||
expect(
|
||||
|
||||
@@ -2,6 +2,7 @@ import { type ChildProcess, spawn } from "node:child_process";
|
||||
import { realpathSync } from "node:fs";
|
||||
import {
|
||||
clearHubDiscovery,
|
||||
isAutoUpdateEnabledGlobally,
|
||||
probeHubServer,
|
||||
readHubDiscovery,
|
||||
resolveSharedHubOwnerContext,
|
||||
@@ -340,6 +341,7 @@ async function restartHubServerIfRunning(): Promise<void> {
|
||||
export function autoUpdateOnStartup(): void {
|
||||
if (process.env.IS_DEV === "true") return;
|
||||
if (process.env.CLINE_NO_AUTO_UPDATE === "1") return;
|
||||
if (!isAutoUpdateEnabledGlobally()) return;
|
||||
|
||||
const { packageName, packageManager, updateCommand } =
|
||||
getInstallationInfo(version);
|
||||
@@ -360,6 +362,9 @@ export function autoUpdateOnStartup(): void {
|
||||
env: autoUpdateCommand.env
|
||||
? { ...process.env, ...autoUpdateCommand.env }
|
||||
: process.env,
|
||||
// Prevent a console window from flashing on Windows; detached
|
||||
// processes otherwise allocate a new visible console.
|
||||
windowsHide: true,
|
||||
});
|
||||
const exitCode = await waitForProcessExit(child);
|
||||
if (exitCode === 0) {
|
||||
|
||||
@@ -228,7 +228,7 @@ describe("discordConnector", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("switches Discord thread state to the incoming participant without reusing the previous participant session", async () => {
|
||||
it("updates Discord participant metadata without changing the thread session", async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), "discord-participants-"));
|
||||
const bindingsPath = join(dir, "threads.json");
|
||||
const thread = createThread({
|
||||
@@ -278,11 +278,13 @@ describe("discordConnector", () => {
|
||||
errorLabel: "Discord",
|
||||
});
|
||||
|
||||
const bob =
|
||||
readBindings<TestDiscordState>(bindingsPath)["discord:user:bob"];
|
||||
expect(bob?.state?.participantKey).toBe("discord:user:bob");
|
||||
expect(bob?.state?.participantLabel).toBe("Bob");
|
||||
expect(bob?.state?.sessionId).toBeUndefined();
|
||||
const binding =
|
||||
readBindings<TestDiscordState>(bindingsPath)[
|
||||
"discord:guild:channel:thread"
|
||||
];
|
||||
expect(binding?.state?.participantKey).toBe("discord:user:bob");
|
||||
expect(binding?.state?.participantLabel).toBe("Bob");
|
||||
expect(binding?.state?.sessionId).toBe("session-alice");
|
||||
expect(
|
||||
readBindings<TestDiscordState>(bindingsPath)["discord:user:alice"]?.state
|
||||
?.sessionId,
|
||||
|
||||
@@ -50,10 +50,9 @@ import {
|
||||
type ConnectorMuteTarget,
|
||||
type ConnectorThreadState,
|
||||
clearBindingSessionIds,
|
||||
findBindingForParticipantKey,
|
||||
findBindingForDeliveryTarget,
|
||||
findBindingForThread,
|
||||
loadThreadState,
|
||||
mergeThreadState,
|
||||
persistMergedThreadState,
|
||||
readBindings,
|
||||
} from "../thread-bindings";
|
||||
@@ -564,45 +563,17 @@ async function postDiscordResolvedText(input: {
|
||||
});
|
||||
}
|
||||
|
||||
function resolveParticipantState(input: {
|
||||
bindingsPath: string;
|
||||
baseStartRequest: ChatStartSessionRequest;
|
||||
function resolveCurrentStateWithParticipant(input: {
|
||||
currentState: DiscordThreadState;
|
||||
participant: DiscordParticipant;
|
||||
}): DiscordThreadState {
|
||||
const existing = findBindingForParticipantKey(
|
||||
readBindings<DiscordThreadState>(input.bindingsPath),
|
||||
input.participant.key,
|
||||
)?.binding.state;
|
||||
return {
|
||||
...mergeThreadState<DiscordThreadState>(
|
||||
undefined,
|
||||
existing,
|
||||
input.baseStartRequest,
|
||||
),
|
||||
...input.currentState,
|
||||
participantKey: input.participant.key,
|
||||
participantLabel: input.participant.label,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveCurrentStateWithParticipant(input: {
|
||||
currentState: DiscordThreadState;
|
||||
bindingsPath: string;
|
||||
baseStartRequest: ChatStartSessionRequest;
|
||||
participant: DiscordParticipant;
|
||||
}): DiscordThreadState {
|
||||
if (input.currentState.participantKey === input.participant.key) {
|
||||
return {
|
||||
...input.currentState,
|
||||
participantLabel: input.participant.label,
|
||||
};
|
||||
}
|
||||
return resolveParticipantState({
|
||||
bindingsPath: input.bindingsPath,
|
||||
baseStartRequest: input.baseStartRequest,
|
||||
participant: input.participant,
|
||||
});
|
||||
}
|
||||
|
||||
async function persistDiscordThreadContext(input: {
|
||||
thread: Thread<DiscordThreadState>;
|
||||
bindingsPath: string;
|
||||
@@ -624,8 +595,6 @@ async function persistDiscordThreadContext(input: {
|
||||
);
|
||||
const nextState = resolveCurrentStateWithParticipant({
|
||||
currentState,
|
||||
bindingsPath: input.bindingsPath,
|
||||
baseStartRequest: input.baseStartRequest,
|
||||
participant,
|
||||
});
|
||||
if (
|
||||
@@ -669,20 +638,20 @@ async function deliverScheduledResult(input: {
|
||||
const threadId =
|
||||
typeof delivery.threadId === "string" ? delivery.threadId.trim() : "";
|
||||
const bindingKey =
|
||||
typeof delivery.bindingKey === "string"
|
||||
? delivery.bindingKey.trim()
|
||||
: typeof delivery.participantKey === "string"
|
||||
? delivery.participantKey.trim()
|
||||
: "";
|
||||
if (!threadId && !bindingKey) {
|
||||
typeof delivery.bindingKey === "string" ? delivery.bindingKey.trim() : "";
|
||||
const participantKey =
|
||||
typeof delivery.participantKey === "string"
|
||||
? delivery.participantKey.trim()
|
||||
: "";
|
||||
if (!threadId && !bindingKey && !participantKey) {
|
||||
return;
|
||||
}
|
||||
const bindings = readBindings<DiscordThreadState>(input.bindingsPath);
|
||||
const match = bindingKey
|
||||
? findBindingForParticipantKey(bindings, bindingKey)
|
||||
: threadId
|
||||
? { key: threadId, binding: bindings[threadId] }
|
||||
: undefined;
|
||||
const match = findBindingForDeliveryTarget(bindings, {
|
||||
bindingKey,
|
||||
threadId,
|
||||
participantKey,
|
||||
});
|
||||
const binding = match?.binding;
|
||||
if (!binding?.serializedThread) {
|
||||
return;
|
||||
@@ -1133,9 +1102,7 @@ class DiscordConnector extends ConnectorBase<
|
||||
isSubscribedThreadMessage?: boolean;
|
||||
},
|
||||
) => {
|
||||
const queueKey =
|
||||
(await loadThreadState(thread, bindingsPath, startRequest))
|
||||
.participantKey || thread.id;
|
||||
const queueKey = thread.id;
|
||||
const runTurn = async () => {
|
||||
try {
|
||||
await handleConnectorUserTurn({
|
||||
|
||||
@@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest";
|
||||
import { __test__ } from "./gchat";
|
||||
|
||||
describe("gchat binding lookup", () => {
|
||||
it("falls back to channel identity when a restarted connector gets a new thread id", () => {
|
||||
it("does not fall back to channel identity for a different space thread id", () => {
|
||||
const result = __test__.findBindingForThread(
|
||||
{
|
||||
legacy_thread_id: {
|
||||
@@ -21,17 +21,7 @@ describe("gchat binding lookup", () => {
|
||||
},
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
key: "legacy_thread_id",
|
||||
binding: {
|
||||
channelId: "space-123",
|
||||
isDM: false,
|
||||
serializedThread: "{}",
|
||||
sessionId: "sess-1",
|
||||
state: { sessionId: "sess-1", cwd: "/tmp/work" },
|
||||
updatedAt: "2026-03-17T00:00:00.000Z",
|
||||
},
|
||||
});
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it("prefers an exact thread id match over a channel fallback", () => {
|
||||
@@ -65,7 +55,7 @@ describe("gchat binding lookup", () => {
|
||||
expect(result?.binding.sessionId).toBe("sess-2");
|
||||
});
|
||||
|
||||
it("reuses a binding by participant key across different spaces", () => {
|
||||
it("does not reuse a binding by participant key across different spaces", () => {
|
||||
const result = __test__.findBindingForThread(
|
||||
{
|
||||
"gchat:email:alice@example.com": {
|
||||
@@ -91,7 +81,6 @@ describe("gchat binding lookup", () => {
|
||||
},
|
||||
);
|
||||
|
||||
expect(result?.key).toBe("gchat:email:alice@example.com");
|
||||
expect(result?.binding.sessionId).toBe("sess-1");
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -46,7 +46,7 @@ import {
|
||||
type ConnectorBindingStore,
|
||||
type ConnectorThreadState,
|
||||
clearBindingSessionIds,
|
||||
findBindingForParticipantKey,
|
||||
findBindingForDeliveryTarget,
|
||||
findBindingForThread,
|
||||
loadThreadState,
|
||||
persistMergedThreadState,
|
||||
@@ -191,20 +191,20 @@ async function deliverScheduledResult(input: {
|
||||
const threadId =
|
||||
typeof delivery.threadId === "string" ? delivery.threadId.trim() : "";
|
||||
const bindingKey =
|
||||
typeof delivery.bindingKey === "string"
|
||||
? delivery.bindingKey.trim()
|
||||
: typeof delivery.participantKey === "string"
|
||||
? delivery.participantKey.trim()
|
||||
: "";
|
||||
if (!threadId && !bindingKey) {
|
||||
typeof delivery.bindingKey === "string" ? delivery.bindingKey.trim() : "";
|
||||
const participantKey =
|
||||
typeof delivery.participantKey === "string"
|
||||
? delivery.participantKey.trim()
|
||||
: "";
|
||||
if (!threadId && !bindingKey && !participantKey) {
|
||||
return;
|
||||
}
|
||||
const bindings = readBindings<GoogleChatThreadState>(input.bindingsPath);
|
||||
const match = bindingKey
|
||||
? findBindingForParticipantKey(bindings, bindingKey)
|
||||
: threadId
|
||||
? { key: threadId, binding: bindings[threadId] }
|
||||
: undefined;
|
||||
const match = findBindingForDeliveryTarget(bindings, {
|
||||
bindingKey,
|
||||
threadId,
|
||||
participantKey,
|
||||
});
|
||||
const binding = match?.binding;
|
||||
if (!binding?.serializedThread) {
|
||||
return;
|
||||
@@ -590,9 +590,7 @@ class GoogleChatConnector extends ConnectorBase<
|
||||
thread: Thread<GoogleChatThreadState>,
|
||||
text: string,
|
||||
) => {
|
||||
const queueKey =
|
||||
(await loadThreadState(thread, bindingsPath, startRequest))
|
||||
.participantKey || thread.id;
|
||||
const queueKey = thread.id;
|
||||
const runTurn = async () => {
|
||||
try {
|
||||
await handleConnectorUserTurn({
|
||||
|
||||
@@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest";
|
||||
import { __test__ } from "./linear";
|
||||
|
||||
describe("linear binding lookup", () => {
|
||||
it("falls back to channel identity when a restarted connector gets a new thread id", () => {
|
||||
it("does not fall back to channel identity for a different issue thread id", () => {
|
||||
const result = __test__.findBindingForThread(
|
||||
{
|
||||
legacy_thread_id: {
|
||||
@@ -21,17 +21,7 @@ describe("linear binding lookup", () => {
|
||||
},
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
key: "legacy_thread_id",
|
||||
binding: {
|
||||
channelId: "linear:issue:ISS-123",
|
||||
isDM: false,
|
||||
serializedThread: "{}",
|
||||
sessionId: "sess-1",
|
||||
state: { sessionId: "sess-1", cwd: "/tmp/work" },
|
||||
updatedAt: "2026-03-17T00:00:00.000Z",
|
||||
},
|
||||
});
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it("prefers an exact thread id match over a channel fallback", () => {
|
||||
@@ -65,7 +55,7 @@ describe("linear binding lookup", () => {
|
||||
expect(result?.binding.sessionId).toBe("sess-2");
|
||||
});
|
||||
|
||||
it("reuses a binding by participant key across different issue threads", () => {
|
||||
it("does not reuse a binding by participant key across different issue threads", () => {
|
||||
const result = __test__.findBindingForThread(
|
||||
{
|
||||
"linear:user:user_123": {
|
||||
@@ -91,7 +81,6 @@ describe("linear binding lookup", () => {
|
||||
},
|
||||
);
|
||||
|
||||
expect(result?.key).toBe("linear:user:user_123");
|
||||
expect(result?.binding.sessionId).toBe("sess-1");
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -42,7 +42,7 @@ import {
|
||||
type ConnectorBindingStore,
|
||||
type ConnectorThreadState,
|
||||
clearBindingSessionIds,
|
||||
findBindingForParticipantKey,
|
||||
findBindingForDeliveryTarget,
|
||||
findBindingForThread,
|
||||
loadThreadState,
|
||||
persistMergedThreadState,
|
||||
@@ -229,20 +229,20 @@ async function deliverScheduledResult(input: {
|
||||
const threadId =
|
||||
typeof delivery.threadId === "string" ? delivery.threadId.trim() : "";
|
||||
const bindingKey =
|
||||
typeof delivery.bindingKey === "string"
|
||||
? delivery.bindingKey.trim()
|
||||
: typeof delivery.participantKey === "string"
|
||||
? delivery.participantKey.trim()
|
||||
: "";
|
||||
if (!threadId && !bindingKey) {
|
||||
typeof delivery.bindingKey === "string" ? delivery.bindingKey.trim() : "";
|
||||
const participantKey =
|
||||
typeof delivery.participantKey === "string"
|
||||
? delivery.participantKey.trim()
|
||||
: "";
|
||||
if (!threadId && !bindingKey && !participantKey) {
|
||||
return;
|
||||
}
|
||||
const bindings = readBindings<LinearThreadState>(input.bindingsPath);
|
||||
const match = bindingKey
|
||||
? findBindingForParticipantKey(bindings, bindingKey)
|
||||
: threadId
|
||||
? { key: threadId, binding: bindings[threadId] }
|
||||
: undefined;
|
||||
const match = findBindingForDeliveryTarget(bindings, {
|
||||
bindingKey,
|
||||
threadId,
|
||||
participantKey,
|
||||
});
|
||||
const binding = match?.binding;
|
||||
if (!binding?.serializedThread) {
|
||||
return;
|
||||
@@ -625,9 +625,7 @@ class LinearConnector extends ConnectorBase<
|
||||
thread: Thread<LinearThreadState>,
|
||||
text: string,
|
||||
) => {
|
||||
const queueKey =
|
||||
(await loadThreadState(thread, bindingsPath, startRequest))
|
||||
.participantKey || thread.id;
|
||||
const queueKey = thread.id;
|
||||
const runTurn = async () => {
|
||||
try {
|
||||
await handleConnectorUserTurn({
|
||||
|
||||
@@ -29,7 +29,7 @@ export function getConnectorSystemRules(
|
||||
}
|
||||
|
||||
const CONNECTOR_FIRST_CONTACT_MESSAGE = [
|
||||
"Connected.",
|
||||
"Connected to Cline.",
|
||||
"Your chat history is kept separately for your account.",
|
||||
"Send /new to start a fresh session or /whereami for thread details.",
|
||||
].join("\n");
|
||||
|
||||
@@ -63,12 +63,12 @@ describe("slack binding lookup", () => {
|
||||
expect(options.appToken).toBe("xapp-token");
|
||||
});
|
||||
|
||||
it("falls back to channel identity when a restarted connector gets a new thread id", () => {
|
||||
it("falls back to DM channel identity when a restarted connector gets a new thread id", () => {
|
||||
const result = __test__.findBindingForThread(
|
||||
{
|
||||
legacy_thread_id: {
|
||||
channelId: "slack:C123",
|
||||
isDM: false,
|
||||
isDM: true,
|
||||
serializedThread: "{}",
|
||||
sessionId: "sess-1",
|
||||
state: { sessionId: "sess-1", cwd: "/tmp/work", teamId: "T123" },
|
||||
@@ -78,7 +78,7 @@ describe("slack binding lookup", () => {
|
||||
{
|
||||
id: "new_thread_id",
|
||||
channelId: "slack:C123",
|
||||
isDM: false,
|
||||
isDM: true,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -86,7 +86,7 @@ describe("slack binding lookup", () => {
|
||||
key: "legacy_thread_id",
|
||||
binding: {
|
||||
channelId: "slack:C123",
|
||||
isDM: false,
|
||||
isDM: true,
|
||||
serializedThread: "{}",
|
||||
sessionId: "sess-1",
|
||||
state: { sessionId: "sess-1", cwd: "/tmp/work", teamId: "T123" },
|
||||
@@ -126,7 +126,7 @@ describe("slack binding lookup", () => {
|
||||
expect(result?.binding.sessionId).toBe("sess-2");
|
||||
});
|
||||
|
||||
it("reuses a binding by participant key across different threads", () => {
|
||||
it("does not reuse a binding by participant key across different threads", () => {
|
||||
const result = __test__.findBindingForThread(
|
||||
{
|
||||
[participantKey]: {
|
||||
@@ -153,8 +153,7 @@ describe("slack binding lookup", () => {
|
||||
},
|
||||
);
|
||||
|
||||
expect(result?.key).toBe(participantKey);
|
||||
expect(result?.binding.sessionId).toBe("sess-1");
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it("builds Slack participant keys with a team scope", () => {
|
||||
|
||||
@@ -51,7 +51,7 @@ import {
|
||||
type ConnectorThreadBinding,
|
||||
type ConnectorThreadState,
|
||||
clearBindingSessionIds,
|
||||
findBindingForParticipantKey,
|
||||
findBindingForDeliveryTarget,
|
||||
findBindingForThread,
|
||||
loadThreadState,
|
||||
persistMergedThreadState,
|
||||
@@ -376,20 +376,20 @@ async function deliverScheduledResult(input: {
|
||||
const threadId =
|
||||
typeof delivery.threadId === "string" ? delivery.threadId.trim() : "";
|
||||
const bindingKey =
|
||||
typeof delivery.bindingKey === "string"
|
||||
? delivery.bindingKey.trim()
|
||||
: typeof delivery.participantKey === "string"
|
||||
? delivery.participantKey.trim()
|
||||
: "";
|
||||
if (!threadId && !bindingKey) {
|
||||
typeof delivery.bindingKey === "string" ? delivery.bindingKey.trim() : "";
|
||||
const participantKey =
|
||||
typeof delivery.participantKey === "string"
|
||||
? delivery.participantKey.trim()
|
||||
: "";
|
||||
if (!threadId && !bindingKey && !participantKey) {
|
||||
return;
|
||||
}
|
||||
const bindings = readBindings<SlackThreadState>(input.bindingsPath);
|
||||
const match = bindingKey
|
||||
? findBindingForParticipantKey(bindings, bindingKey)
|
||||
: threadId
|
||||
? { key: threadId, binding: bindings[threadId] }
|
||||
: undefined;
|
||||
const match = findBindingForDeliveryTarget(bindings, {
|
||||
bindingKey,
|
||||
threadId,
|
||||
participantKey,
|
||||
});
|
||||
const binding = match?.binding;
|
||||
const deliveryThreadId = match?.key || threadId || bindingKey;
|
||||
if (!binding?.serializedThread) {
|
||||
@@ -826,7 +826,7 @@ class SlackConnector extends ConnectorBase<
|
||||
bindingsPath,
|
||||
startRequest,
|
||||
);
|
||||
const queueKey = currentState.participantKey || thread.id;
|
||||
const queueKey = thread.id;
|
||||
const runTurn = async () => {
|
||||
try {
|
||||
await withSlackTeamBotToken({
|
||||
|
||||
@@ -363,7 +363,7 @@ describe("telegram binding lookup", () => {
|
||||
expect(result?.binding.sessionId).toBe("sess-2");
|
||||
});
|
||||
|
||||
it("reuses a binding by participant key across different chats", () => {
|
||||
it("does not reuse a binding by participant key across different chats", () => {
|
||||
const result = __test__.findBindingForThread(
|
||||
{
|
||||
"telegram:user:alice": {
|
||||
@@ -389,7 +389,6 @@ describe("telegram binding lookup", () => {
|
||||
},
|
||||
);
|
||||
|
||||
expect(result?.key).toBe("telegram:user:alice");
|
||||
expect(result?.binding.sessionId).toBe("sess-1");
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -42,7 +42,7 @@ import {
|
||||
type ConnectorBindingStore,
|
||||
type ConnectorThreadState,
|
||||
clearBindingSessionIds,
|
||||
findBindingForParticipantKey,
|
||||
findBindingForDeliveryTarget,
|
||||
findBindingForThread,
|
||||
loadThreadState,
|
||||
persistMergedThreadState,
|
||||
@@ -293,20 +293,20 @@ async function deliverScheduledResult(input: {
|
||||
const threadId =
|
||||
typeof delivery.threadId === "string" ? delivery.threadId.trim() : "";
|
||||
const bindingKey =
|
||||
typeof delivery.bindingKey === "string"
|
||||
? delivery.bindingKey.trim()
|
||||
: typeof delivery.participantKey === "string"
|
||||
? delivery.participantKey.trim()
|
||||
: "";
|
||||
if (!threadId && !bindingKey) {
|
||||
typeof delivery.bindingKey === "string" ? delivery.bindingKey.trim() : "";
|
||||
const participantKey =
|
||||
typeof delivery.participantKey === "string"
|
||||
? delivery.participantKey.trim()
|
||||
: "";
|
||||
if (!threadId && !bindingKey && !participantKey) {
|
||||
return;
|
||||
}
|
||||
const bindings = readBindings<TelegramThreadState>(input.bindingsPath);
|
||||
const match = bindingKey
|
||||
? findBindingForParticipantKey(bindings, bindingKey)
|
||||
: threadId
|
||||
? { key: threadId, binding: bindings[threadId] }
|
||||
: undefined;
|
||||
const match = findBindingForDeliveryTarget(bindings, {
|
||||
bindingKey,
|
||||
threadId,
|
||||
participantKey,
|
||||
});
|
||||
const binding = match?.binding;
|
||||
const deliveryThreadId = match?.key || threadId;
|
||||
if (!binding?.serializedThread) {
|
||||
@@ -788,9 +788,7 @@ class TelegramConnector extends ConnectorBase<
|
||||
thread: Thread<TelegramThreadState>,
|
||||
text: string,
|
||||
) => {
|
||||
const queueKey =
|
||||
(await loadThreadState(thread, bindingsPath, startRequest))
|
||||
.participantKey || thread.id;
|
||||
const queueKey = thread.id;
|
||||
const runTurn = async () => {
|
||||
try {
|
||||
await handleConnectorUserTurn({
|
||||
|
||||
@@ -65,7 +65,7 @@ describe("whatsapp binding lookup", () => {
|
||||
expect(result?.binding.sessionId).toBe("sess-2");
|
||||
});
|
||||
|
||||
it("reuses a binding by participant key across different threads", () => {
|
||||
it("does not reuse a binding by participant key across different threads", () => {
|
||||
const result = __test__.findBindingForThread(
|
||||
{
|
||||
"whatsapp:user:15551234567": {
|
||||
@@ -91,7 +91,6 @@ describe("whatsapp binding lookup", () => {
|
||||
},
|
||||
);
|
||||
|
||||
expect(result?.key).toBe("whatsapp:user:15551234567");
|
||||
expect(result?.binding.sessionId).toBe("sess-1");
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -46,7 +46,7 @@ import {
|
||||
type ConnectorBindingStore,
|
||||
type ConnectorThreadState,
|
||||
clearBindingSessionIds,
|
||||
findBindingForParticipantKey,
|
||||
findBindingForDeliveryTarget,
|
||||
findBindingForThread,
|
||||
loadThreadState,
|
||||
persistMergedThreadState,
|
||||
@@ -226,20 +226,20 @@ async function deliverScheduledResult(input: {
|
||||
const threadId =
|
||||
typeof delivery.threadId === "string" ? delivery.threadId.trim() : "";
|
||||
const bindingKey =
|
||||
typeof delivery.bindingKey === "string"
|
||||
? delivery.bindingKey.trim()
|
||||
: typeof delivery.participantKey === "string"
|
||||
? delivery.participantKey.trim()
|
||||
: "";
|
||||
if (!threadId && !bindingKey) {
|
||||
typeof delivery.bindingKey === "string" ? delivery.bindingKey.trim() : "";
|
||||
const participantKey =
|
||||
typeof delivery.participantKey === "string"
|
||||
? delivery.participantKey.trim()
|
||||
: "";
|
||||
if (!threadId && !bindingKey && !participantKey) {
|
||||
return;
|
||||
}
|
||||
const bindings = readBindings<WhatsAppThreadState>(input.bindingsPath);
|
||||
const match = bindingKey
|
||||
? findBindingForParticipantKey(bindings, bindingKey)
|
||||
: threadId
|
||||
? { key: threadId, binding: bindings[threadId] }
|
||||
: undefined;
|
||||
const match = findBindingForDeliveryTarget(bindings, {
|
||||
bindingKey,
|
||||
threadId,
|
||||
participantKey,
|
||||
});
|
||||
const binding = match?.binding;
|
||||
if (!binding?.serializedThread) {
|
||||
return;
|
||||
@@ -597,9 +597,7 @@ class WhatsAppConnector extends ConnectorBase<
|
||||
thread: Thread<WhatsAppThreadState>,
|
||||
text: string,
|
||||
) => {
|
||||
const queueKey =
|
||||
(await loadThreadState(thread, bindingsPath, startRequest))
|
||||
.participantKey || thread.id;
|
||||
const queueKey = thread.id;
|
||||
const runTurn = async () => {
|
||||
try {
|
||||
await handleConnectorUserTurn({
|
||||
|
||||
@@ -194,6 +194,9 @@ export function spawnDetachedConnector(
|
||||
...withResolvedClineBuildEnv(process.env),
|
||||
[childEnvKey]: "1",
|
||||
},
|
||||
// Prevent a console window from appearing on Windows; detached
|
||||
// processes otherwise allocate a new visible console.
|
||||
windowsHide: true,
|
||||
});
|
||||
logSpawnedProcess({
|
||||
component: options?.component ?? "connectors",
|
||||
|
||||
@@ -92,6 +92,11 @@ function createRuntimeClient(
|
||||
) {
|
||||
const startRuntimeSession = vi.fn(async () => ({ sessionId: "session-1" }));
|
||||
const updateSession = vi.fn(async () => undefined);
|
||||
const getSession = vi.fn(
|
||||
async (sessionId: string): Promise<{ sessionId: string } | undefined> => ({
|
||||
sessionId,
|
||||
}),
|
||||
);
|
||||
const abortRuntimeSession = vi.fn(async () => undefined);
|
||||
const deleteSession = vi.fn(async () => undefined);
|
||||
const sendRuntimeSession = vi.fn(async () => ({
|
||||
@@ -106,6 +111,7 @@ function createRuntimeClient(
|
||||
client: {
|
||||
startRuntimeSession,
|
||||
updateSession,
|
||||
getSession,
|
||||
abortRuntimeSession,
|
||||
stopRuntimeSession: abortRuntimeSession,
|
||||
deleteSession,
|
||||
@@ -115,6 +121,7 @@ function createRuntimeClient(
|
||||
},
|
||||
startRuntimeSession,
|
||||
updateSession,
|
||||
getSession,
|
||||
sendRuntimeSession,
|
||||
readMessages,
|
||||
};
|
||||
@@ -593,7 +600,8 @@ describe("handleConnectorUserTurn", () => {
|
||||
metadata: expect.objectContaining({
|
||||
delivery: expect.objectContaining({
|
||||
adapter: "telegram",
|
||||
bindingKey: "telegram:user:alice",
|
||||
bindingKey: "thread-1",
|
||||
participantKey: "telegram:user:alice",
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
@@ -627,7 +635,8 @@ describe("handleConnectorUserTurn", () => {
|
||||
metadata: {
|
||||
delivery: {
|
||||
adapter: "telegram",
|
||||
bindingKey: "telegram:user:alice",
|
||||
bindingKey: "thread-1",
|
||||
participantKey: "telegram:user:alice",
|
||||
threadId: "thread-1",
|
||||
},
|
||||
},
|
||||
@@ -640,7 +649,8 @@ describe("handleConnectorUserTurn", () => {
|
||||
metadata: {
|
||||
delivery: {
|
||||
adapter: "telegram",
|
||||
bindingKey: "telegram:user:bob",
|
||||
bindingKey: "thread-2",
|
||||
participantKey: "telegram:user:bob",
|
||||
threadId: "thread-2",
|
||||
},
|
||||
},
|
||||
@@ -699,7 +709,8 @@ describe("handleConnectorUserTurn", () => {
|
||||
delivery: expect.objectContaining({
|
||||
adapter: "telegram",
|
||||
threadId: "thread-1",
|
||||
bindingKey: "telegram:user:alice",
|
||||
bindingKey: "thread-1",
|
||||
participantKey: "telegram:user:alice",
|
||||
userName: "ClineAdapterBot",
|
||||
}),
|
||||
}),
|
||||
@@ -1442,7 +1453,7 @@ describe("handleConnectorUserTurn", () => {
|
||||
});
|
||||
const runtime = createRuntimeClient("unused");
|
||||
const activeTurns = new Map([
|
||||
["other-turn-key", { sessionId: "session-1" }],
|
||||
["other-turn-key", { sessionId: "session-1", threadId: "thread-1" }],
|
||||
]);
|
||||
|
||||
await handleConnectorUserTurn({
|
||||
@@ -1478,4 +1489,105 @@ describe("handleConnectorUserTurn", () => {
|
||||
);
|
||||
expect(posts.at(-1)).toEqual({ raw: "Steering current task." });
|
||||
});
|
||||
|
||||
it("starts a normal turn when the active session is in a different thread", async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "connector-host-test-"));
|
||||
tempDirs.push(dir);
|
||||
const bindingsPath = join(dir, "threads.json");
|
||||
const { thread, posts } = createThread({
|
||||
enableTools: true,
|
||||
autoApproveTools: true,
|
||||
cwd: "/tmp/work",
|
||||
workspaceRoot: "/tmp/work",
|
||||
welcomeSentAt: new Date().toISOString(),
|
||||
});
|
||||
const runtime = createRuntimeClient("normal reply");
|
||||
const activeTurns = new Map([
|
||||
["other-thread", { sessionId: "session-1", threadId: "other-thread" }],
|
||||
]);
|
||||
|
||||
await handleConnectorUserTurn({
|
||||
thread: thread as never,
|
||||
text: "start work in this thread",
|
||||
client: runtime.client as never,
|
||||
pendingApprovals: new Map(),
|
||||
baseStartRequest: baseStartRequest() as never,
|
||||
explicitSystemPrompt: undefined,
|
||||
clientId: "client-1",
|
||||
logger: {
|
||||
core: { debug: vi.fn(), log: vi.fn(), error: vi.fn() },
|
||||
} as never,
|
||||
transport: "telegram",
|
||||
botUserName: "ClineAdapterBot",
|
||||
requestStop: vi.fn(),
|
||||
bindingsPath,
|
||||
systemRules: "rules",
|
||||
errorLabel: "Telegram",
|
||||
getSessionMetadata: () => ({}),
|
||||
reusedLogMessage: "reused",
|
||||
activeTurns,
|
||||
turnKey: "thread-1",
|
||||
});
|
||||
|
||||
expect(runtime.startRuntimeSession).toHaveBeenCalled();
|
||||
expect(runtime.sendRuntimeSession).toHaveBeenCalledWith(
|
||||
"session-1",
|
||||
expect.not.objectContaining({
|
||||
delivery: "steer",
|
||||
}),
|
||||
{ timeoutMs: null },
|
||||
);
|
||||
expect(posts.at(-1)).toEqual({ raw: "normal reply" });
|
||||
});
|
||||
|
||||
it("starts a fresh session when persisted thread session is missing from the hub", async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "connector-host-test-"));
|
||||
tempDirs.push(dir);
|
||||
const bindingsPath = join(dir, "threads.json");
|
||||
const { thread, posts, getState } = createThread({
|
||||
sessionId: "stale-session",
|
||||
enableTools: true,
|
||||
autoApproveTools: true,
|
||||
cwd: "/tmp/work",
|
||||
workspaceRoot: "/tmp/work",
|
||||
welcomeSentAt: new Date().toISOString(),
|
||||
});
|
||||
const runtime = createRuntimeClient("fresh reply");
|
||||
runtime.getSession.mockResolvedValueOnce(undefined);
|
||||
|
||||
await handleConnectorUserTurn({
|
||||
thread: thread as never,
|
||||
text: "continue after hub restart",
|
||||
client: runtime.client as never,
|
||||
pendingApprovals: new Map(),
|
||||
baseStartRequest: baseStartRequest() as never,
|
||||
explicitSystemPrompt: undefined,
|
||||
clientId: "client-1",
|
||||
logger: {
|
||||
core: { debug: vi.fn(), log: vi.fn(), error: vi.fn() },
|
||||
} as never,
|
||||
transport: "telegram",
|
||||
botUserName: "ClineAdapterBot",
|
||||
requestStop: vi.fn(),
|
||||
bindingsPath,
|
||||
systemRules: "rules",
|
||||
errorLabel: "Telegram",
|
||||
getSessionMetadata: () => ({}),
|
||||
reusedLogMessage: "reused",
|
||||
startedLogMessage: "started",
|
||||
turnKey: "thread-1",
|
||||
});
|
||||
|
||||
expect(runtime.getSession).toHaveBeenCalledWith("stale-session");
|
||||
expect(runtime.startRuntimeSession).toHaveBeenCalled();
|
||||
expect(runtime.sendRuntimeSession).toHaveBeenCalledWith(
|
||||
"session-1",
|
||||
expect.not.objectContaining({
|
||||
delivery: "steer",
|
||||
}),
|
||||
{ timeoutMs: null },
|
||||
);
|
||||
expect(getState().sessionId).toBe("session-1");
|
||||
expect(posts.at(-1)).toEqual({ raw: "fresh reply" });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -749,9 +749,7 @@ export async function handleConnectorUserTurn<
|
||||
`channelId=${input.thread.channelId}`,
|
||||
`deliveryAdapter=${input.transport}`,
|
||||
`deliveryThread=${input.thread.id}`,
|
||||
...(effectiveCurrent.participantKey
|
||||
? [`deliveryBindingKey=${effectiveCurrent.participantKey}`]
|
||||
: []),
|
||||
`deliveryBindingKey=${input.thread.id}`,
|
||||
`deliveryChannel=${input.thread.channelId}`,
|
||||
...(input.botUserName
|
||||
? [`deliveryUserName=${input.botUserName}`]
|
||||
@@ -789,11 +787,9 @@ export async function handleConnectorUserTurn<
|
||||
delivery: {
|
||||
adapter: input.transport,
|
||||
threadId: input.thread.id,
|
||||
bindingKey: input.thread.id,
|
||||
...(current.participantKey
|
||||
? {
|
||||
bindingKey: current.participantKey,
|
||||
participantKey: current.participantKey,
|
||||
}
|
||||
? { participantKey: current.participantKey }
|
||||
: {}),
|
||||
...(current.participantLabel
|
||||
? { participantLabel: current.participantLabel }
|
||||
@@ -832,11 +828,6 @@ export async function handleConnectorUserTurn<
|
||||
].join("\n");
|
||||
},
|
||||
list: async () => {
|
||||
const current = await loadThreadState(
|
||||
input.thread,
|
||||
input.bindingsPath,
|
||||
input.baseStartRequest,
|
||||
);
|
||||
const schedules = await input.client.listSchedules({ limit: 200 });
|
||||
const matching = schedules.filter((schedule) => {
|
||||
const delivery = schedule.metadata?.delivery;
|
||||
@@ -846,17 +837,9 @@ export async function handleConnectorUserTurn<
|
||||
!Array.isArray(delivery)
|
||||
? (delivery as Record<string, unknown>)
|
||||
: undefined;
|
||||
const deliveryBindingKey =
|
||||
typeof deliveryRecord?.bindingKey === "string"
|
||||
? deliveryRecord.bindingKey
|
||||
: typeof deliveryRecord?.participantKey === "string"
|
||||
? deliveryRecord.participantKey
|
||||
: undefined;
|
||||
return (
|
||||
deliveryRecord?.adapter === input.transport &&
|
||||
(current.participantKey
|
||||
? deliveryBindingKey === current.participantKey
|
||||
: deliveryRecord.threadId === input.thread.id)
|
||||
deliveryRecord.threadId === input.thread.id
|
||||
);
|
||||
});
|
||||
if (matching.length === 0) {
|
||||
@@ -913,7 +896,9 @@ export async function handleConnectorUserTurn<
|
||||
input.activeTurns?.get(turnKey) ??
|
||||
(input.activeTurns && currentState.sessionId?.trim()
|
||||
? Array.from(input.activeTurns.values()).find(
|
||||
(turn) => turn.sessionId === currentState.sessionId?.trim(),
|
||||
(turn) =>
|
||||
turn.sessionId === currentState.sessionId?.trim() &&
|
||||
turn.threadId === input.thread.id,
|
||||
)
|
||||
: undefined);
|
||||
if (activeTurn?.sessionId?.trim()) {
|
||||
|
||||
@@ -159,36 +159,57 @@ export async function getOrCreateSessionId<
|
||||
);
|
||||
const existing = threadState.sessionId?.trim();
|
||||
if (existing) {
|
||||
const existingSession = await input.client.getSession(existing);
|
||||
if (existingSession) {
|
||||
await persistMergedThreadState(
|
||||
input.thread,
|
||||
input.bindingsPath,
|
||||
{
|
||||
...threadState,
|
||||
sessionId: existing,
|
||||
},
|
||||
input.errorLabel,
|
||||
);
|
||||
input.logger.core.log(input.reusedLogMessage, {
|
||||
transport: input.transport,
|
||||
threadId: input.thread.id,
|
||||
sessionId: existing,
|
||||
});
|
||||
await dispatchConnectorHook(
|
||||
input.hookCommand,
|
||||
{
|
||||
adapter: input.transport,
|
||||
botUserName: input.hookBotUserName,
|
||||
event: "session.reused",
|
||||
payload: {
|
||||
threadId: input.thread.id,
|
||||
channelId: input.thread.channelId,
|
||||
sessionId: existing,
|
||||
},
|
||||
ts: new Date().toISOString(),
|
||||
},
|
||||
input.logger,
|
||||
);
|
||||
return existing;
|
||||
}
|
||||
await persistMergedThreadState(
|
||||
input.thread,
|
||||
input.bindingsPath,
|
||||
{
|
||||
...threadState,
|
||||
sessionId: existing,
|
||||
sessionId: undefined,
|
||||
},
|
||||
input.errorLabel,
|
||||
);
|
||||
input.logger.core.log(input.reusedLogMessage, {
|
||||
transport: input.transport,
|
||||
threadId: input.thread.id,
|
||||
sessionId: existing,
|
||||
});
|
||||
await dispatchConnectorHook(
|
||||
input.hookCommand,
|
||||
input.logger.core.log(
|
||||
"Connector thread session missing; starting a new session",
|
||||
{
|
||||
adapter: input.transport,
|
||||
botUserName: input.hookBotUserName,
|
||||
event: "session.reused",
|
||||
payload: {
|
||||
threadId: input.thread.id,
|
||||
channelId: input.thread.channelId,
|
||||
sessionId: existing,
|
||||
},
|
||||
ts: new Date().toISOString(),
|
||||
severity: "warn",
|
||||
transport: input.transport,
|
||||
threadId: input.thread.id,
|
||||
sessionId: existing,
|
||||
},
|
||||
input.logger,
|
||||
);
|
||||
return existing;
|
||||
}
|
||||
|
||||
const started = await input.client.startRuntimeSession(input.startRequest);
|
||||
|
||||
@@ -6,6 +6,7 @@ import { afterEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
type ConnectorThreadState,
|
||||
clearBindingSessionIds,
|
||||
findBindingForDeliveryTarget,
|
||||
isParticipantMuted,
|
||||
isThreadMuted,
|
||||
readBindingForThread,
|
||||
@@ -52,16 +53,16 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe("thread binding refresh", () => {
|
||||
it("refreshes the serialized thread immediately when channel fallback rebinds a thread id", () => {
|
||||
it("refreshes the serialized thread immediately when DM channel fallback rebinds a thread id", () => {
|
||||
const path = createBindingsPath();
|
||||
writeBindings<TestState>(path, {
|
||||
legacy_thread_id: {
|
||||
channelId: "slack:C123",
|
||||
isDM: false,
|
||||
isDM: true,
|
||||
serializedThread: JSON.stringify({
|
||||
id: "legacy_thread_id",
|
||||
channelId: "slack:C123",
|
||||
isDM: false,
|
||||
isDM: true,
|
||||
}),
|
||||
sessionId: "sess-1",
|
||||
state: { sessionId: "sess-1", teamId: "T123" },
|
||||
@@ -74,7 +75,7 @@ describe("thread binding refresh", () => {
|
||||
createThread({
|
||||
id: "new_thread_id",
|
||||
channelId: "slack:C123",
|
||||
isDM: false,
|
||||
isDM: true,
|
||||
}),
|
||||
"Slack",
|
||||
);
|
||||
@@ -85,7 +86,7 @@ describe("thread binding refresh", () => {
|
||||
expect(bindings.new_thread_id?.serializedThread).toContain("new_thread_id");
|
||||
});
|
||||
|
||||
it("refreshes the serialized thread when a participant-key binding matches a new thread id", () => {
|
||||
it("does not rebind a different thread by participant key", () => {
|
||||
const path = createBindingsPath();
|
||||
const participantKey = "slack:team:T123:user:U123";
|
||||
writeBindings<TestState>(path, {
|
||||
@@ -119,10 +120,69 @@ describe("thread binding refresh", () => {
|
||||
participantKey,
|
||||
);
|
||||
|
||||
expect(binding?.serializedThread).toContain("new_thread_id");
|
||||
expect(binding).toBeUndefined();
|
||||
expect(
|
||||
readBindings<TestState>(path)[participantKey]?.serializedThread,
|
||||
).toContain("new_thread_id");
|
||||
).toContain("legacy_thread_id");
|
||||
});
|
||||
|
||||
it("resolves schedule delivery targets by exact binding key before participant metadata", () => {
|
||||
const path = createBindingsPath();
|
||||
writeBindings<TestState>(path, {
|
||||
"slack:C123:111.222": {
|
||||
kind: "conversation",
|
||||
channelId: "slack:C123",
|
||||
isDM: false,
|
||||
participantKey: "slack:team:T123:user:U123",
|
||||
serializedThread: "{}",
|
||||
sessionId: "sess-thread",
|
||||
state: {
|
||||
sessionId: "sess-thread",
|
||||
participantKey: "slack:team:T123:user:U123",
|
||||
},
|
||||
updatedAt: "2026-03-17T00:00:00.000Z",
|
||||
},
|
||||
});
|
||||
|
||||
const match = findBindingForDeliveryTarget<TestState>(
|
||||
readBindings<TestState>(path),
|
||||
{
|
||||
bindingKey: "slack:C123:111.222",
|
||||
threadId: "slack:C123:111.222",
|
||||
participantKey: "slack:team:T123:user:U123",
|
||||
},
|
||||
);
|
||||
|
||||
expect(match?.key).toBe("slack:C123:111.222");
|
||||
expect(match?.binding.sessionId).toBe("sess-thread");
|
||||
});
|
||||
|
||||
it("resolves schedule delivery targets by participant key when no exact thread binding exists", () => {
|
||||
const path = createBindingsPath();
|
||||
writeBindings<TestState>(path, {
|
||||
"slack:team:T123:user:U123": {
|
||||
channelId: "slack:C123",
|
||||
isDM: true,
|
||||
participantKey: "slack:team:T123:user:U123",
|
||||
serializedThread: "{}",
|
||||
sessionId: "sess-participant",
|
||||
state: {
|
||||
sessionId: "sess-participant",
|
||||
participantKey: "slack:team:T123:user:U123",
|
||||
},
|
||||
updatedAt: "2026-03-17T00:00:00.000Z",
|
||||
},
|
||||
});
|
||||
|
||||
const match = findBindingForDeliveryTarget<TestState>(
|
||||
readBindings<TestState>(path),
|
||||
{
|
||||
participantKey: "slack:team:T123:user:U123",
|
||||
},
|
||||
);
|
||||
|
||||
expect(match?.key).toBe("slack:team:T123:user:U123");
|
||||
expect(match?.binding.sessionId).toBe("sess-participant");
|
||||
});
|
||||
|
||||
it("stores mute state at thread scope instead of participant scope", () => {
|
||||
|
||||
@@ -14,7 +14,7 @@ export type ConnectorThreadState = {
|
||||
};
|
||||
|
||||
export type ConnectorThreadBinding<TState extends ConnectorThreadState> = {
|
||||
kind?: "participant" | "thread" | "thread-participant-mute";
|
||||
kind?: "conversation" | "participant" | "thread" | "thread-participant-mute";
|
||||
channelId: string;
|
||||
isDM: boolean;
|
||||
participantKey?: string;
|
||||
@@ -134,12 +134,9 @@ function clearSerializedThreadSessionId(serializedThread: string | undefined): {
|
||||
|
||||
export function resolveThreadBindingKey(
|
||||
thread: ConnectorBindingThreadIdentity,
|
||||
state?: ConnectorThreadState | null,
|
||||
_state?: ConnectorThreadState | null,
|
||||
): string {
|
||||
return (
|
||||
normalizeParticipantKey(state?.participantKey ?? thread.participantKey) ??
|
||||
thread.id
|
||||
);
|
||||
return thread.id;
|
||||
}
|
||||
|
||||
export function readBindings<TState extends ConnectorThreadState>(
|
||||
@@ -160,40 +157,13 @@ export function findBindingForThread<TState extends ConnectorThreadState>(
|
||||
bindings: ConnectorBindingStore<TState>,
|
||||
thread: ConnectorBindingThreadIdentity,
|
||||
): { binding: ConnectorThreadBinding<TState>; key: string } | undefined {
|
||||
const participantKey = normalizeParticipantKey(thread.participantKey);
|
||||
if (participantKey) {
|
||||
const exactThread = bindings[thread.id];
|
||||
const exactThreadParticipantKey = normalizeParticipantKey(
|
||||
exactThread?.participantKey ?? exactThread?.state?.participantKey,
|
||||
);
|
||||
if (
|
||||
exactThread &&
|
||||
!isControlBinding(exactThread) &&
|
||||
exactThreadParticipantKey === participantKey
|
||||
) {
|
||||
return { key: thread.id, binding: exactThread };
|
||||
}
|
||||
const exactParticipant = bindings[participantKey];
|
||||
if (exactParticipant && !isControlBinding(exactParticipant)) {
|
||||
return { key: participantKey, binding: exactParticipant };
|
||||
}
|
||||
for (const [key, binding] of Object.entries(bindings)) {
|
||||
if (isControlBinding(binding)) {
|
||||
continue;
|
||||
}
|
||||
const bindingParticipantKey = normalizeParticipantKey(
|
||||
binding.participantKey ?? binding.state?.participantKey,
|
||||
);
|
||||
if (bindingParticipantKey === participantKey) {
|
||||
return { key, binding };
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
const exact = bindings[thread.id];
|
||||
if (exact && !isControlBinding(exact)) {
|
||||
return { key: thread.id, binding: exact };
|
||||
}
|
||||
if (!thread.isDM) {
|
||||
return undefined;
|
||||
}
|
||||
for (const [key, binding] of Object.entries(bindings)) {
|
||||
if (isControlBinding(binding)) {
|
||||
continue;
|
||||
@@ -282,29 +252,8 @@ export function persistThreadBinding<TState extends ConnectorThreadState>(
|
||||
thread as ConnectorBindingThreadIdentity,
|
||||
state,
|
||||
);
|
||||
for (const [key, binding] of Object.entries(bindings)) {
|
||||
if (isControlBinding(binding)) {
|
||||
continue;
|
||||
}
|
||||
const bindingParticipantKey = normalizeParticipantKey(
|
||||
binding.participantKey ?? binding.state?.participantKey,
|
||||
);
|
||||
const matchesParticipant =
|
||||
participantKey && bindingParticipantKey === participantKey;
|
||||
const matchesLegacyKey = participantKey && key === thread.id;
|
||||
const matchesLegacyThread =
|
||||
!participantKey &&
|
||||
binding.channelId === thread.channelId &&
|
||||
binding.isDM === thread.isDM;
|
||||
if (
|
||||
key !== bindingKey &&
|
||||
(matchesParticipant || matchesLegacyKey || matchesLegacyThread)
|
||||
) {
|
||||
delete bindings[key];
|
||||
}
|
||||
}
|
||||
bindings[bindingKey] = {
|
||||
kind: "participant",
|
||||
kind: "conversation",
|
||||
channelId: thread.channelId,
|
||||
isDM: thread.isDM,
|
||||
participantKey,
|
||||
@@ -531,6 +480,37 @@ export function findBindingForParticipantKey<
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function findBindingForDeliveryTarget<
|
||||
TState extends ConnectorThreadState,
|
||||
>(
|
||||
bindings: ConnectorBindingStore<TState>,
|
||||
input: {
|
||||
bindingKey?: string;
|
||||
threadId?: string;
|
||||
participantKey?: string;
|
||||
},
|
||||
): { binding: ConnectorThreadBinding<TState>; key: string } | undefined {
|
||||
const bindingKey = normalizeParticipantKey(input.bindingKey);
|
||||
if (bindingKey) {
|
||||
const exact = bindings[bindingKey];
|
||||
if (exact && !isControlBinding(exact)) {
|
||||
return { key: bindingKey, binding: exact };
|
||||
}
|
||||
const participantMatch = findBindingForParticipantKey(bindings, bindingKey);
|
||||
if (participantMatch) {
|
||||
return participantMatch;
|
||||
}
|
||||
}
|
||||
const threadId = input.threadId?.trim();
|
||||
if (threadId) {
|
||||
const exact = bindings[threadId];
|
||||
if (exact && !isControlBinding(exact)) {
|
||||
return { key: threadId, binding: exact };
|
||||
}
|
||||
}
|
||||
return findBindingForParticipantKey(bindings, input.participantKey);
|
||||
}
|
||||
|
||||
export async function persistMergedThreadState<
|
||||
TState extends ConnectorThreadState,
|
||||
>(
|
||||
|
||||
@@ -152,6 +152,7 @@ export async function runCli(): Promise<void> {
|
||||
.option("-k, --apikey <key>", "API key")
|
||||
.option("-m, --modelid <id>", "Model ID")
|
||||
.option("-b, --baseurl <url>", "Base URL")
|
||||
.option("--azure-api-version <version>", "Azure API version")
|
||||
.option("--config <dir>", "configuration directory")
|
||||
.option("-c, --cwd <path>", "Working directory")
|
||||
.option(
|
||||
@@ -165,6 +166,7 @@ export async function runCli(): Promise<void> {
|
||||
apikey?: string;
|
||||
modelid?: string;
|
||||
baseurl?: string;
|
||||
azureApiVersion?: string;
|
||||
config?: string;
|
||||
cwd?: string;
|
||||
dataDir?: string;
|
||||
@@ -195,6 +197,7 @@ export async function runCli(): Promise<void> {
|
||||
apikey: opts.apikey,
|
||||
modelid: opts.modelid,
|
||||
baseurl: opts.baseurl,
|
||||
azureApiVersion: opts.azureApiVersion,
|
||||
io,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -26,7 +26,6 @@ test.describe("root flag descriptions", () => {
|
||||
"Set reasoning effort level",
|
||||
"consecutive mistakes",
|
||||
"Output messages as JSON",
|
||||
"ACP",
|
||||
"Check for updates and install if available",
|
||||
"Run the kanban app",
|
||||
]);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { Config } from "../utils/types";
|
||||
|
||||
const coreMocks = vi.hoisted(() => {
|
||||
@@ -9,13 +9,14 @@ const coreMocks = vi.hoisted(() => {
|
||||
return {
|
||||
getProviderSettings: vi.fn(),
|
||||
saveProviderSettings: vi.fn(),
|
||||
getValidClineCredentials: vi.fn(),
|
||||
serviceOptions,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@cline/core", () => {
|
||||
vi.mock("@cline/core", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@cline/core")>();
|
||||
return {
|
||||
...actual,
|
||||
ClineAccountService: class {
|
||||
constructor(options: {
|
||||
apiBaseUrl: string;
|
||||
@@ -32,7 +33,6 @@ vi.mock("@cline/core", () => {
|
||||
coreMocks.saveProviderSettings(settings, options);
|
||||
}
|
||||
},
|
||||
getValidClineCredentials: coreMocks.getValidClineCredentials,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -59,15 +59,51 @@ function makeConfig(overrides: Partial<Config> = {}): Config {
|
||||
} as unknown as Config;
|
||||
}
|
||||
|
||||
function mockFetchJson(body: unknown, status = 200): void {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(
|
||||
async () =>
|
||||
new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}),
|
||||
) as unknown as typeof fetch,
|
||||
);
|
||||
}
|
||||
|
||||
describe("createClineAccountService", () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
coreMocks.getProviderSettings.mockReset();
|
||||
coreMocks.saveProviderSettings.mockReset();
|
||||
coreMocks.getValidClineCredentials.mockReset();
|
||||
coreMocks.serviceOptions.length = 0;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("refreshes persisted Cline OAuth credentials before creating the account service", async () => {
|
||||
vi.spyOn(Date, "now").mockReturnValue(100_000);
|
||||
mockFetchJson({
|
||||
success: true,
|
||||
data: {
|
||||
accessToken: "new-access",
|
||||
refreshToken: "new-refresh",
|
||||
tokenType: "Bearer",
|
||||
expiresAt: "2096-10-02T07:06:40.000Z",
|
||||
userInfo: {
|
||||
subject: "sub-new",
|
||||
email: "new@example.com",
|
||||
name: "New User",
|
||||
clineUserId: "acct-new",
|
||||
accounts: [],
|
||||
},
|
||||
},
|
||||
});
|
||||
coreMocks.getProviderSettings.mockReturnValue({
|
||||
provider: "cline",
|
||||
auth: {
|
||||
@@ -77,26 +113,12 @@ describe("createClineAccountService", () => {
|
||||
expiresAt: 1,
|
||||
},
|
||||
});
|
||||
coreMocks.getValidClineCredentials.mockResolvedValue({
|
||||
access: "new-access",
|
||||
refresh: "new-refresh",
|
||||
expires: 4_000_000_000_000,
|
||||
accountId: "acct-new",
|
||||
});
|
||||
|
||||
const { createClineAccountService } = await import("./cline-account");
|
||||
const service = await createClineAccountService({ config: makeConfig() });
|
||||
|
||||
expect(service).toBeDefined();
|
||||
expect(coreMocks.getValidClineCredentials).toHaveBeenCalledWith(
|
||||
{
|
||||
access: "old-access",
|
||||
refresh: "refresh-token",
|
||||
expires: 1,
|
||||
accountId: "acct-old",
|
||||
},
|
||||
{ apiBaseUrl: "https://api.cline.bot" },
|
||||
);
|
||||
expect(globalThis.fetch).toHaveBeenCalled();
|
||||
expect(coreMocks.saveProviderSettings).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
provider: "cline",
|
||||
@@ -115,6 +137,14 @@ describe("createClineAccountService", () => {
|
||||
});
|
||||
|
||||
it("asks the user to re-authenticate when Cline OAuth credentials cannot refresh", async () => {
|
||||
vi.spyOn(Date, "now").mockReturnValue(100_000);
|
||||
mockFetchJson(
|
||||
{
|
||||
error: "invalid_grant",
|
||||
error_description: "refresh expired",
|
||||
},
|
||||
401,
|
||||
);
|
||||
coreMocks.getProviderSettings.mockReturnValue({
|
||||
provider: "cline",
|
||||
auth: {
|
||||
@@ -123,7 +153,6 @@ describe("createClineAccountService", () => {
|
||||
expiresAt: 1,
|
||||
},
|
||||
});
|
||||
coreMocks.getValidClineCredentials.mockResolvedValue(null);
|
||||
|
||||
const { createClineAccountService } = await import("./cline-account");
|
||||
|
||||
|
||||
@@ -4,16 +4,20 @@ import {
|
||||
type ClineAccountOrganizationBalance,
|
||||
ClineAccountService,
|
||||
type ClineAccountUser,
|
||||
formatProviderOAuthApiKey,
|
||||
getPersistedProviderApiKey,
|
||||
getProviderOAuthCredentialsFromSettings,
|
||||
getValidClineCredentials,
|
||||
type ProviderSettings,
|
||||
ProviderSettingsManager,
|
||||
saveLocalProviderOAuthCredentials,
|
||||
} from "@cline/core";
|
||||
import { getClineEnvironmentConfig } from "@cline/shared";
|
||||
import { formatCreditBalance, normalizeCreditBalance } from "../utils/output";
|
||||
import { toProviderApiKey } from "../utils/provider-auth";
|
||||
import type { Config } from "../utils/types";
|
||||
|
||||
const WORKOS_TOKEN_PREFIX = "workos:";
|
||||
export const CLINE_CREDITS_DASHBOARD_URL =
|
||||
"https://app.cline.bot/dashboard/account?tab=credits";
|
||||
|
||||
type ClineAccountConfig = Pick<Config, "apiKey" | "providerId">;
|
||||
|
||||
@@ -30,6 +34,8 @@ export function formatClineCredits(value: number): string {
|
||||
return formatCreditBalance(normalizeCreditBalance(value));
|
||||
}
|
||||
|
||||
// FIXME: These message checks are temporary until structured error types are
|
||||
// passed through to the CLI instead of plain error strings.
|
||||
export function isClineAccountAuthErrorMessage(message: string): boolean {
|
||||
const normalized = message.trim().toLowerCase();
|
||||
return (
|
||||
@@ -38,6 +44,14 @@ export function isClineAccountAuthErrorMessage(message: string): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
export function isClineAccountCreditsErrorMessage(message: string): boolean {
|
||||
const normalized = message.trim().toLowerCase();
|
||||
return (
|
||||
normalized.includes("insufficient balance") &&
|
||||
normalized.includes("cline credits balance")
|
||||
);
|
||||
}
|
||||
|
||||
function resolveAccountApiBaseUrl(input: {
|
||||
clineApiBaseUrl?: string;
|
||||
clineProviderSettings?: ProviderSettings;
|
||||
@@ -57,26 +71,13 @@ function resolveClineAccountAuthToken(input: {
|
||||
config: ClineAccountConfig;
|
||||
clineProviderSettings?: ProviderSettings;
|
||||
}): string | undefined {
|
||||
const persistedAccessToken =
|
||||
input.clineProviderSettings?.auth?.accessToken?.trim() || "";
|
||||
const configApiKey =
|
||||
input.config.providerId === "cline" ? input.config.apiKey.trim() : "";
|
||||
const settingsApiKey =
|
||||
input.clineProviderSettings?.apiKey?.trim() ||
|
||||
input.clineProviderSettings?.auth?.apiKey?.trim() ||
|
||||
"";
|
||||
|
||||
let authToken = persistedAccessToken || configApiKey || settingsApiKey;
|
||||
if (authToken.toLowerCase().startsWith("workos:workos:")) {
|
||||
authToken = authToken.slice("workos:".length);
|
||||
}
|
||||
return authToken || undefined;
|
||||
}
|
||||
|
||||
function stripWorkosTokenPrefix(accessToken: string): string {
|
||||
return accessToken.toLowerCase().startsWith(WORKOS_TOKEN_PREFIX)
|
||||
? accessToken.slice(WORKOS_TOKEN_PREFIX.length)
|
||||
: accessToken;
|
||||
return (
|
||||
getPersistedProviderApiKey("cline", input.clineProviderSettings) ||
|
||||
configApiKey ||
|
||||
undefined
|
||||
);
|
||||
}
|
||||
|
||||
async function resolveValidClineAccountAuthToken(input: {
|
||||
@@ -86,43 +87,26 @@ async function resolveValidClineAccountAuthToken(input: {
|
||||
apiBaseUrl: string;
|
||||
}): Promise<string | undefined> {
|
||||
const settings = input.clineProviderSettings;
|
||||
const auth = settings?.auth;
|
||||
const accessToken = auth?.accessToken?.trim();
|
||||
const refreshToken = auth?.refreshToken?.trim();
|
||||
if (settings && auth && accessToken && refreshToken) {
|
||||
const credentials = await getValidClineCredentials(
|
||||
{
|
||||
access: stripWorkosTokenPrefix(accessToken),
|
||||
refresh: refreshToken,
|
||||
expires: auth.expiresAt ?? Date.now() - 1,
|
||||
accountId: auth.accountId,
|
||||
},
|
||||
{ apiBaseUrl: input.apiBaseUrl },
|
||||
);
|
||||
if (!credentials) {
|
||||
const credentials = settings
|
||||
? getProviderOAuthCredentialsFromSettings("cline", settings)
|
||||
: null;
|
||||
if (settings && credentials) {
|
||||
const nextCredentials = await getValidClineCredentials(credentials, {
|
||||
apiBaseUrl: input.apiBaseUrl,
|
||||
});
|
||||
if (!nextCredentials) {
|
||||
throw new Error(
|
||||
"Cline account requires re-authentication. Run cline auth cline.",
|
||||
);
|
||||
}
|
||||
const nextAccessToken = toProviderApiKey("cline", credentials);
|
||||
if (
|
||||
nextAccessToken !== accessToken ||
|
||||
credentials.refresh !== refreshToken ||
|
||||
credentials.accountId !== auth.accountId ||
|
||||
credentials.expires !== auth.expiresAt
|
||||
) {
|
||||
input.manager.saveProviderSettings(
|
||||
{
|
||||
...settings,
|
||||
auth: {
|
||||
...(settings.auth ?? {}),
|
||||
accessToken: nextAccessToken,
|
||||
refreshToken: credentials.refresh,
|
||||
accountId: credentials.accountId,
|
||||
expiresAt: credentials.expires,
|
||||
},
|
||||
},
|
||||
{ setLastUsed: false, tokenSource: "oauth" },
|
||||
const nextAccessToken = formatProviderOAuthApiKey("cline", nextCredentials);
|
||||
if (nextCredentials !== credentials) {
|
||||
saveLocalProviderOAuthCredentials(
|
||||
input.manager,
|
||||
"cline",
|
||||
settings,
|
||||
nextCredentials,
|
||||
{ setLastUsed: false },
|
||||
);
|
||||
}
|
||||
return nextAccessToken;
|
||||
|
||||
@@ -2,6 +2,10 @@ import { useTerminalDimensions } from "@opentui/react";
|
||||
import type React from "react";
|
||||
import { useState } from "react";
|
||||
import "opentui-spinner/react";
|
||||
import {
|
||||
CLINE_CREDITS_DASHBOARD_URL,
|
||||
isClineAccountCreditsErrorMessage,
|
||||
} from "../cline-account";
|
||||
import { useTerminalBackground } from "../hooks/use-terminal-background";
|
||||
import {
|
||||
getDefaultForeground,
|
||||
@@ -256,6 +260,36 @@ function ToolCallView(props: {
|
||||
);
|
||||
}
|
||||
|
||||
function ClineCreditsErrorView(props: { defaultFg?: string }) {
|
||||
return (
|
||||
<box flexDirection="row">
|
||||
<text fg="red" content="* " />
|
||||
<box
|
||||
flexDirection="column"
|
||||
border
|
||||
borderStyle="rounded"
|
||||
borderColor="red"
|
||||
paddingX={1}
|
||||
>
|
||||
<text fg="red">Cline Credits depleted</text>
|
||||
<text
|
||||
fg={props.defaultFg}
|
||||
selectable
|
||||
content="You have run out of Cline credits. Add credits in the dashboard to continue."
|
||||
/>
|
||||
<box flexDirection="row">
|
||||
<text fg="gray">Dashboard: </text>
|
||||
<text fg="cyan" selectable>
|
||||
<a href={CLINE_CREDITS_DASHBOARD_URL}>
|
||||
{CLINE_CREDITS_DASHBOARD_URL}
|
||||
</a>
|
||||
</text>
|
||||
</box>
|
||||
</box>
|
||||
</box>
|
||||
);
|
||||
}
|
||||
|
||||
export function ChatEntryView(props: {
|
||||
entry: ChatEntry;
|
||||
accent?: string;
|
||||
@@ -351,6 +385,9 @@ export function ChatEntryView(props: {
|
||||
);
|
||||
|
||||
case "error":
|
||||
if (isClineAccountCreditsErrorMessage(entry.text)) {
|
||||
return <ClineCreditsErrorView defaultFg={defaultFg} />;
|
||||
}
|
||||
return (
|
||||
<box flexDirection="row">
|
||||
<text fg="red" content="* " />
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
completeClineDeviceAuth,
|
||||
getProviderConfigFields,
|
||||
isOAuthProvider,
|
||||
listLocalProviders,
|
||||
loginLocalProvider,
|
||||
type ProviderConfigFieldKey,
|
||||
@@ -21,12 +22,13 @@ import {
|
||||
checkCodexCliInstalled,
|
||||
isOpenAICodexCliProvider,
|
||||
} from "../../../utils/codex-cli";
|
||||
import { isOAuthProvider } from "../../../utils/provider-auth";
|
||||
import { palette } from "../../palette";
|
||||
import {
|
||||
getDefaultAwsRegion,
|
||||
type ProviderConfigValues,
|
||||
resolveProviderConfigAwsRegion,
|
||||
resolveProviderConfigAzure,
|
||||
resolveProviderConfigGcp,
|
||||
resolveProviderConfigSap,
|
||||
updateProviderConfigValue,
|
||||
} from "../../utils/provider-config-values";
|
||||
@@ -315,8 +317,11 @@ export function UseExistingOrReconfigureContent(
|
||||
const DEFAULT_FIELD_LABELS: Partial<Record<ProviderConfigFieldKey, string>> = {
|
||||
apiKey: "API key",
|
||||
baseUrl: "Base URL",
|
||||
azureApiVersion: "Azure API Version",
|
||||
awsRegion: "AWS Region",
|
||||
awsProfile: "AWS Profile Name",
|
||||
gcpProjectId: "Google Cloud Project ID",
|
||||
gcpRegion: "Google Cloud Region",
|
||||
sapClientId: "Client ID",
|
||||
sapClientSecret: "Client Secret",
|
||||
sapTokenUrl: "Token URL",
|
||||
@@ -329,8 +334,11 @@ const DEFAULT_FIELD_PLACEHOLDERS: Partial<
|
||||
> = {
|
||||
apiKey: "sk-...",
|
||||
baseUrl: "",
|
||||
azureApiVersion: "2025-01-01-preview",
|
||||
awsRegion: "us-east-1",
|
||||
awsProfile: "default",
|
||||
gcpProjectId: "my-gcp-project",
|
||||
gcpRegion: "us-central1",
|
||||
sapClientId: "sb-...|xsuaa_std!b...",
|
||||
sapClientSecret: "SAP AI Core client secret",
|
||||
sapTokenUrl: "https://<subdomain>.authentication.sap.hana.ondemand.com",
|
||||
@@ -341,7 +349,10 @@ const DEFAULT_FIELD_PLACEHOLDERS: Partial<
|
||||
/** Render order for cycling focus with Tab. */
|
||||
const FIELD_ORDER: ProviderConfigFieldKey[] = [
|
||||
"awsRegion",
|
||||
"gcpProjectId",
|
||||
"gcpRegion",
|
||||
"baseUrl",
|
||||
"azureApiVersion",
|
||||
"apiKey",
|
||||
"awsProfile",
|
||||
"sapClientId",
|
||||
@@ -398,11 +409,22 @@ export function ProviderConfigInputContent(
|
||||
config.fields.baseUrl?.defaultValue ??
|
||||
"";
|
||||
}
|
||||
if (config.fields.azureApiVersion) {
|
||||
initial.azureApiVersion =
|
||||
existingSettings?.azure?.apiVersion?.trim() ?? "";
|
||||
}
|
||||
if (config.fields.awsRegion) {
|
||||
const ep = existingSettings?.aws?.profile?.trim() ?? "";
|
||||
initial.awsRegion =
|
||||
existingSettings?.aws?.region?.trim() || getDefaultAwsRegion(ep);
|
||||
}
|
||||
if (config.fields.gcpProjectId)
|
||||
initial.gcpProjectId = existingSettings?.gcp?.projectId?.trim() ?? "";
|
||||
if (config.fields.gcpRegion)
|
||||
initial.gcpRegion =
|
||||
existingSettings?.gcp?.region?.trim() ??
|
||||
config.fields.gcpRegion.defaultValue ??
|
||||
"us-central1";
|
||||
if (config.fields.apiKey)
|
||||
initial.apiKey = existingSettings?.apiKey?.trim() ?? "";
|
||||
if (config.fields.awsProfile)
|
||||
@@ -430,7 +452,9 @@ export function ProviderConfigInputContent(
|
||||
const submit = () => {
|
||||
const apiKey = values.apiKey?.trim();
|
||||
const awsProfile = values.awsProfile?.trim();
|
||||
const hasAzureFields = config.fields.azureApiVersion;
|
||||
const hasAwsFields = config.fields.awsRegion || config.fields.awsProfile;
|
||||
const hasGcpFields = config.fields.gcpProjectId || config.fields.gcpRegion;
|
||||
const hasSapFields =
|
||||
config.fields.sapClientId ||
|
||||
config.fields.sapClientSecret ||
|
||||
@@ -441,6 +465,7 @@ export function ProviderConfigInputContent(
|
||||
providerId,
|
||||
apiKey: config.fields.apiKey ? apiKey : undefined,
|
||||
baseUrl: config.fields.baseUrl ? values.baseUrl?.trim() : undefined,
|
||||
azure: hasAzureFields ? resolveProviderConfigAzure(values) : undefined,
|
||||
aws: hasAwsFields
|
||||
? {
|
||||
region: resolveProviderConfigAwsRegion(values),
|
||||
@@ -448,6 +473,7 @@ export function ProviderConfigInputContent(
|
||||
profile: apiKey ? undefined : awsProfile || undefined,
|
||||
}
|
||||
: undefined,
|
||||
gcp: hasGcpFields ? resolveProviderConfigGcp(values) : undefined,
|
||||
sap: hasSapFields ? resolveProviderConfigSap(values) : undefined,
|
||||
});
|
||||
resolve(true);
|
||||
@@ -671,7 +697,7 @@ export function OAuthLoginContent(
|
||||
if (!isActiveAuthAttempt(attempt)) return;
|
||||
saveLocalProviderOAuthCredentials(
|
||||
manager,
|
||||
providerId as "cline" | "oca" | "openai-codex",
|
||||
providerId,
|
||||
existing,
|
||||
credentials,
|
||||
);
|
||||
@@ -705,30 +731,24 @@ export function OAuthLoginContent(
|
||||
const manager = new ProviderSettingsManager();
|
||||
const existing = manager.getProviderSettings(providerId);
|
||||
|
||||
loginLocalProvider(
|
||||
providerId as "cline" | "oca" | "openai-codex",
|
||||
existing,
|
||||
(url: string) => {
|
||||
setAuthUrl(url);
|
||||
setStatus("Waiting for authentication in browser...");
|
||||
try {
|
||||
void open(url, { wait: false }).catch(() => {
|
||||
setStatus(
|
||||
"Could not open browser automatically. Open the URL below.",
|
||||
);
|
||||
});
|
||||
} catch {
|
||||
loginLocalProvider(providerId, existing, (url: string) => {
|
||||
setAuthUrl(url);
|
||||
setStatus("Waiting for authentication in browser...");
|
||||
try {
|
||||
void open(url, { wait: false }).catch(() => {
|
||||
setStatus(
|
||||
"Could not open browser automatically. Open the URL below.",
|
||||
);
|
||||
}
|
||||
},
|
||||
)
|
||||
});
|
||||
} catch {
|
||||
setStatus("Could not open browser automatically. Open the URL below.");
|
||||
}
|
||||
})
|
||||
.then((credentials) => {
|
||||
if (!isActiveAuthAttempt(attempt)) return;
|
||||
saveLocalProviderOAuthCredentials(
|
||||
manager,
|
||||
providerId as "cline" | "oca" | "openai-codex",
|
||||
providerId,
|
||||
existing,
|
||||
credentials,
|
||||
);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { ScrollBoxRenderable } from "@opentui/core";
|
||||
import { useKeyboard, useTerminalDimensions } from "@opentui/react";
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { palette } from "../palette";
|
||||
import type { RuntimeToolInteraction } from "../types";
|
||||
import { formatApprovalParams } from "./dialogs/tool-approval";
|
||||
@@ -22,23 +23,129 @@ function keyToText(name: string): string {
|
||||
return name === "space" ? " " : name;
|
||||
}
|
||||
|
||||
function getToolShellMaxHeight(terminalHeight: number): number {
|
||||
return Math.max(7, Math.min(14, Math.floor(terminalHeight * 0.38)));
|
||||
}
|
||||
|
||||
function getAskQuestionShellMaxHeight(terminalHeight: number): number {
|
||||
const preferredHeight = Math.max(11, Math.floor(terminalHeight * 0.58));
|
||||
const availableHeight = Math.max(7, terminalHeight - 3);
|
||||
return Math.min(18, preferredHeight, availableHeight);
|
||||
}
|
||||
|
||||
function getAskQuestionBodyHeight(shellMaxHeight: number): number {
|
||||
return Math.max(1, shellMaxHeight - 4);
|
||||
}
|
||||
|
||||
function addWrappedWidth(input: {
|
||||
rows: number;
|
||||
lineWidth: number;
|
||||
width: number;
|
||||
maxWidth: number;
|
||||
}): { rows: number; lineWidth: number } {
|
||||
if (input.width <= 0) {
|
||||
return { rows: input.rows, lineWidth: input.lineWidth };
|
||||
}
|
||||
|
||||
let rows = input.rows;
|
||||
let remainingWidth = input.width;
|
||||
let lineWidth = input.lineWidth;
|
||||
|
||||
if (lineWidth > 0) {
|
||||
const availableWidth = input.maxWidth - lineWidth;
|
||||
if (remainingWidth <= availableWidth) {
|
||||
return { rows, lineWidth: lineWidth + remainingWidth };
|
||||
}
|
||||
|
||||
remainingWidth -= Math.max(0, availableWidth);
|
||||
rows += 1;
|
||||
lineWidth = 0;
|
||||
}
|
||||
|
||||
rows += Math.max(0, Math.ceil(remainingWidth / input.maxWidth) - 1);
|
||||
lineWidth = remainingWidth % input.maxWidth || input.maxWidth;
|
||||
|
||||
return { rows, lineWidth };
|
||||
}
|
||||
|
||||
function countWrappedRows(text: string, width: number): number {
|
||||
const safeWidth = Math.max(1, width);
|
||||
const paragraphs = text.split("\n");
|
||||
let rows = 0;
|
||||
|
||||
for (const paragraph of paragraphs) {
|
||||
rows += 1;
|
||||
let lineWidth = 0;
|
||||
const tokens = paragraph.match(/\s+|\S+/g) ?? [];
|
||||
|
||||
for (const token of tokens) {
|
||||
const tokenWidth = Bun.stringWidth(token);
|
||||
const isWhitespace = /^\s+$/.test(token);
|
||||
|
||||
if (
|
||||
!isWhitespace &&
|
||||
lineWidth > 0 &&
|
||||
lineWidth + tokenWidth > safeWidth
|
||||
) {
|
||||
rows += 1;
|
||||
lineWidth = 0;
|
||||
}
|
||||
|
||||
const next = addWrappedWidth({
|
||||
rows,
|
||||
lineWidth,
|
||||
width: tokenWidth,
|
||||
maxWidth: safeWidth,
|
||||
});
|
||||
rows = next.rows;
|
||||
lineWidth = next.lineWidth;
|
||||
}
|
||||
}
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
function getAskQuestionContentHeight(input: {
|
||||
terminalWidth: number;
|
||||
question: string;
|
||||
options: string[];
|
||||
customText: string;
|
||||
}): number {
|
||||
const questionWidth = Math.max(1, input.terminalWidth - 3);
|
||||
const optionTextWidth = Math.max(1, input.terminalWidth - 7);
|
||||
const questionRows = countWrappedRows(input.question, questionWidth);
|
||||
const optionRows = input.options.reduce(
|
||||
(rows, option) => rows + countWrappedRows(option, optionTextWidth),
|
||||
0,
|
||||
);
|
||||
const customRows = countWrappedRows(input.customText, optionTextWidth);
|
||||
return questionRows + 1 + optionRows + customRows;
|
||||
}
|
||||
|
||||
function getAskQuestionChoiceId(interactionId: number, index: number): string {
|
||||
return `ask-question-${interactionId.toString()}-choice-${index.toString()}`;
|
||||
}
|
||||
|
||||
function Shell(
|
||||
props: Pick<
|
||||
InlineToolResponseProps,
|
||||
"accent" | "inputBackground" | "inputForeground"
|
||||
> & {
|
||||
title: string;
|
||||
maxHeight?: number;
|
||||
overflow?: "hidden";
|
||||
children: React.ReactNode;
|
||||
},
|
||||
) {
|
||||
const { height } = useTerminalDimensions();
|
||||
const maxHeight = Math.max(7, Math.min(14, Math.floor(height * 0.38)));
|
||||
const maxHeight = props.maxHeight ?? getToolShellMaxHeight(height);
|
||||
|
||||
return (
|
||||
<box
|
||||
flexDirection="column"
|
||||
width="100%"
|
||||
maxHeight={maxHeight}
|
||||
overflow={props.overflow}
|
||||
backgroundColor={props.inputBackground}
|
||||
paddingX={1}
|
||||
paddingY={1}
|
||||
@@ -59,6 +166,7 @@ function ChoiceButton(props: {
|
||||
onPress: () => void;
|
||||
}) {
|
||||
return (
|
||||
// biome-ignore lint/a11y/noStaticElementInteractions: OpenTUI boxes handle terminal mouse input.
|
||||
<box
|
||||
paddingX={1}
|
||||
backgroundColor={props.selected ? palette.selection : undefined}
|
||||
@@ -155,9 +263,11 @@ function AskQuestionResponse(
|
||||
},
|
||||
) {
|
||||
const { interaction } = props;
|
||||
const { height, width } = useTerminalDimensions();
|
||||
const [selected, setSelected] = useState(0);
|
||||
const [customValue, setCustomValue] = useState("");
|
||||
const [customEmptyAttempted, setCustomEmptyAttempted] = useState(false);
|
||||
const scrollRef = useRef<ScrollBoxRenderable | null>(null);
|
||||
const selectedRef = useRef(0);
|
||||
const customValueRef = useRef("");
|
||||
const interactionId = interaction.id;
|
||||
@@ -165,6 +275,24 @@ function AskQuestionResponse(
|
||||
const customIndex = interaction.options.length;
|
||||
const isTyping = selected === customIndex;
|
||||
const totalChoices = interaction.options.length + 1;
|
||||
const shellMaxHeight = getAskQuestionShellMaxHeight(height);
|
||||
const maxBodyHeight = getAskQuestionBodyHeight(shellMaxHeight);
|
||||
const customText = isTyping
|
||||
? customValue
|
||||
? `${customValue}|`
|
||||
: customEmptyAttempted
|
||||
? "Type a response first..."
|
||||
: "Type a response..."
|
||||
: "Type a response...";
|
||||
const bodyHeight = Math.min(
|
||||
maxBodyHeight,
|
||||
getAskQuestionContentHeight({
|
||||
terminalWidth: width,
|
||||
question: interaction.question,
|
||||
options: interaction.options,
|
||||
customText,
|
||||
}),
|
||||
);
|
||||
|
||||
const selectIndex = useCallback(
|
||||
(index: number) => {
|
||||
@@ -192,6 +320,26 @@ function AskQuestionResponse(
|
||||
[interactionId, onResolveAskQuestion],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const choiceId = getAskQuestionChoiceId(interactionId, selected);
|
||||
let canceled = false;
|
||||
const scrollSelectedChoiceIntoView = () => {
|
||||
if (canceled) {
|
||||
return;
|
||||
}
|
||||
|
||||
scrollRef.current?.scrollChildIntoView(choiceId);
|
||||
};
|
||||
|
||||
scrollSelectedChoiceIntoView();
|
||||
queueMicrotask(scrollSelectedChoiceIntoView);
|
||||
const timeout = setTimeout(scrollSelectedChoiceIntoView, 0);
|
||||
return () => {
|
||||
canceled = true;
|
||||
clearTimeout(timeout);
|
||||
};
|
||||
}, [interactionId, selected]);
|
||||
|
||||
useKeyboard((key) => {
|
||||
const typing = selectedRef.current === customIndex;
|
||||
if (key.name === "escape") {
|
||||
@@ -261,64 +409,91 @@ function AskQuestionResponse(
|
||||
accent={props.accent}
|
||||
inputBackground={props.inputBackground}
|
||||
inputForeground={props.inputForeground}
|
||||
maxHeight={shellMaxHeight}
|
||||
overflow="hidden"
|
||||
>
|
||||
<text fg={props.inputForeground} selectable>
|
||||
{interaction.question}
|
||||
</text>
|
||||
<scrollbox
|
||||
ref={scrollRef}
|
||||
height={bodyHeight}
|
||||
width="100%"
|
||||
scrollY
|
||||
scrollX={false}
|
||||
viewportOptions={{ overflow: "hidden" }}
|
||||
contentOptions={{ flexDirection: "column" }}
|
||||
>
|
||||
<box flexDirection="column" gap={1} flexShrink={0} width="100%">
|
||||
<text fg={props.inputForeground} selectable flexShrink={0}>
|
||||
{interaction.question}
|
||||
</text>
|
||||
|
||||
<box flexDirection="column">
|
||||
{interaction.options.map((option, index) => {
|
||||
const optionSelected = !isTyping && selected === index;
|
||||
return (
|
||||
<box flexDirection="column" flexShrink={0} width="100%">
|
||||
{interaction.options.map((option, index) => {
|
||||
const optionSelected = !isTyping && selected === index;
|
||||
return (
|
||||
// biome-ignore lint/a11y/noStaticElementInteractions: OpenTUI boxes handle terminal mouse input.
|
||||
<box
|
||||
id={getAskQuestionChoiceId(interactionId, index)}
|
||||
key={`${index.toString()}:${option}`}
|
||||
paddingX={1}
|
||||
flexDirection="row"
|
||||
gap={1}
|
||||
flexShrink={0}
|
||||
width="100%"
|
||||
backgroundColor={
|
||||
optionSelected ? palette.selection : undefined
|
||||
}
|
||||
onMouseDown={() => resolveAnswer(option)}
|
||||
>
|
||||
<text
|
||||
fg={optionSelected ? palette.textOnSelection : "gray"}
|
||||
flexShrink={0}
|
||||
>
|
||||
{optionSelected ? ">" : " "}
|
||||
</text>
|
||||
<text
|
||||
fg={
|
||||
optionSelected
|
||||
? palette.textOnSelection
|
||||
: props.inputForeground
|
||||
}
|
||||
flexGrow={1}
|
||||
flexShrink={1}
|
||||
>
|
||||
{option}
|
||||
</text>
|
||||
</box>
|
||||
);
|
||||
})}
|
||||
{/* biome-ignore lint/a11y/noStaticElementInteractions: OpenTUI boxes handle terminal mouse input. */}
|
||||
<box
|
||||
key={`${index.toString()}:${option}`}
|
||||
id={getAskQuestionChoiceId(interactionId, customIndex)}
|
||||
paddingX={1}
|
||||
flexDirection="row"
|
||||
gap={1}
|
||||
backgroundColor={optionSelected ? palette.selection : undefined}
|
||||
onMouseDown={() => resolveAnswer(option)}
|
||||
flexShrink={0}
|
||||
width="100%"
|
||||
backgroundColor={isTyping ? palette.selection : undefined}
|
||||
onMouseDown={() => selectIndex(customIndex)}
|
||||
>
|
||||
<text
|
||||
fg={optionSelected ? palette.textOnSelection : "gray"}
|
||||
fg={isTyping ? palette.textOnSelection : "gray"}
|
||||
flexShrink={0}
|
||||
>
|
||||
{optionSelected ? ">" : " "}
|
||||
</text>
|
||||
<text
|
||||
fg={
|
||||
optionSelected
|
||||
? palette.textOnSelection
|
||||
: props.inputForeground
|
||||
}
|
||||
>
|
||||
{option}
|
||||
{isTyping ? ">" : " "}
|
||||
</text>
|
||||
{isTyping ? (
|
||||
<text fg={palette.textOnSelection} flexGrow={1} flexShrink={1}>
|
||||
{customText}
|
||||
</text>
|
||||
) : (
|
||||
<text fg={props.inputPlaceholder} flexGrow={1} flexShrink={1}>
|
||||
Type a response...
|
||||
</text>
|
||||
)}
|
||||
</box>
|
||||
);
|
||||
})}
|
||||
<box
|
||||
paddingX={1}
|
||||
flexDirection="row"
|
||||
gap={1}
|
||||
backgroundColor={isTyping ? palette.selection : undefined}
|
||||
onMouseDown={() => selectIndex(customIndex)}
|
||||
>
|
||||
<text fg={isTyping ? palette.textOnSelection : "gray"} flexShrink={0}>
|
||||
{isTyping ? ">" : " "}
|
||||
</text>
|
||||
{isTyping ? (
|
||||
<text fg={palette.textOnSelection} flexGrow={1}>
|
||||
{customValue
|
||||
? `${customValue}|`
|
||||
: customEmptyAttempted
|
||||
? "Type a response first..."
|
||||
: "Type a response..."}
|
||||
</text>
|
||||
) : (
|
||||
<text fg={props.inputPlaceholder}>Type a response...</text>
|
||||
)}
|
||||
</box>
|
||||
</box>
|
||||
</box>
|
||||
</scrollbox>
|
||||
</Shell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -107,12 +107,13 @@ describe("copyTextToSystemClipboard", () => {
|
||||
|
||||
expect(spawnMock).toHaveBeenNthCalledWith(1, "wl-copy", [], {
|
||||
stdio: ["pipe", "ignore", "ignore"],
|
||||
windowsHide: true,
|
||||
});
|
||||
expect(spawnMock).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
"xclip",
|
||||
["-selection", "clipboard"],
|
||||
{ stdio: ["pipe", "ignore", "ignore"] },
|
||||
{ stdio: ["pipe", "ignore", "ignore"], windowsHide: true },
|
||||
);
|
||||
expect(failed.getInput()).toBe("selected text");
|
||||
expect(succeeded.getInput()).toBe("selected text");
|
||||
@@ -134,6 +135,7 @@ describe("copyTextToSystemClipboard", () => {
|
||||
expect(spawnMock).toHaveBeenCalledTimes(1);
|
||||
expect(spawnMock).toHaveBeenCalledWith("wl-copy", [], {
|
||||
stdio: ["pipe", "ignore", "ignore"],
|
||||
windowsHide: true,
|
||||
});
|
||||
expect(wlcopy.getInput()).toBe("plain linux");
|
||||
});
|
||||
|
||||
@@ -142,6 +142,8 @@ function runClipboardCommand(
|
||||
const child = spawn(command.command, command.args, {
|
||||
stdio: ["pipe", "ignore", "ignore"],
|
||||
...(command.env ? { env: command.env } : {}),
|
||||
// Prevent a console window from flashing on Windows.
|
||||
windowsHide: true,
|
||||
});
|
||||
let settled = false;
|
||||
|
||||
|
||||
@@ -115,6 +115,8 @@ async function runCommand(
|
||||
return await new Promise((resolve) => {
|
||||
const child = spawn(command, args, {
|
||||
stdio: ["ignore", "pipe", "ignore"],
|
||||
// Prevent a console window from flashing on Windows.
|
||||
windowsHide: true,
|
||||
});
|
||||
const chunks: Buffer[] = [];
|
||||
let total = 0;
|
||||
|
||||
@@ -5,6 +5,8 @@ import { afterEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
getDefaultAwsRegion,
|
||||
resolveProviderConfigAwsRegion,
|
||||
resolveProviderConfigAzure,
|
||||
resolveProviderConfigGcp,
|
||||
resolveProviderConfigSap,
|
||||
updateProviderConfigValue,
|
||||
} from "./provider-config-values";
|
||||
@@ -66,6 +68,18 @@ describe("provider config values", () => {
|
||||
).toBe("us-west-2");
|
||||
});
|
||||
|
||||
it("resolves Vertex GCP field values into GCP settings", () => {
|
||||
expect(
|
||||
resolveProviderConfigGcp({ gcpRegion: "us-central1" }),
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
resolveProviderConfigGcp({
|
||||
gcpProjectId: " project ",
|
||||
gcpRegion: " europe-west4 ",
|
||||
}),
|
||||
).toEqual({ projectId: "project", region: "europe-west4" });
|
||||
});
|
||||
|
||||
it("resolves SAP AI Core field values into SAP settings", () => {
|
||||
expect(
|
||||
resolveProviderConfigSap({
|
||||
@@ -83,4 +97,24 @@ describe("provider config values", () => {
|
||||
deploymentId: "deployment",
|
||||
});
|
||||
});
|
||||
|
||||
it("resolves Azure API version into Azure settings", () => {
|
||||
expect(
|
||||
resolveProviderConfigAzure({
|
||||
azureApiVersion: " 2025-01-01-preview ",
|
||||
}),
|
||||
).toEqual({
|
||||
apiVersion: "2025-01-01-preview",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps blank Azure API version so persisted settings can be cleared", () => {
|
||||
expect(
|
||||
resolveProviderConfigAzure({
|
||||
azureApiVersion: " ",
|
||||
}),
|
||||
).toEqual({
|
||||
apiVersion: "",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,6 +6,7 @@ export type ProviderConfigValues = Partial<
|
||||
>;
|
||||
|
||||
const DEFAULT_AWS_REGION = "us-east-1";
|
||||
const DEFAULT_GCP_REGION = "us-central1";
|
||||
|
||||
export function getDefaultAwsRegion(profile?: string): string {
|
||||
return (
|
||||
@@ -20,6 +21,20 @@ export function resolveProviderConfigAwsRegion(
|
||||
return values.awsRegion?.trim() || getDefaultAwsRegion(values.awsProfile);
|
||||
}
|
||||
|
||||
export function resolveProviderConfigGcp(values: ProviderConfigValues):
|
||||
| {
|
||||
projectId?: string;
|
||||
region?: string;
|
||||
}
|
||||
| undefined {
|
||||
const projectId = values.gcpProjectId?.trim() || undefined;
|
||||
if (!projectId) return undefined;
|
||||
return {
|
||||
projectId,
|
||||
region: values.gcpRegion?.trim() || DEFAULT_GCP_REGION,
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveProviderConfigSap(values: ProviderConfigValues):
|
||||
| {
|
||||
clientId?: string;
|
||||
@@ -41,6 +56,12 @@ export function resolveProviderConfigSap(values: ProviderConfigValues):
|
||||
: undefined;
|
||||
}
|
||||
|
||||
export function resolveProviderConfigAzure(values: ProviderConfigValues): {
|
||||
apiVersion?: string;
|
||||
} {
|
||||
return { apiVersion: values.azureApiVersion?.trim() ?? "" };
|
||||
}
|
||||
|
||||
export function updateProviderConfigValue(
|
||||
previous: ProviderConfigValues,
|
||||
field: ProviderConfigFieldKey,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { readGlobalSettings, setAutoUpdateEnabledGlobally } from "@cline/core";
|
||||
import { useTerminalDimensions } from "@opentui/react";
|
||||
import type { ChoiceContext } from "@opentui-ui/dialog";
|
||||
import { useDialogKeyboard } from "@opentui-ui/dialog/react";
|
||||
@@ -368,6 +369,9 @@ export function ConfigPanelContent(props: ConfigPanelProps) {
|
||||
const [autoApprove, setAutoApprove] = useState(
|
||||
config.toolPolicies["*"]?.autoApprove !== false,
|
||||
);
|
||||
const [autoUpdateEnabled, setAutoUpdateEnabled] = useState(
|
||||
() => readGlobalSettings().autoUpdateEnabled,
|
||||
);
|
||||
const [verbose, setVerbose] = useState(config.verbose);
|
||||
const [compactionMode, setCompactionMode] = useState(
|
||||
props.currentCompactionMode,
|
||||
@@ -445,6 +449,7 @@ export function ConfigPanelContent(props: ConfigPanelProps) {
|
||||
id: "auto-approve",
|
||||
label: "Auto-approve all",
|
||||
});
|
||||
r.push({ kind: "toggle", id: "auto-update", label: "Auto update" });
|
||||
r.push({ kind: "toggle", id: "verbose", label: "Verbose" });
|
||||
} else {
|
||||
const activeItems = resolveActiveConfigItems(configData, activeTab);
|
||||
@@ -584,6 +589,13 @@ export function ConfigPanelContent(props: ConfigPanelProps) {
|
||||
setAutoApprove(!autoApprove);
|
||||
props.onToggleAutoApprove();
|
||||
break;
|
||||
case "auto-update":
|
||||
setAutoUpdateEnabled((previous) => {
|
||||
const next = !previous;
|
||||
setAutoUpdateEnabledGlobally(next);
|
||||
return next;
|
||||
});
|
||||
break;
|
||||
case "compaction": {
|
||||
const nextMode = getNextCliCompactionMode(compactionMode);
|
||||
setCompactionMode(nextMode);
|
||||
@@ -789,6 +801,9 @@ export function ConfigPanelContent(props: ConfigPanelProps) {
|
||||
} else if (row.id === "auto-approve") {
|
||||
value = autoApprove ? "● on" : "○ off";
|
||||
valueColor = autoApprove ? palette.success : "gray";
|
||||
} else if (row.id === "auto-update") {
|
||||
value = autoUpdateEnabled ? "● on" : "○ off";
|
||||
valueColor = autoUpdateEnabled ? palette.success : "gray";
|
||||
} else if (row.id === "compaction") {
|
||||
value = formatCliCompactionMode(compactionMode);
|
||||
valueColor = COMPACTION_MODE_COLORS[compactionMode];
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
completeClineDeviceAuth,
|
||||
type ITelemetryService,
|
||||
isOAuthProvider,
|
||||
loginLocalProvider,
|
||||
type ProviderSettingsManager,
|
||||
saveLocalProviderOAuthCredentials,
|
||||
@@ -9,16 +10,12 @@ import {
|
||||
import { getClineEnvironmentConfig } from "@cline/shared";
|
||||
import open from "open";
|
||||
|
||||
export type OnboardingOAuthProviderId = "cline" | "oca" | "openai-codex";
|
||||
export type OnboardingOAuthProviderId = string;
|
||||
|
||||
export function isOnboardingOAuthProviderId(
|
||||
providerId: string,
|
||||
): providerId is OnboardingOAuthProviderId {
|
||||
return (
|
||||
providerId === "cline" ||
|
||||
providerId === "oca" ||
|
||||
providerId === "openai-codex"
|
||||
);
|
||||
return isOAuthProvider(providerId);
|
||||
}
|
||||
|
||||
export function runOAuthAuthFlow(input: {
|
||||
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
getDefaultAwsRegion,
|
||||
type ProviderConfigValues,
|
||||
resolveProviderConfigAwsRegion,
|
||||
resolveProviderConfigAzure,
|
||||
resolveProviderConfigSap,
|
||||
updateProviderConfigValue,
|
||||
} from "../../utils/provider-config-values";
|
||||
@@ -382,6 +383,10 @@ export function useOnboardingController(props: OnboardingControllerProps) {
|
||||
config.fields.baseUrl?.defaultValue ??
|
||||
"";
|
||||
}
|
||||
if (config.fields.azureApiVersion) {
|
||||
initialValues.azureApiVersion =
|
||||
existing?.azure?.apiVersion?.trim() ?? "";
|
||||
}
|
||||
if (config.fields.awsRegion) {
|
||||
const existingProfile = existing?.aws?.profile?.trim() ?? "";
|
||||
initialValues.awsRegion =
|
||||
@@ -444,6 +449,7 @@ export function useOnboardingController(props: OnboardingControllerProps) {
|
||||
// surfaced when the model picker / first turn runs.
|
||||
const apiKey = byoValues.apiKey?.trim();
|
||||
const awsProfile = byoValues.awsProfile?.trim();
|
||||
const hasAzureFields = byoFields.azureApiVersion;
|
||||
const hasAwsFields = byoFields.awsRegion || byoFields.awsProfile;
|
||||
const hasSapFields =
|
||||
byoFields.sapClientId ||
|
||||
@@ -456,6 +462,7 @@ export function useOnboardingController(props: OnboardingControllerProps) {
|
||||
providerId: activeProviderId,
|
||||
apiKey: byoFields.apiKey ? apiKey : undefined,
|
||||
baseUrl: byoFields.baseUrl ? byoValues.baseUrl?.trim() : undefined,
|
||||
azure: hasAzureFields ? resolveProviderConfigAzure(byoValues) : undefined,
|
||||
aws: hasAwsFields
|
||||
? {
|
||||
region: resolveProviderConfigAwsRegion(byoValues),
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { ProviderConfigFieldKey } from "@cline/core";
|
||||
export const FIELD_ORDER: ProviderConfigFieldKey[] = [
|
||||
"awsRegion",
|
||||
"baseUrl",
|
||||
"azureApiVersion",
|
||||
"apiKey",
|
||||
"awsProfile",
|
||||
"sapClientId",
|
||||
|
||||
@@ -222,6 +222,7 @@ import type {
|
||||
const DEFAULT_FIELD_LABELS: Partial<Record<ProviderConfigFieldKey, string>> = {
|
||||
apiKey: "API key",
|
||||
baseUrl: "Base URL",
|
||||
azureApiVersion: "Azure API Version",
|
||||
awsRegion: "AWS Region",
|
||||
awsProfile: "AWS Profile Name",
|
||||
sapClientId: "Client ID",
|
||||
@@ -236,6 +237,7 @@ const DEFAULT_FIELD_PLACEHOLDERS: Partial<
|
||||
> = {
|
||||
apiKey: "Paste your API key here...",
|
||||
baseUrl: "",
|
||||
azureApiVersion: "2025-01-01-preview",
|
||||
awsRegion: "us-east-1",
|
||||
awsProfile: "default",
|
||||
sapClientId: "sb-...|xsuaa_std!b...",
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
import { Llms, type ProviderSettings } from "@cline/core";
|
||||
import { isOAuthProviderId } from "@cline/shared";
|
||||
import {
|
||||
formatProviderOAuthApiKey,
|
||||
getPersistedProviderApiKey as getCorePersistedProviderApiKey,
|
||||
isOAuthProvider,
|
||||
Llms,
|
||||
type ProviderOAuthCredentials,
|
||||
type ProviderSettings,
|
||||
} from "@cline/core";
|
||||
|
||||
export type OAuthCredentials = {
|
||||
access: string;
|
||||
refresh: string;
|
||||
expires: number;
|
||||
accountId?: string;
|
||||
email?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
};
|
||||
export type OAuthCredentials = ProviderOAuthCredentials;
|
||||
|
||||
export function normalizeProviderId(providerId: string): string {
|
||||
return Llms.normalizeProviderId(providerId.trim());
|
||||
@@ -22,42 +21,20 @@ export function normalizeAuthProviderId(providerId: string): string {
|
||||
return normalizeProviderId(normalized);
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-exports `isOAuthProviderId` from `@cline/shared` so the CLI has a
|
||||
* single source of truth for the OAuth provider list. Existing call sites
|
||||
* keep their `isOAuthProvider` import name.
|
||||
*/
|
||||
export const isOAuthProvider = isOAuthProviderId;
|
||||
export { isOAuthProvider };
|
||||
|
||||
export function toProviderApiKey(
|
||||
providerId: string,
|
||||
credentials: Pick<OAuthCredentials, "access">,
|
||||
): string {
|
||||
if (providerId === "cline") {
|
||||
return credentials.access.startsWith("workos:")
|
||||
? credentials.access
|
||||
: `workos:${credentials.access}`;
|
||||
}
|
||||
return credentials.access;
|
||||
return formatProviderOAuthApiKey(providerId, credentials);
|
||||
}
|
||||
|
||||
export function getPersistedProviderApiKey(
|
||||
providerId: string,
|
||||
settings?: ProviderSettings,
|
||||
): string | undefined {
|
||||
const accessToken = settings?.auth?.accessToken?.trim();
|
||||
if (accessToken) {
|
||||
return toProviderApiKey(providerId, { access: accessToken });
|
||||
}
|
||||
const shorthandKey = settings?.apiKey?.trim();
|
||||
if (shorthandKey) {
|
||||
return shorthandKey;
|
||||
}
|
||||
const authKey = settings?.auth?.apiKey?.trim();
|
||||
if (authKey) {
|
||||
return authKey;
|
||||
}
|
||||
return undefined;
|
||||
return getCorePersistedProviderApiKey(providerId, settings);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -76,7 +53,7 @@ export function isProviderConfigured(
|
||||
settings: ProviderSettings | undefined,
|
||||
): boolean {
|
||||
if (!settings) return false;
|
||||
if (isOAuthProviderId(providerId)) {
|
||||
if (isOAuthProvider(providerId)) {
|
||||
return Boolean(settings.auth?.accessToken?.trim());
|
||||
}
|
||||
if (getPersistedProviderApiKey(providerId, settings)) return true;
|
||||
|
||||
@@ -139,6 +139,12 @@ describe("provider readiness", () => {
|
||||
gcp: { projectId: "test-project" },
|
||||
} satisfies ProviderSettings),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isProviderSettingsUsable("vertex", {
|
||||
provider: "vertex",
|
||||
gcp: { projectId: "test-project", region: "us-central1" },
|
||||
} satisfies ProviderSettings),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isProviderSettingsUsable("sapaicore", {
|
||||
provider: "sapaicore",
|
||||
|
||||
@@ -33,6 +33,8 @@ function hasAwsRegion(settings: ProviderSettings): boolean {
|
||||
|
||||
function hasGcpCredentials(settings: ProviderSettings): boolean {
|
||||
const gcp = settings.gcp;
|
||||
// Vertex defaults to us-central1 at runtime when no region is stored, so keep
|
||||
// existing project-only configs usable while new CLI saves include a region.
|
||||
return hasText(gcp?.projectId);
|
||||
}
|
||||
|
||||
|
||||
@@ -18,9 +18,12 @@ export async function readRepoStatus(cwd: string): Promise<RepoStatus> {
|
||||
const [branchResult, diffResult] = await Promise.allSettled([
|
||||
execFileAsync("git", ["-C", cwd, "rev-parse", "--abbrev-ref", "HEAD"], {
|
||||
encoding: "utf8",
|
||||
// Prevent a console window from flashing on Windows.
|
||||
windowsHide: true,
|
||||
}),
|
||||
execFileAsync("git", ["-C", cwd, "diff", "--shortstat"], {
|
||||
encoding: "utf8",
|
||||
windowsHide: true,
|
||||
}),
|
||||
]);
|
||||
|
||||
|
||||
@@ -68,6 +68,8 @@ async function runCliConnectCommand(args: string[]): Promise<{
|
||||
CLINE_BUILD_ENV: process.env.CLINE_BUILD_ENV ?? "development",
|
||||
},
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
// Prevent a console window from flashing on Windows.
|
||||
windowsHide: true,
|
||||
},
|
||||
);
|
||||
let stdout = "";
|
||||
|
||||
@@ -6,15 +6,15 @@ import {
|
||||
executeClineAccountAction,
|
||||
getLocalProviderModels,
|
||||
listLocalProviders,
|
||||
loginLocalProvider,
|
||||
loginAndSaveLocalProviderOAuthCredentials,
|
||||
normalizeOAuthProvider,
|
||||
type ProviderCapability,
|
||||
type ProviderClient,
|
||||
type ProviderProtocol,
|
||||
readGlobalSettings,
|
||||
resolveLocalClineAuthToken,
|
||||
saveLocalProviderOAuthCredentials,
|
||||
saveLocalProviderSettings,
|
||||
setAutoUpdateEnabledGlobally,
|
||||
setDisabledPlugin,
|
||||
setDisabledTools,
|
||||
setTelemetryOptOutGlobally,
|
||||
@@ -116,17 +116,10 @@ export async function handleDesktopCommand(
|
||||
}
|
||||
if (command === "run_provider_oauth_login") {
|
||||
const providerId = normalizeOAuthProvider(String(args?.provider ?? ""));
|
||||
const existing = providerSettingsManager.getProviderSettings(providerId);
|
||||
const credentials = await loginLocalProvider(
|
||||
providerId,
|
||||
existing,
|
||||
openExternalUrl,
|
||||
);
|
||||
const saved = saveLocalProviderOAuthCredentials(
|
||||
const saved = await loginAndSaveLocalProviderOAuthCredentials(
|
||||
providerSettingsManager,
|
||||
providerId,
|
||||
existing,
|
||||
credentials,
|
||||
openExternalUrl,
|
||||
);
|
||||
return {
|
||||
provider: providerId,
|
||||
@@ -155,6 +148,13 @@ export async function handleDesktopCommand(
|
||||
setTelemetryOptOutGlobally(args.telemetry_opt_out);
|
||||
return readGlobalSettings();
|
||||
}
|
||||
if (command === "set_auto_update_enabled") {
|
||||
if (typeof args?.auto_update_enabled !== "boolean") {
|
||||
throw new Error("auto_update_enabled must be a boolean");
|
||||
}
|
||||
setAutoUpdateEnabledGlobally(args.auto_update_enabled);
|
||||
return readGlobalSettings();
|
||||
}
|
||||
if (command === "list_connector_channels") {
|
||||
return connectorChannelsPayload();
|
||||
}
|
||||
|
||||
@@ -4,9 +4,8 @@ import {
|
||||
getLocalProviderModels,
|
||||
Llms,
|
||||
listLocalProviders,
|
||||
loginLocalProvider,
|
||||
loginAndSaveLocalProviderOAuthCredentials,
|
||||
normalizeOAuthProvider,
|
||||
saveLocalProviderOAuthCredentials,
|
||||
saveLocalProviderSettings,
|
||||
} from "@cline/core";
|
||||
import type {
|
||||
@@ -134,17 +133,10 @@ export async function runProviderOAuthLogin(
|
||||
providerId: string,
|
||||
): Promise<void> {
|
||||
const normalized = normalizeOAuthProvider(providerId);
|
||||
const existing = providerSettingsManager.getProviderSettings(normalized);
|
||||
const credentials = await loginLocalProvider(
|
||||
normalized,
|
||||
existing,
|
||||
openExternalUrl,
|
||||
);
|
||||
const saved = saveLocalProviderOAuthCredentials(
|
||||
const saved = await loginAndSaveLocalProviderOAuthCredentials(
|
||||
providerSettingsManager,
|
||||
normalized,
|
||||
existing,
|
||||
credentials,
|
||||
openExternalUrl,
|
||||
);
|
||||
ctx.send(peer, {
|
||||
type: "provider_oauth_login_done",
|
||||
|
||||
@@ -134,6 +134,12 @@ export function openExternalUrl(url: string): void {
|
||||
const command =
|
||||
platform === "darwin" ? "open" : platform === "win32" ? "cmd" : "xdg-open";
|
||||
const args = platform === "win32" ? ["/c", "start", "", url] : [url];
|
||||
const child = spawn(command, args, { stdio: "ignore", detached: true });
|
||||
const child = spawn(command, args, {
|
||||
stdio: "ignore",
|
||||
detached: true,
|
||||
// Prevent a console window from flashing on Windows; the launched
|
||||
// browser/app still opens normally.
|
||||
windowsHide: true,
|
||||
});
|
||||
child.unref();
|
||||
}
|
||||
|
||||
@@ -43,6 +43,7 @@ export type SettingsSection = (typeof navCategories)[number];
|
||||
type Theme = "dark" | "light";
|
||||
type GlobalSettingsResponse = {
|
||||
telemetryOptOut: boolean;
|
||||
autoUpdateEnabled: boolean;
|
||||
};
|
||||
|
||||
const PROVIDER_CATALOG_CACHE_TTL_MS = 60_000;
|
||||
@@ -525,20 +526,29 @@ function GeneralSettingsContent({
|
||||
const [telemetryLoading, setTelemetryLoading] = useState(true);
|
||||
const [telemetrySaving, setTelemetrySaving] = useState(false);
|
||||
const [telemetryError, setTelemetryError] = useState<string | null>(null);
|
||||
const [autoUpdateEnabled, setAutoUpdateEnabled] = useState(true);
|
||||
const [autoUpdateLoading, setAutoUpdateLoading] = useState(true);
|
||||
const [autoUpdateSaving, setAutoUpdateSaving] = useState(false);
|
||||
const [autoUpdateError, setAutoUpdateError] = useState<string | null>(null);
|
||||
|
||||
const loadGlobalSettings = useCallback(async () => {
|
||||
setTelemetryLoading(true);
|
||||
setTelemetryError(null);
|
||||
setAutoUpdateLoading(true);
|
||||
setAutoUpdateError(null);
|
||||
try {
|
||||
const settings = await desktopClient.invoke<GlobalSettingsResponse>(
|
||||
"get_global_settings",
|
||||
);
|
||||
setTelemetryOptOut(settings.telemetryOptOut);
|
||||
setAutoUpdateEnabled(settings.autoUpdateEnabled);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
setTelemetryError(message);
|
||||
setAutoUpdateError(message);
|
||||
} finally {
|
||||
setTelemetryLoading(false);
|
||||
setAutoUpdateLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
@@ -571,6 +581,28 @@ function GeneralSettingsContent({
|
||||
}
|
||||
};
|
||||
|
||||
const updateAutoUpdateEnabled = async (nextValue: boolean) => {
|
||||
const previousValue = autoUpdateEnabled;
|
||||
setAutoUpdateEnabled(nextValue);
|
||||
setAutoUpdateSaving(true);
|
||||
setAutoUpdateError(null);
|
||||
try {
|
||||
const settings = await desktopClient.invoke<GlobalSettingsResponse>(
|
||||
"set_auto_update_enabled",
|
||||
{
|
||||
auto_update_enabled: nextValue,
|
||||
},
|
||||
);
|
||||
setAutoUpdateEnabled(settings.autoUpdateEnabled);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
setAutoUpdateEnabled(previousValue);
|
||||
setAutoUpdateError(message);
|
||||
} finally {
|
||||
setAutoUpdateSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ScrollArea className="h-full">
|
||||
<div className="mx-auto max-w-3xl px-8 py-6">
|
||||
@@ -605,6 +637,29 @@ function GeneralSettingsContent({
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section className="mt-4 rounded-lg border border-border p-5">
|
||||
<div className="flex items-center justify-between gap-5 max-[720px]:flex-col max-[720px]:items-stretch">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-foreground">Auto update</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Automatically install CLI updates on startup.
|
||||
</p>
|
||||
{autoUpdateError ? (
|
||||
<p className="mt-2 text-xs text-destructive">
|
||||
Failed to update auto update setting: {autoUpdateError}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<Switch
|
||||
aria-label="Auto update"
|
||||
checked={autoUpdateEnabled}
|
||||
disabled={autoUpdateLoading || autoUpdateSaving}
|
||||
onCheckedChange={(checked) =>
|
||||
void updateAutoUpdateEnabled(checked)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
<section className="mt-4 rounded-lg border border-border p-5">
|
||||
<div className="flex items-center justify-between gap-5 max-[720px]:flex-col max-[720px]:items-stretch">
|
||||
<div>
|
||||
|
||||
@@ -31,7 +31,7 @@ import {
|
||||
listHookConfigFiles,
|
||||
listLocalProviders,
|
||||
listPluginTools,
|
||||
loginLocalProvider,
|
||||
loginAndSaveLocalProviderOAuthCredentials,
|
||||
normalizeOAuthProvider,
|
||||
ProviderSettingsManager,
|
||||
readGlobalSettings,
|
||||
@@ -40,7 +40,6 @@ import {
|
||||
resolveSessionBackend,
|
||||
resolveAgentConfigSearchPaths as resolveSharedAgentConfigSearchPaths,
|
||||
SqliteSessionStore,
|
||||
saveLocalProviderOAuthCredentials,
|
||||
saveLocalProviderSettings,
|
||||
sendHubCommand,
|
||||
setDisabledPlugin,
|
||||
@@ -1012,10 +1011,9 @@ export async function handleCommand(
|
||||
if (command === "run_provider_oauth_login") {
|
||||
const providerId = normalizeOAuthProvider(String(args?.provider ?? ""));
|
||||
const manager = new ProviderSettingsManager();
|
||||
const existing = manager.getProviderSettings(providerId);
|
||||
const credentials = await loginLocalProvider(
|
||||
const saved = await loginAndSaveLocalProviderOAuthCredentials(
|
||||
manager,
|
||||
providerId,
|
||||
existing,
|
||||
(url) => {
|
||||
const platform = process.platform;
|
||||
const spawned =
|
||||
@@ -1033,12 +1031,6 @@ export async function handleCommand(
|
||||
spawned.unref();
|
||||
},
|
||||
);
|
||||
const saved = saveLocalProviderOAuthCredentials(
|
||||
manager,
|
||||
providerId,
|
||||
existing,
|
||||
credentials,
|
||||
);
|
||||
return {
|
||||
provider: providerId,
|
||||
accessToken: saved.auth?.accessToken ?? saved.apiKey ?? "",
|
||||
|
||||
@@ -6,6 +6,13 @@
|
||||
"src/**/__tests__/*.ts",
|
||||
"src/test/services/**/*.test.ts"
|
||||
],
|
||||
"ignore": [
|
||||
"src/core/controller/models/__tests__/providerCatalogHandlers.test.ts",
|
||||
"src/core/controller/models/__tests__/providerCatalogSmoke.test.ts",
|
||||
"src/core/controller/models/__tests__/providerSwitchNormalization.test.ts",
|
||||
"src/core/controller/models/__tests__/resolveModelInfo.test.ts",
|
||||
"src/core/controller/models/__tests__/refreshClineRecommendedModels.test.ts"
|
||||
],
|
||||
"require": [
|
||||
"ts-node/register",
|
||||
"source-map-support/register",
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import { defineConfig } from "@vscode/test-cli"
|
||||
import path from "path"
|
||||
|
||||
const vscodeTestVersion = process.env.VSCODE_TEST_VERSION ?? "stable"
|
||||
|
||||
export default defineConfig({
|
||||
files: "{out/**/*.test.js,src/**/*.test.js,!src/test/e2e/**/*.test.js,!out/src/test/e2e/**/*.test.js}",
|
||||
files: [
|
||||
"out/src/{core,test,utils,shared,integrations,hosts,services}/**/*.test.js",
|
||||
"src/{core,test,utils,shared,integrations,hosts,services}/**/*.test.js",
|
||||
],
|
||||
mocha: {
|
||||
ui: "bdd",
|
||||
timeout: 20000, // Maximum time (in ms) that a test can run before failing
|
||||
|
||||
+21
-7
@@ -1,6 +1,11 @@
|
||||
{
|
||||
"root": false,
|
||||
"root": true,
|
||||
"$schema": "./node_modules/@biomejs/biome/configuration_schema.json",
|
||||
"vcs": {
|
||||
"enabled": true,
|
||||
"clientKind": "git",
|
||||
"defaultBranch": "main"
|
||||
},
|
||||
"assist": {
|
||||
"enabled": true,
|
||||
"actions": {
|
||||
@@ -50,8 +55,8 @@
|
||||
"useEnumInitializers": "off",
|
||||
"useSelfClosingElements": "info",
|
||||
"useSingleVarDeclarator": "off",
|
||||
"useNumberNamespace": "info",
|
||||
"noInferrableTypes": "info",
|
||||
"useNumberNamespace": "off",
|
||||
"noInferrableTypes": "off",
|
||||
"useTemplate": "info",
|
||||
"noUselessElse": "info"
|
||||
},
|
||||
@@ -124,6 +129,7 @@
|
||||
"!!**/playwright",
|
||||
"!!**/.vscode-test",
|
||||
"!!**/test-results",
|
||||
"!!**/coverage",
|
||||
"!!**/node_modules",
|
||||
"!!**/webview-ui/build",
|
||||
"!!**/generated",
|
||||
@@ -131,7 +137,9 @@
|
||||
"!!**/tests/specs"
|
||||
]
|
||||
},
|
||||
"plugins": ["src/dev/grit/process-env.grit"],
|
||||
"plugins": [
|
||||
"src/dev/grit/process-env.grit"
|
||||
],
|
||||
"overrides": [
|
||||
{
|
||||
"includes": [
|
||||
@@ -146,11 +154,15 @@
|
||||
"!!src/integrations/terminal/**",
|
||||
"!!src/core/controller/ui/openWalkthrough.ts"
|
||||
],
|
||||
"plugins": ["src/dev/grit/vscode-api.grit"]
|
||||
"plugins": [
|
||||
"src/dev/grit/vscode-api.grit"
|
||||
]
|
||||
},
|
||||
{
|
||||
// Do not use console logging directly, use the Logger service instead.
|
||||
"plugins": ["src/dev/grit/console-log.grit"],
|
||||
"plugins": [
|
||||
"src/dev/grit/console-log.grit"
|
||||
],
|
||||
"includes": [
|
||||
"**",
|
||||
"!!**/esbuild.*",
|
||||
@@ -183,7 +195,9 @@
|
||||
"!!src/core/storage/utils/state-helpers.ts",
|
||||
"!!src/extension.ts"
|
||||
],
|
||||
"plugins": ["src/dev/grit/use-cache-service.grit"]
|
||||
"plugins": [
|
||||
"src/dev/grit/use-cache-service.grit"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -85,44 +85,6 @@ const esbuildProblemMatcherPlugin = {
|
||||
},
|
||||
}
|
||||
|
||||
const copyWasmFiles = {
|
||||
name: "copy-wasm-files",
|
||||
setup(build) {
|
||||
build.onEnd(() => {
|
||||
// tree sitter
|
||||
const sourceDir = path.join(__dirname, "node_modules", "web-tree-sitter")
|
||||
const targetDir = path.join(__dirname, destDir)
|
||||
|
||||
// Copy tree-sitter.wasm
|
||||
fs.copyFileSync(path.join(sourceDir, "tree-sitter.wasm"), path.join(targetDir, "tree-sitter.wasm"))
|
||||
|
||||
// Copy language-specific WASM files
|
||||
const languageWasmDir = path.join(__dirname, "node_modules", "tree-sitter-wasms", "out")
|
||||
const languages = [
|
||||
"typescript",
|
||||
"tsx",
|
||||
"python",
|
||||
"rust",
|
||||
"javascript",
|
||||
"go",
|
||||
"cpp",
|
||||
"c",
|
||||
"c_sharp",
|
||||
"ruby",
|
||||
"java",
|
||||
"php",
|
||||
"swift",
|
||||
"kotlin",
|
||||
]
|
||||
|
||||
languages.forEach((lang) => {
|
||||
const filename = `tree-sitter-${lang}.wasm`
|
||||
fs.copyFileSync(path.join(languageWasmDir, filename), path.join(targetDir, filename))
|
||||
})
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
const buildEnvVars = {
|
||||
"import.meta.url": "_importMetaUrl",
|
||||
"process.env.IS_STANDALONE": JSON.stringify(standalone ? "true" : "false"),
|
||||
@@ -176,7 +138,6 @@ const baseConfig = {
|
||||
define: buildEnvVars,
|
||||
tsconfig: path.resolve(__dirname, "tsconfig.json"),
|
||||
plugins: [
|
||||
copyWasmFiles,
|
||||
aliasResolverPlugin,
|
||||
/* add to the end of plugins array */
|
||||
esbuildProblemMatcherPlugin,
|
||||
|
||||
+32
-21
@@ -1,23 +1,34 @@
|
||||
{
|
||||
"entry": [
|
||||
"src/extension.ts",
|
||||
"src/standalone/cline-core.ts",
|
||||
"src/generated/hosts/standalone/protobus-server-setup.ts",
|
||||
"src/generated/hosts/standalone/host-bridge-clients.ts",
|
||||
"src/generated/hosts/vscode/protobus-services.ts",
|
||||
"src/generated/hosts/vscode/hostbridge-grpc-service-config.ts"
|
||||
],
|
||||
"project": [
|
||||
"src/**/*.ts"
|
||||
],
|
||||
"ignore": [
|
||||
"out/**",
|
||||
"node_modules/**",
|
||||
"*.d.ts",
|
||||
"**/*.test.ts",
|
||||
"**/__tests__",
|
||||
"src/test/**",
|
||||
"src/shared/**"
|
||||
],
|
||||
"vite": true
|
||||
"$schema": "https://unpkg.com/knip@5/schema.json",
|
||||
"workspaces": {
|
||||
".": {
|
||||
"entry": [
|
||||
"src/extension.ts",
|
||||
"src/standalone/cline-core.ts",
|
||||
"src/generated/hosts/standalone/protobus-server-setup.ts",
|
||||
"src/generated/hosts/standalone/host-bridge-clients.ts",
|
||||
"src/generated/hosts/vscode/protobus-services.ts",
|
||||
"src/generated/hosts/vscode/hostbridge-grpc-service-config.ts",
|
||||
"src/**/*.test.ts",
|
||||
"src/**/__tests__/**/*.ts",
|
||||
"src/test/**/*.ts"
|
||||
],
|
||||
"project": [
|
||||
"src/**/*.ts"
|
||||
]
|
||||
},
|
||||
"webview-ui": {
|
||||
"entry": [
|
||||
"src/services/grpc-client.ts",
|
||||
"src/**/*.test.{ts,tsx}",
|
||||
"src/**/*.spec.{ts,tsx}",
|
||||
"src/**/__tests__/**/*.{ts,tsx}"
|
||||
],
|
||||
"project": [
|
||||
"src/**/*.{ts,tsx}",
|
||||
"*.ts"
|
||||
],
|
||||
"vite": true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+2148
-1921
File diff suppressed because it is too large
Load Diff
+29
-60
@@ -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.88.0",
|
||||
"version": "3.89.0",
|
||||
"icon": "assets/icons/icon.png",
|
||||
"engines": {
|
||||
"vscode": "^1.84.0"
|
||||
@@ -89,7 +89,7 @@
|
||||
{
|
||||
"id": "mcp",
|
||||
"title": "Extend with Powerful Tools (MCP)",
|
||||
"description": "Connect to databases, APIs, or discover new capabilities in the MCP Marketplace.",
|
||||
"description": "Connect to databases, APIs, and other external tools through MCP.",
|
||||
"media": {
|
||||
"markdown": "walkthrough/step4.md"
|
||||
}
|
||||
@@ -229,26 +229,9 @@
|
||||
"command": "cline.reconstructTaskHistory",
|
||||
"title": "Reconstruct Task History",
|
||||
"category": "Cline"
|
||||
},
|
||||
{
|
||||
"command": "cline.reviewComment.reply",
|
||||
"title": "Reply",
|
||||
"category": "Cline",
|
||||
"enablement": "!commentIsEmpty"
|
||||
},
|
||||
{
|
||||
"command": "cline.reviewComment.addToChat",
|
||||
"title": "Add to Cline Chat",
|
||||
"category": "Cline",
|
||||
"icon": "$(link-external)"
|
||||
}
|
||||
],
|
||||
"keybindings": [
|
||||
{
|
||||
"command": "editor.action.submitComment",
|
||||
"key": "enter",
|
||||
"when": "commentEditorFocused && commentController == cline-ai-review && !commentIsEmpty"
|
||||
},
|
||||
{
|
||||
"command": "cline.addToChat",
|
||||
"key": "cmd+'",
|
||||
@@ -350,24 +333,6 @@
|
||||
{
|
||||
"command": "cline.abortGitCommitMessage",
|
||||
"when": "config.git.enabled && cline.isGeneratingCommit"
|
||||
},
|
||||
{
|
||||
"command": "cline.reviewComment.reply",
|
||||
"when": "false"
|
||||
}
|
||||
],
|
||||
"comments/commentThread/context": [
|
||||
{
|
||||
"command": "cline.reviewComment.reply",
|
||||
"group": "inline",
|
||||
"when": "commentController == cline-ai-review"
|
||||
}
|
||||
],
|
||||
"comments/commentThread/title": [
|
||||
{
|
||||
"command": "cline.reviewComment.addToChat",
|
||||
"group": "inline",
|
||||
"when": "commentController == cline-ai-review"
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -395,21 +360,28 @@
|
||||
"clean:all": "npm run clean:build && npm run clean:deps",
|
||||
"compile-tests": "node ./scripts/build-tests.js",
|
||||
"watch-tests": "tsc -p . -w --outDir out",
|
||||
"check-types": "npm run protos && tsc --noEmit && cd webview-ui && tsc --noEmit",
|
||||
"check-types": "npm run protos && npx tsc --noEmit && cd webview-ui && npx tsc --noEmit",
|
||||
"lint": "biome lint --config-path ./biome.jsonc --no-errors-on-unmatched --files-ignore-unknown=true --diagnostic-level=error && npm run lint:proto",
|
||||
"lint:proto": "bash ./scripts/proto-lint.sh",
|
||||
"format": "biome format --config-path ./biome.jsonc --changed --since main --no-errors-on-unmatched --files-ignore-unknown=true --diagnostic-level=error",
|
||||
"format:fix": "biome check --config-path ./biome.jsonc --changed --since main --no-errors-on-unmatched --files-ignore-unknown=true --write",
|
||||
"fix:all": "biome check --config-path ./biome.jsonc --no-errors-on-unmatched --files-ignore-unknown=true --write --diagnostic-level=error --unsafe",
|
||||
"analyze:unused": "npx --yes knip --config knip.json --include files,exports,types,enumMembers,duplicates",
|
||||
"analyze:unused:prod": "npx --yes knip --config knip.json --production --include files,exports,types,enumMembers,dependencies",
|
||||
"analyze:unused:fix-exports": "node scripts/remove-unused-exports.mjs --apply",
|
||||
"analyze:unused:fix-exports:dry": "node scripts/remove-unused-exports.mjs",
|
||||
"format": "biome format --config-path ./biome.jsonc --changed --since main --no-errors-on-unmatched --files-ignore-unknown=true --diagnostic-level=error --semicolons=as-needed",
|
||||
"format:fix": "biome check --config-path ./biome.jsonc --changed --since main --no-errors-on-unmatched --files-ignore-unknown=true --write --semicolons=as-needed",
|
||||
"fix:all": "biome check --config-path ./biome.jsonc --no-errors-on-unmatched --files-ignore-unknown=true --write --diagnostic-level=error --unsafe --semicolons=as-needed",
|
||||
"ci:check-all": "npx npm-run-all -p check-types lint format",
|
||||
"ci:build": "npm run protos && npm run build:webview && node esbuild.mjs && npm run compile-tests",
|
||||
"pretest": "npm run compile && npm run compile-tests && npm run compile-standalone && npm run lint",
|
||||
"test": "npx npm-run-all test:unit test:integration",
|
||||
"test:integration": "vscode-test",
|
||||
"test:integration": "npm run compile-tests && vscode-test",
|
||||
"test:unit": "cross-env TS_NODE_PROJECT=./tsconfig.unit-test.json mocha",
|
||||
"test:coverage": "vscode-test --coverage",
|
||||
"test:vitest": "vitest run --config vitest.config.ts",
|
||||
"test:vitest:watch": "vitest --config vitest.config.ts",
|
||||
"test:coverage": "npm run compile-tests && vscode-test --coverage",
|
||||
"test:sca-server": "npx tsx watch scripts/test-standalone-core-api-server.ts",
|
||||
"test:tp-orchestrator": "npx tsx scripts/testing-platform-orchestrator.ts",
|
||||
"dev:mcp-oauth-test-server": "npx tsx src/dev/mcp-oauth-test-server/server.ts",
|
||||
"e2e": "playwright test -c playwright.config.ts",
|
||||
"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",
|
||||
@@ -433,10 +405,10 @@
|
||||
"lint-staged": {
|
||||
"src/shared/storage/state-keys.ts": [
|
||||
"node scripts/generate-state-proto.mjs",
|
||||
"git add proto/cline/state.proto"
|
||||
"git add apps/vscode/proto/cline/state.proto"
|
||||
],
|
||||
"*": [
|
||||
"biome check --write --staged --no-errors-on-unmatched --files-ignore-unknown=true"
|
||||
"biome check --write --staged --no-errors-on-unmatched --files-ignore-unknown=true --semicolons=as-needed"
|
||||
]
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -479,27 +451,28 @@
|
||||
"rimraf": "^6.0.1",
|
||||
"should": "^13.2.3",
|
||||
"sinon": "^21.0.3",
|
||||
"tar": "^7.5.2",
|
||||
"tree-kill": "^1.2.2",
|
||||
"ts-node": "^10.9.2",
|
||||
"ts-proto": "^2.6.1",
|
||||
"tsconfig-paths": "^4.2.0",
|
||||
"typescript": "^5.4.5"
|
||||
"typescript": "^5.4.5",
|
||||
"vitest": "^4.0.17"
|
||||
},
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "^0.37.0",
|
||||
"@anthropic-ai/vertex-sdk": "^0.6.4",
|
||||
"@aws-sdk/client-bedrock-runtime": "^3.922.0",
|
||||
"@aws-sdk/credential-providers": "^3.922.0",
|
||||
"@azure/identity": "^4.13.0",
|
||||
"@bufbuild/protobuf": "^2.2.5",
|
||||
"@cerebras/cerebras_cloud_sdk": "^1.35.0",
|
||||
"@google-cloud/vertexai": "^1.9.3",
|
||||
"@cline/agents": "0.0.47",
|
||||
"@cline/core": "0.0.47",
|
||||
"@cline/llms": "0.0.47",
|
||||
"@cline/shared": "0.0.47",
|
||||
"@google/genai": "^1.30.0",
|
||||
"@grpc/grpc-js": "^1.9.15",
|
||||
"@grpc/proto-loader": "^0.7.13",
|
||||
"@grpc/reflection": "^1.0.4",
|
||||
"@mistralai/mistralai": "^1.5.0",
|
||||
"@modelcontextprotocol/sdk": "^1.25.1",
|
||||
"@opentelemetry/api": "^1.9.0",
|
||||
"@opentelemetry/api-logs": "^0.56.0",
|
||||
"@opentelemetry/core": "^2.1.0",
|
||||
"@opentelemetry/exporter-logs-otlp-grpc": "^0.56.0",
|
||||
"@opentelemetry/exporter-logs-otlp-http": "^0.56.0",
|
||||
@@ -519,9 +492,6 @@
|
||||
"@opentelemetry/sdk-trace-node": "^1.30.1",
|
||||
"@opentelemetry/semantic-conventions": "^1.37.0",
|
||||
"@playwright/test": "^1.55.1",
|
||||
"@sap-ai-sdk/ai-api": "^2.7.0",
|
||||
"@sap-ai-sdk/orchestration": "^2.7.0",
|
||||
"@sap-cloud-sdk/connectivity": "^4.6.0",
|
||||
"@streamparser/json": "^0.0.22",
|
||||
"@tailwindcss/vite": "^4.1.14",
|
||||
"@types/uuid": "^10.0.0",
|
||||
@@ -547,13 +517,14 @@
|
||||
"ignore": "^7.0.3",
|
||||
"image-size": "^2.0.2",
|
||||
"isbinaryfile": "^5.0.2",
|
||||
"js-yaml": "^4.1.1",
|
||||
"jschardet": "^3.1.4",
|
||||
"json5": "^2.2.3",
|
||||
"jwt-decode": "^4.0.0",
|
||||
"mammoth": "^1.11.0",
|
||||
"nanoid": "^5.1.6",
|
||||
"nice-grpc": "^2.1.12",
|
||||
"node-machine-id": "^1.1.12",
|
||||
"ollama": "^0.5.13",
|
||||
"open": "^10.1.2",
|
||||
"open-graph-scraper": "^6.9.0",
|
||||
"openai": "^6.21.0",
|
||||
@@ -572,15 +543,13 @@
|
||||
"simple-git": "3.36.0",
|
||||
"strip-ansi": "^7.1.2",
|
||||
"tailwindcss": "^4.1.14",
|
||||
"tree-sitter-wasms": "^0.1.11",
|
||||
"ts-morph": "^25.0.1",
|
||||
"turndown": "^7.2.0",
|
||||
"ulid": "^2.4.0",
|
||||
"undici": "^7.26.0",
|
||||
"uuid": "^11.1.0",
|
||||
"vscode-uri": "^3.1.0",
|
||||
"web-tree-sitter": "^0.22.6",
|
||||
"zod": "^3.24.2"
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"overrides": {
|
||||
"tar-fs": ">=3.1.1",
|
||||
|
||||
@@ -12,16 +12,11 @@ service McpService {
|
||||
rpc toggleMcpServer(ToggleMcpServerRequest) returns (McpServers);
|
||||
rpc updateMcpTimeout(UpdateMcpTimeoutRequest) returns (McpServers);
|
||||
rpc addRemoteMcpServer(AddRemoteMcpServerRequest) returns (McpServers);
|
||||
rpc downloadMcp(StringRequest) returns (McpDownloadResponse);
|
||||
rpc restartMcpServer(StringRequest) returns (McpServers);
|
||||
rpc deleteMcpServer(StringRequest) returns (McpServers);
|
||||
rpc toggleToolAutoApprove(ToggleToolAutoApproveRequest) returns (McpServers);
|
||||
rpc refreshMcpMarketplace(EmptyRequest) returns (McpMarketplaceCatalog);
|
||||
rpc openMcpSettings(EmptyRequest) returns (Empty);
|
||||
rpc authenticateMcpServer(StringRequest) returns (Empty);
|
||||
|
||||
// Subscribe to MCP marketplace catalog updates
|
||||
rpc subscribeToMcpMarketplaceCatalog(EmptyRequest) returns (stream McpMarketplaceCatalog);
|
||||
rpc getLatestMcpServers(Empty) returns (McpServers);
|
||||
|
||||
// Subscribe to MCP server updates
|
||||
@@ -114,40 +109,3 @@ message McpServer {
|
||||
message McpServers {
|
||||
repeated McpServer mcp_servers = 1;
|
||||
}
|
||||
|
||||
message McpMarketplaceItem {
|
||||
string mcp_id = 1;
|
||||
string github_url = 2;
|
||||
string name = 3;
|
||||
string author = 4;
|
||||
string description = 5;
|
||||
string codicon_icon = 6;
|
||||
string logo_url = 7;
|
||||
string category = 8;
|
||||
repeated string tags = 9;
|
||||
bool requires_api_key = 10;
|
||||
optional string readme_content = 11;
|
||||
optional string llms_installation_content = 12;
|
||||
bool is_recommended = 13;
|
||||
int32 github_stars = 14;
|
||||
int32 download_count = 15;
|
||||
string created_at = 16;
|
||||
string updated_at = 17;
|
||||
string last_github_sync = 18;
|
||||
}
|
||||
|
||||
message McpMarketplaceCatalog {
|
||||
repeated McpMarketplaceItem items = 1;
|
||||
}
|
||||
|
||||
message McpDownloadResponse {
|
||||
string mcp_id = 1;
|
||||
string github_url = 2;
|
||||
string name = 3;
|
||||
string author = 4;
|
||||
string description = 5;
|
||||
string readme_content = 6;
|
||||
string llms_installation_content = 7;
|
||||
bool requires_api_key = 8;
|
||||
optional string error = 9;
|
||||
}
|
||||
|
||||
@@ -21,8 +21,6 @@ service ModelsService {
|
||||
rpc refreshOpenRouterModelsRpc(EmptyRequest) returns (OpenRouterCompatibleModelInfo);
|
||||
// Refreshes and returns recommended and free Cline models
|
||||
rpc refreshClineRecommendedModelsRpc(EmptyRequest) returns (ClineRecommendedModelsResponse);
|
||||
// Refreshes and returns Cline provider models
|
||||
rpc refreshClineModelsRpc(EmptyRequest) returns (OpenRouterCompatibleModelInfo);
|
||||
// Refreshes and returns Hugging Face models
|
||||
rpc refreshHuggingFaceModels(EmptyRequest) returns (OpenRouterCompatibleModelInfo);
|
||||
// Refreshes and returns OpenAI models
|
||||
@@ -55,6 +53,18 @@ service ModelsService {
|
||||
rpc getAihubmixModels(EmptyRequest) returns (OpenRouterCompatibleModelInfo);
|
||||
// Refreshes and returns Vercel AI Gateway models
|
||||
rpc refreshVercelAiGatewayModelsRpc(EmptyRequest) returns (OpenRouterCompatibleModelInfo);
|
||||
// Lists providers available from the unified SDK-backed catalog
|
||||
rpc listProviders(Empty) returns (ProviderListingsResponse);
|
||||
// Resolves model metadata for a provider through the unified SDK-backed catalog
|
||||
rpc resolveProviderModels(ResolveProviderModelsRequest) returns (ProviderModelsResponse);
|
||||
// Resolves model metadata for a provider/model without refreshing model lists
|
||||
rpc resolveModelInfo(ResolveModelInfoRequest) returns (ResolveModelInfoResponse);
|
||||
// Reads redacted effective provider configuration
|
||||
rpc readProviderConfig(StringRequest) returns (ProviderConfigResponse);
|
||||
// Writes provider configuration fields and returns redacted effective configuration
|
||||
rpc writeProviderConfig(WriteProviderConfigRequest) returns (ProviderConfigResponse);
|
||||
// Commits a mode-specific model selection atomically with its model metadata
|
||||
rpc commitModelSelection(CommitModelSelectionRequest) returns (Empty);
|
||||
}
|
||||
|
||||
// List of VS Code LM models
|
||||
@@ -117,6 +127,142 @@ message OpenRouterCompatibleModelInfo {
|
||||
map<string, OpenRouterModelInfo> models = 1;
|
||||
}
|
||||
|
||||
// Lightweight provider entry for the top-level model/provider picker.
|
||||
// Does not include the full model list; use resolveProviderModels for models.
|
||||
message ProviderListing {
|
||||
string id = 1;
|
||||
string name = 2;
|
||||
optional string default_model_id = 3;
|
||||
optional string family = 4;
|
||||
optional string protocol = 5;
|
||||
optional string auth_description = 6;
|
||||
optional string base_url_description = 7;
|
||||
bool allows_custom_model_ids = 8;
|
||||
|
||||
// SDK-driven hint for cost display. Values: "show" (default) or "hide".
|
||||
// Sourced from `resolveProviderUsageCostDisplay(provider.metadata)` in
|
||||
// `@cline/llms`. When "hide", consumers must suppress per-token pricing
|
||||
// and total cost displays (matches the CLI's `shouldShowCliUsageCost`).
|
||||
string usage_cost_display = 11;
|
||||
}
|
||||
|
||||
message ProviderListingsResponse {
|
||||
repeated ProviderListing providers = 1;
|
||||
}
|
||||
|
||||
message ResolveProviderModelsRequest {
|
||||
string provider_id = 1;
|
||||
bool force_refresh = 2;
|
||||
optional string request_id = 3;
|
||||
}
|
||||
|
||||
message CatalogErrorInfo {
|
||||
string kind = 1;
|
||||
string message = 2;
|
||||
optional string code = 3;
|
||||
optional bool retryable = 4;
|
||||
}
|
||||
|
||||
message ProviderModelsResponse {
|
||||
string provider_id = 1;
|
||||
string request_id = 2;
|
||||
string config_fingerprint = 3;
|
||||
int64 fetched_at = 4;
|
||||
bool ok = 5;
|
||||
map<string, OpenRouterModelInfo> models = 6;
|
||||
optional string default_model_id = 7;
|
||||
optional string source = 8;
|
||||
optional CatalogErrorInfo error = 9;
|
||||
}
|
||||
|
||||
message ResolveModelInfoRequest {
|
||||
string provider_id = 1;
|
||||
optional string model_id = 2;
|
||||
}
|
||||
|
||||
message ResolveModelInfoResponse {
|
||||
string provider_id = 1;
|
||||
string model_id = 2;
|
||||
optional OpenRouterModelInfo model_info = 3;
|
||||
string source = 4;
|
||||
}
|
||||
|
||||
message AwsProviderConfig {
|
||||
optional string authentication = 1;
|
||||
optional string profile = 2;
|
||||
optional string access_key = 3;
|
||||
int64 access_key_length = 4;
|
||||
optional string secret_key = 5;
|
||||
int64 secret_key_length = 6;
|
||||
optional string session_token = 7;
|
||||
int64 session_token_length = 8;
|
||||
optional string endpoint = 9;
|
||||
optional bool use_prompt_cache = 10;
|
||||
optional string custom_model_base_id = 11;
|
||||
optional bool use_cross_region_inference = 12;
|
||||
optional bool use_global_inference = 13;
|
||||
}
|
||||
|
||||
message GcpProviderConfig {
|
||||
optional string project_id = 1;
|
||||
optional string region = 2;
|
||||
}
|
||||
|
||||
message ProviderConfigResponse {
|
||||
string provider_id = 1;
|
||||
optional string base_url = 2;
|
||||
optional string api_line = 3;
|
||||
map<string, string> headers = 4;
|
||||
optional string region = 5;
|
||||
int64 api_key_length = 6;
|
||||
bool has_access_token = 7;
|
||||
bool has_refresh_token = 8;
|
||||
optional string account_id = 9;
|
||||
optional CommittedModelSelection plan_selection = 10;
|
||||
optional CommittedModelSelection act_selection = 11;
|
||||
optional AwsProviderConfig aws = 12;
|
||||
optional GcpProviderConfig gcp = 13;
|
||||
}
|
||||
|
||||
message CommittedModelSelection {
|
||||
string provider_id = 1;
|
||||
string model_id = 2;
|
||||
OpenRouterModelInfo model_info = 3;
|
||||
}
|
||||
|
||||
message ProviderReasoningPatch {
|
||||
optional bool enabled = 1;
|
||||
optional string effort = 2; // "none" | "low" | "medium" | "high" | "xhigh"
|
||||
optional int32 budget_tokens = 3;
|
||||
}
|
||||
|
||||
message WriteProviderConfigPatch {
|
||||
optional string api_key = 1;
|
||||
optional string base_url = 2;
|
||||
map<string, string> headers = 3;
|
||||
optional string region = 4;
|
||||
optional string api_line = 5;
|
||||
optional string access_token = 6;
|
||||
optional string refresh_token = 7;
|
||||
optional string account_id = 8;
|
||||
optional ProviderReasoningPatch reasoning = 9;
|
||||
optional bool clear_headers = 10;
|
||||
optional AwsProviderConfig aws = 11;
|
||||
optional GcpProviderConfig gcp = 12;
|
||||
}
|
||||
|
||||
message WriteProviderConfigRequest {
|
||||
string provider_id = 1;
|
||||
WriteProviderConfigPatch patch = 2;
|
||||
}
|
||||
|
||||
message CommitModelSelectionRequest {
|
||||
string provider_id = 1;
|
||||
string mode = 2;
|
||||
string model_id = 3;
|
||||
OpenRouterModelInfo model_info = 4;
|
||||
}
|
||||
|
||||
message ClineRecommendedModel {
|
||||
string id = 1;
|
||||
string name = 2;
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
syntax = "proto3";
|
||||
|
||||
package cline;
|
||||
|
||||
import "cline/common.proto";
|
||||
|
||||
option go_package = "github.com/cline/grpc-go/cline";
|
||||
option java_multiple_files = true;
|
||||
option java_package = "bot.cline.proto";
|
||||
|
||||
enum RemoteConfigType {
|
||||
RULE = 0;
|
||||
WORKFLOW = 1;
|
||||
SKILL = 2;
|
||||
}
|
||||
|
||||
message RemoteConfigSetting {
|
||||
RemoteConfigType type = 1;
|
||||
string name = 2;
|
||||
string content = 3;
|
||||
bool enabled = 4;
|
||||
bool locked = 5;
|
||||
}
|
||||
|
||||
message RemoteConfigSettingsResponse {
|
||||
repeated RemoteConfigSetting settings = 1;
|
||||
}
|
||||
|
||||
service RemoteConfigService {
|
||||
rpc getRemoteConfigSettings(Empty) returns (RemoteConfigSettingsResponse);
|
||||
rpc toggleRemoteConfigSetting(StringRequest) returns (RemoteConfigSetting);
|
||||
}
|
||||
@@ -23,7 +23,7 @@ message SlashCommandInfo {
|
||||
string name = 1; // Command name without slash, e.g., "newtask", "smol"
|
||||
string description = 2; // Human-readable description
|
||||
string section = 3; // "default", "custom", or "cli"
|
||||
bool cli_compatible = 4; // false for VS Code-only commands like explain-changes
|
||||
bool cli_compatible = 4; // false for VS Code-only commands
|
||||
}
|
||||
|
||||
// Response containing all available slash commands
|
||||
|
||||
@@ -250,7 +250,6 @@ message Settings {
|
||||
optional string default_terminal_profile = 137;
|
||||
optional int32 terminal_output_line_limit = 138;
|
||||
optional int32 max_consecutive_mistakes = 139;
|
||||
optional bool strict_plan_mode_enabled = 141;
|
||||
optional bool yolo_mode_toggled = 142;
|
||||
optional bool use_auto_condense = 143;
|
||||
optional bool cline_web_tools_enabled = 144;
|
||||
@@ -286,7 +285,6 @@ message Settings {
|
||||
optional string act_mode_cline_model_id = 180;
|
||||
optional OpenRouterModelInfo act_mode_cline_model_info = 181;
|
||||
optional bool show_feature_tips = 182;
|
||||
optional bool lazy_teammate_mode_enabled = 183;
|
||||
}
|
||||
|
||||
message State {
|
||||
@@ -391,6 +389,7 @@ message UpdateSettingsRequest {
|
||||
reserved 15; // was openai_reasoning_effort (moved to mode-scoped reasoning effort)
|
||||
reserved 23; // was dictation_settings (dictation removed)
|
||||
reserved 38; // was skills_enabled (removed - now always enabled)
|
||||
reserved 43; // was lazy_teammate_mode_enabled (removed)
|
||||
|
||||
Metadata metadata = 1;
|
||||
optional ModelsApiConfiguration api_configuration = 2;
|
||||
@@ -405,7 +404,7 @@ message UpdateSettingsRequest {
|
||||
optional int32 terminal_output_line_limit = 12;
|
||||
optional PlanActMode mode = 13;
|
||||
optional string preferred_language = 14;
|
||||
optional bool strict_plan_mode_enabled = 16;
|
||||
reserved 16; // was strict_plan_mode_enabled (removed)
|
||||
optional FocusChainSettings focus_chain_settings = 17;
|
||||
optional bool use_auto_condense = 18;
|
||||
optional string custom_prompt = 19;
|
||||
@@ -429,7 +428,6 @@ message UpdateSettingsRequest {
|
||||
optional bool worktrees_enabled = 40;
|
||||
optional bool double_check_completion_enabled = 41;
|
||||
optional bool show_feature_tips = 42;
|
||||
optional bool lazy_teammate_mode_enabled = 43;
|
||||
}
|
||||
|
||||
message UpdateTerminalConnectionTimeoutRequest {
|
||||
|
||||
@@ -32,6 +32,8 @@ service TaskService {
|
||||
rpc getTaskHistory(GetTaskHistoryRequest) returns (TaskHistoryArray);
|
||||
// Sends a response to a previous ask operation
|
||||
rpc askResponse(AskResponseRequest) returns (Empty);
|
||||
// Edits a previous user message, truncates following conversation, and regenerates
|
||||
rpc editMessageAndRegenerate(EditMessageAndRegenerateRequest) returns (Empty);
|
||||
// Records task feedback (thumbs up/down)
|
||||
rpc taskFeedback(StringRequest) returns (Empty);
|
||||
// Shows task completion changes diff in a view
|
||||
@@ -40,8 +42,6 @@ service TaskService {
|
||||
rpc executeQuickWin(ExecuteQuickWinRequest) returns (Empty);
|
||||
// Deletes all task history
|
||||
rpc deleteAllTaskHistory(EmptyRequest) returns (DeleteAllTaskHistoryCount);
|
||||
// Explains changes with AI and adds inline comments to the diff view
|
||||
rpc explainChanges(ExplainChangesRequest) returns (Empty);
|
||||
}
|
||||
|
||||
// Request message for creating a new task
|
||||
@@ -82,12 +82,14 @@ message GetTaskHistoryRequest {
|
||||
string search_query = 3;
|
||||
string sort_by = 4;
|
||||
bool current_workspace_only = 5;
|
||||
int32 limit = 6;
|
||||
int32 offset = 7;
|
||||
}
|
||||
|
||||
// Response for task history
|
||||
message TaskHistoryArray {
|
||||
repeated TaskItem tasks = 1;
|
||||
int32 total_count = 2;
|
||||
bool has_more = 2;
|
||||
}
|
||||
|
||||
// Task item details for history list
|
||||
@@ -114,6 +116,16 @@ message AskResponseRequest {
|
||||
repeated string files = 5;
|
||||
}
|
||||
|
||||
// Request for editing a past user message and regenerating the conversation after it
|
||||
message EditMessageAndRegenerateRequest {
|
||||
Metadata metadata = 1;
|
||||
int64 message_ts = 2;
|
||||
string text = 3;
|
||||
repeated string images = 4;
|
||||
repeated string files = 5;
|
||||
bool restore_workspace = 6;
|
||||
}
|
||||
|
||||
// Request for executing a quick win task
|
||||
message ExecuteQuickWinRequest {
|
||||
Metadata metadata = 1;
|
||||
@@ -125,10 +137,3 @@ message ExecuteQuickWinRequest {
|
||||
message DeleteAllTaskHistoryCount {
|
||||
int32 tasks_deleted = 1;
|
||||
}
|
||||
|
||||
// Request for explaining changes with AI
|
||||
message ExplainChangesRequest {
|
||||
Metadata metadata = 1;
|
||||
// Timestamp of the completion message to explain changes for
|
||||
int64 message_ts = 2;
|
||||
}
|
||||
|
||||
@@ -67,7 +67,6 @@ enum ClineSay {
|
||||
INFO = 26;
|
||||
TASK_PROGRESS = 27;
|
||||
ERROR_RETRY = 28;
|
||||
GENERATE_EXPLANATION = 29;
|
||||
HOOK_STATUS = 30;
|
||||
HOOK_OUTPUT_STREAM = 31;
|
||||
COMMAND_PERMISSION_DENIED = 32;
|
||||
@@ -226,6 +225,12 @@ message ClineMessage {
|
||||
ClineAskNewTask ask_new_task = 21;
|
||||
ClineApiReqInfo api_req_info = 22;
|
||||
ClineModelInfo model_info = 23;
|
||||
|
||||
// Convergent-replica fields (see webview-message-state-design.md):
|
||||
// seq = monotonic freshness (higher seq wins for the same ts/identity)
|
||||
// epoch = conversation/replica fence (older epoch is dropped by the webview)
|
||||
int64 seq = 24;
|
||||
int64 epoch = 25;
|
||||
}
|
||||
|
||||
message ShowWebviewEvent {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
#!/usr/bin/env node
|
||||
const { execSync } = require("child_process")
|
||||
const fs = require("fs")
|
||||
const path = require("path")
|
||||
const esbuild = require("esbuild")
|
||||
|
||||
const watch = process.argv.includes("--watch")
|
||||
@@ -53,6 +55,12 @@ async function main() {
|
||||
}
|
||||
}
|
||||
|
||||
// tsc does not delete output for source/tests that were removed or are no longer
|
||||
// part of tsconfig.test.json. The VS Code test runner globs out/src/**/*.test.js,
|
||||
// so stale compiled tests can still run unless we clear the test build output first.
|
||||
fs.rmSync(path.join(__dirname, "..", "out", "src"), { recursive: true, force: true })
|
||||
fs.rmSync(path.join(__dirname, "..", "out", "packages"), { recursive: true, force: true })
|
||||
|
||||
execSync("tsc -p ./tsconfig.test.json --outDir out", { encoding: "utf-8" })
|
||||
|
||||
main().catch((e) => {
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
// Dead-source finder: uses esbuild's own bundle reachability (the same analysis
|
||||
// that drives tree-shaking + minification mangling) to compute which src/ files
|
||||
// are reachable from BOTH shipped entry points:
|
||||
// - src/extension.ts (VS Code extension host)
|
||||
// - src/standalone/cline-core.ts (standalone host used by JetBrains + CLI)
|
||||
//
|
||||
// A src/*.ts file that is NOT in the union of metafile inputs for those two
|
||||
// builds is unreachable from any shipped entry => dead (modulo dynamic import()
|
||||
// of computed specifiers, which esbuild surfaces separately).
|
||||
//
|
||||
// Run: node scripts/find-dead-src.mjs
|
||||
|
||||
import fs from "node:fs"
|
||||
import path from "node:path"
|
||||
import { fileURLToPath } from "node:url"
|
||||
import * as esbuild from "esbuild"
|
||||
import { glob } from "glob"
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||
const root = path.resolve(__dirname, "..")
|
||||
|
||||
const aliases = {
|
||||
"@": path.join(root, "src"),
|
||||
"@core": path.join(root, "src/core"),
|
||||
"@integrations": path.join(root, "src/integrations"),
|
||||
"@services": path.join(root, "src/services"),
|
||||
"@shared": path.join(root, "src/shared"),
|
||||
"@utils": path.join(root, "src/utils"),
|
||||
"@packages": path.join(root, "src/packages"),
|
||||
}
|
||||
|
||||
const aliasResolverPlugin = {
|
||||
name: "alias-resolver",
|
||||
setup(build) {
|
||||
for (const [alias, aliasPath] of Object.entries(aliases)) {
|
||||
const aliasRegex = new RegExp(`^${alias}($|/.*)`)
|
||||
build.onResolve({ filter: aliasRegex }, (args) => {
|
||||
const importPath = args.path.replace(alias, aliasPath)
|
||||
const exts = [".ts", ".tsx", ".js", ".jsx"]
|
||||
if (fs.existsSync(importPath)) {
|
||||
const stats = fs.statSync(importPath)
|
||||
if (stats.isDirectory()) {
|
||||
for (const ext of exts) {
|
||||
const idx = path.join(importPath, `index${ext}`)
|
||||
if (fs.existsSync(idx)) return { path: idx }
|
||||
}
|
||||
} else {
|
||||
return { path: importPath }
|
||||
}
|
||||
}
|
||||
for (const ext of exts) {
|
||||
if (fs.existsSync(`${importPath}${ext}`)) return { path: `${importPath}${ext}` }
|
||||
}
|
||||
return undefined
|
||||
})
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
const common = {
|
||||
bundle: true,
|
||||
minify: false,
|
||||
sourcemap: false,
|
||||
logLevel: "silent",
|
||||
format: "cjs",
|
||||
platform: "node",
|
||||
metafile: true,
|
||||
write: false,
|
||||
absWorkingDir: root,
|
||||
tsconfig: path.join(root, "tsconfig.json"),
|
||||
packages: "external",
|
||||
plugins: [aliasResolverPlugin],
|
||||
define: { "process.env.IS_DEV": "false", "process.env.IS_TEST": "false" },
|
||||
banner: { js: "const _importMetaUrl=require('url').pathToFileURL(__filename)" },
|
||||
}
|
||||
|
||||
async function inputsFor(entry, external) {
|
||||
const r = await esbuild.build({ ...common, entryPoints: [entry], external })
|
||||
return new Set(Object.keys(r.metafile.inputs).filter((f) => f.startsWith("src/") && /\.tsx?$/.test(f)))
|
||||
}
|
||||
|
||||
const ext = await inputsFor("src/extension.ts", ["vscode"])
|
||||
const standalone = await inputsFor("src/standalone/cline-core.ts", [
|
||||
"vscode",
|
||||
"@grpc/reflection",
|
||||
"grpc-health-check",
|
||||
"better-sqlite3",
|
||||
])
|
||||
const live = new Set([...ext, ...standalone])
|
||||
|
||||
// Third consumer: the webview (webview-ui/) is a separate Vite/React build that
|
||||
// imports extension code ONLY from src/shared (via "@shared/*" alias or relative
|
||||
// "../src/shared/*" paths). Any src/shared file referenced from webview-ui/src is
|
||||
// therefore live even if the extension-host/standalone bundles don't reach it.
|
||||
// Conservatively mark every src/shared file mentioned by the webview as live.
|
||||
const webviewFiles = await glob("webview-ui/src/**/*.{ts,tsx}", { cwd: root })
|
||||
const sharedMentionedByWebview = new Set()
|
||||
for (const wf of webviewFiles) {
|
||||
const text = fs.readFileSync(path.join(root, wf), "utf8")
|
||||
// Match @shared/X or .../src/shared/X import specifiers and map to src/shared/X
|
||||
const re = /(?:@shared\/|src\/shared\/)([A-Za-z0-9_./-]+)/g
|
||||
let m
|
||||
while ((m = re.exec(text))) {
|
||||
const rel = m[1].replace(/\.(ts|tsx|js|jsx)$/, "")
|
||||
for (const cand of [`src/shared/${rel}.ts`, `src/shared/${rel}.tsx`, `src/shared/${rel}/index.ts`]) {
|
||||
if (fs.existsSync(path.join(root, cand))) sharedMentionedByWebview.add(cand)
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const f of sharedMentionedByWebview) live.add(f)
|
||||
console.log(`src/shared files referenced by webview: ${sharedMentionedByWebview.size}`)
|
||||
|
||||
// All non-test, non-.d.ts source files on disk.
|
||||
const allSrc = (await glob("src/**/*.{ts,tsx}", { cwd: root }))
|
||||
.filter((f) => !/\.test\.tsx?$/.test(f))
|
||||
.filter((f) => !f.endsWith(".d.ts"))
|
||||
.filter((f) => !f.includes("/__tests__/"))
|
||||
.filter((f) => !f.startsWith("src/test/"))
|
||||
.filter((f) => !f.startsWith("src/generated/")) // generated host glue
|
||||
.filter((f) => !f.startsWith("src/dev/")) // dev-only tooling
|
||||
|
||||
const dead = allSrc.filter((f) => !live.has(f)).sort()
|
||||
|
||||
console.log(`extension inputs: ${ext.size}`)
|
||||
console.log(`standalone inputs: ${standalone.size}`)
|
||||
console.log(`union live src files: ${live.size}`)
|
||||
console.log(`candidate dead files: ${dead.length}`)
|
||||
fs.writeFileSync("/tmp/dead-src.json", JSON.stringify(dead, null, "\t"))
|
||||
console.log("--- dead candidates written to /tmp/dead-src.json ---")
|
||||
@@ -87,6 +87,12 @@ function createMockService<T extends grpc.UntypedServiceImplementation>(serviceN
|
||||
})
|
||||
return
|
||||
|
||||
case "openExternal":
|
||||
simulateOAuthBrowserCallback(call.request?.value || "")
|
||||
.then(() => callback(null, {}))
|
||||
.catch((error) => callback(error))
|
||||
return
|
||||
|
||||
case "getWebviewHtml":
|
||||
callback(null, {
|
||||
html: "<html><body>Fake Webview</body></html>",
|
||||
@@ -143,6 +149,41 @@ function createMockService<T extends grpc.UntypedServiceImplementation>(serviceN
|
||||
return new Proxy({} as T, handler)
|
||||
}
|
||||
|
||||
async function simulateOAuthBrowserCallback(urlString: string): Promise<void> {
|
||||
let url: URL
|
||||
try {
|
||||
url = new URL(urlString)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
if (!isLoopbackHost(url.hostname) || url.pathname !== "/api/v1/auth/authorize") {
|
||||
return
|
||||
}
|
||||
|
||||
const callbackUrl = url.searchParams.get("callback_url") ?? url.searchParams.get("redirect_uri")
|
||||
if (!callbackUrl) {
|
||||
return
|
||||
}
|
||||
|
||||
const callback = new URL(callbackUrl)
|
||||
if (!isLoopbackHost(callback.hostname) || callback.pathname !== "/auth") {
|
||||
return
|
||||
}
|
||||
|
||||
callback.searchParams.set("code", "test-personal-token")
|
||||
callback.searchParams.set("provider", "cline")
|
||||
|
||||
const response = await fetch(callback.toString())
|
||||
if (!response.ok) {
|
||||
throw new Error(`Mock OAuth callback failed: ${response.status} ${response.statusText}`)
|
||||
}
|
||||
}
|
||||
|
||||
function isLoopbackHost(hostname: string): boolean {
|
||||
return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1"
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
startTestHostBridgeServer().catch((err) => {
|
||||
console.error("Failed to start test host bridge server:", err)
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
* The following components are started automatically:
|
||||
* 1. HostBridge test server
|
||||
* 2. ClineApiServerMock (mock implementation of the Cline API)
|
||||
* 3. AuthServiceMock (activated if E2E_TEST="true")
|
||||
* 3. SDK WorkOS device-auth flow, with WorkOS fetches mocked by testing-platform-workos-fetch-mock.cjs
|
||||
*
|
||||
* Environment Variables for Customization:
|
||||
* PROJECT_ROOT - Override project root directory (default: parent of scripts dir)
|
||||
@@ -22,7 +22,7 @@
|
||||
* PROTOBUS_PORT - gRPC server port (default: 26040)
|
||||
* HOSTBRIDGE_PORT - HostBridge server port (default: 26041)
|
||||
* WORKSPACE_DIR - Working directory (default: current directory)
|
||||
* E2E_TEST - Enable E2E test mode (default: true)
|
||||
* E2E_TEST - Enable legacy mock auth mode (default: false)
|
||||
* CLINE_ENVIRONMENT - Environment setting (default: local)
|
||||
*
|
||||
* Ideal for local development, testing, or lightweight E2E scenarios.
|
||||
@@ -38,7 +38,7 @@ import { ClineApiServerMock } from "../src/test/e2e/fixtures/server/index"
|
||||
const PROTOBUS_PORT = process.env.PROTOBUS_PORT || "26040"
|
||||
const HOSTBRIDGE_PORT = process.env.HOSTBRIDGE_PORT || "26041"
|
||||
const WORKSPACE_DIR = process.env.WORKSPACE_DIR || process.cwd()
|
||||
const E2E_TEST = process.env.E2E_TEST || "true"
|
||||
const E2E_TEST = process.env.E2E_TEST || "false"
|
||||
const CLINE_ENVIRONMENT = process.env.CLINE_ENVIRONMENT || "local"
|
||||
const USE_C8 = process.env.USE_C8 === "true"
|
||||
|
||||
@@ -115,7 +115,8 @@ async function main(): Promise<void> {
|
||||
|
||||
const covDir = path.join(projectRoot, `coverage/coverage-core-${PROTOBUS_PORT}`)
|
||||
|
||||
const baseArgs = ["--enable-source-maps", path.join(distDir, "cline-core.js")]
|
||||
const workosFetchMockPath = path.join(projectRoot, "scripts", "testing-platform-workos-fetch-mock.cjs")
|
||||
const baseArgs = ["--enable-source-maps", "--require", workosFetchMockPath, path.join(distDir, "cline-core.js")]
|
||||
|
||||
const spawnArgs = USE_C8 ? ["c8", "--report-dir", covDir, "node", ...baseArgs] : ["node", ...baseArgs]
|
||||
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
// Preload used by the standalone testing platform.
|
||||
// It makes the SDK WorkOS device-auth flow deterministic and fully local while
|
||||
// leaving production auth code on the same device-auth path used by users.
|
||||
|
||||
const originalFetch = globalThis.fetch?.bind(globalThis)
|
||||
|
||||
const WORKOS_ORIGIN = "https://api.workos.com"
|
||||
const DEVICE_CODE = "test-device-code"
|
||||
const USER_CODE = "PTBC-TXTP"
|
||||
const ACCESS_TOKEN = "test-personal-token"
|
||||
const REFRESH_TOKEN = "test-personal-token_refresh"
|
||||
|
||||
function jsonResponse(body, init = {}) {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status: init.status ?? 200,
|
||||
headers: { "Content-Type": "application/json", ...(init.headers ?? {}) },
|
||||
})
|
||||
}
|
||||
|
||||
function inputUrl(input) {
|
||||
if (typeof input === "string") return input
|
||||
if (input instanceof URL) return input.toString()
|
||||
if (input && typeof input === "object" && "url" in input) return input.url
|
||||
return String(input)
|
||||
}
|
||||
|
||||
globalThis.fetch = async (input, init) => {
|
||||
const urlString = inputUrl(input)
|
||||
let url
|
||||
try {
|
||||
url = new URL(urlString)
|
||||
} catch {
|
||||
return originalFetch(input, init)
|
||||
}
|
||||
|
||||
if (url.origin === WORKOS_ORIGIN && url.pathname === "/user_management/authorize/device") {
|
||||
return jsonResponse({
|
||||
device_code: DEVICE_CODE,
|
||||
user_code: USER_CODE,
|
||||
verification_uri: "https://login.workos.test/device",
|
||||
verification_uri_complete: `https://login.workos.test/device?user_code=${USER_CODE}`,
|
||||
expires_in: 300,
|
||||
interval: 1,
|
||||
})
|
||||
}
|
||||
|
||||
if (url.origin === WORKOS_ORIGIN && url.pathname === "/user_management/authenticate") {
|
||||
return jsonResponse({
|
||||
access_token: ACCESS_TOKEN,
|
||||
refresh_token: REFRESH_TOKEN,
|
||||
token_type: "Bearer",
|
||||
})
|
||||
}
|
||||
|
||||
return originalFetch(input, init)
|
||||
}
|
||||
@@ -21,9 +21,7 @@ describe("ClineEndpoint configuration", () => {
|
||||
|
||||
// Stub os.homedir to return our temp directory
|
||||
originalHomedir = os.homedir
|
||||
sandbox
|
||||
.stub(os, "homedir")
|
||||
.returns(tempDir)
|
||||
sandbox.stub(os, "homedir").returns(tempDir)
|
||||
|
||||
// Reset the singleton state using internal method
|
||||
;(ClineEndpoint as any)._instance = null
|
||||
|
||||
@@ -4,13 +4,13 @@ import "./utils/path" // necessary to have access to String.prototype.toPosix
|
||||
import { HostProvider } from "@/hosts/host-provider"
|
||||
import { Logger } from "@/shared/services/Logger"
|
||||
import type { StorageContext } from "@/shared/storage/storage-context"
|
||||
import { FileContextTracker } from "./core/context/context-tracking/FileContextTracker"
|
||||
import { clearOnboardingModelsCache } from "./core/controller/models/getClineOnboardingModels"
|
||||
import { HookDiscoveryCache } from "./core/hooks/HookDiscoveryCache"
|
||||
import { HookProcessRegistry } from "./core/hooks/HookProcessRegistry"
|
||||
import { StateManager } from "./core/storage/StateManager"
|
||||
import { AgentConfigLoader } from "./core/task/tools/subagent/AgentConfigLoader"
|
||||
import { ExtensionRegistryInfo } from "./registry"
|
||||
import { registerVsCodeLmHandler } from "./sdk/vscode-lm/register-vscode-lm"
|
||||
import { ErrorService } from "./services/error"
|
||||
import { featureFlagsService } from "./services/feature-flags"
|
||||
import { getDistinctId } from "./services/logging/distinctId"
|
||||
@@ -52,6 +52,11 @@ export async function initialize(storageContext: StorageContext): Promise<Webvie
|
||||
})
|
||||
}
|
||||
|
||||
// Register host-only SDK provider handlers (e.g. VS Code Language Model API),
|
||||
// which depend on the `vscode` module and cannot live in the SDK package.
|
||||
// Must run before any handler is built (standalone utilities or task loop).
|
||||
registerVsCodeLmHandler()
|
||||
|
||||
// =============== External services ===============
|
||||
await ErrorService.initialize()
|
||||
// Initialize PostHog client provider (skip in self-hosted mode)
|
||||
@@ -74,8 +79,6 @@ export async function initialize(storageContext: StorageContext): Promise<Webvie
|
||||
syncWorker().init({ ...blobStoreSettings, userDistinctId: getDistinctId() })
|
||||
// Clean up old temp files in background (non-blocking) and start periodic cleanup every 24 hours
|
||||
ClineTempManager.startPeriodicCleanup()
|
||||
// Clean up orphaned file context warnings (startup cleanup)
|
||||
FileContextTracker.cleanupOrphanedWarnings(stateManager)
|
||||
|
||||
telemetryService.captureExtensionActivated()
|
||||
|
||||
@@ -106,7 +109,7 @@ async function showVersionUpdateAnnouncement(stateManager: StateManager) {
|
||||
})
|
||||
}
|
||||
// Always update the main version tracker for the next launch.
|
||||
await stateManager.setGlobalState("clineVersion", currentVersion)
|
||||
stateManager.setGlobalState("clineVersion", currentVersion)
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||
|
||||
@@ -4,12 +4,11 @@ import * as path from "path"
|
||||
import { Environment, type EnvironmentConfig } from "./shared/config-types"
|
||||
import { Logger } from "./shared/services/Logger"
|
||||
|
||||
export { Environment, type EnvironmentConfig }
|
||||
|
||||
/**
|
||||
export { Environment } /**
|
||||
* Schema for the endpoints.json configuration file used in on-premise deployments.
|
||||
* All fields are required and must be valid URLs.
|
||||
*/
|
||||
|
||||
interface EndpointsFileSchema {
|
||||
appBaseUrl: string
|
||||
apiBaseUrl: string
|
||||
@@ -36,7 +35,7 @@ class ClineEndpoint {
|
||||
private onPremiseConfig: EndpointsFileSchema | null = null
|
||||
private environment: Environment = Environment.production
|
||||
// Track if config came from bundled file (enterprise distribution)
|
||||
private isBundled: boolean = false
|
||||
private isBundled = false
|
||||
|
||||
private constructor() {
|
||||
// Set environment at module load. Use override if provided.
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,606 +0,0 @@
|
||||
import { ClineStorageMessage } from "@/shared/messages/content"
|
||||
|
||||
const APPLY_PATCH_PATCH_REGEX = /\*\*\* Begin Patch\s+([\s\S]*?)\s+\*\*\* End Patch/m
|
||||
|
||||
/**
|
||||
* Convert apply_patch tool calls to write_to_file and replace_in_file format
|
||||
*/
|
||||
export function convertApplyPatchToolCalls(messages: Array<ClineStorageMessage>): Array<ClineStorageMessage> {
|
||||
// Map to track tool_use_id to converted tool info and original input
|
||||
const toolUseIdMap = new Map<string, { name: string; input: any; originalInput: any }>()
|
||||
|
||||
return messages.map((message) => {
|
||||
if (!Array.isArray(message.content)) {
|
||||
return message
|
||||
}
|
||||
|
||||
const convertedContent = message.content.map((block) => {
|
||||
// Handle tool_use blocks
|
||||
if (block.type === "tool_use" && block.name === "apply_patch") {
|
||||
const converted = convertApplyPatchToToolCalls(block.input)
|
||||
// Store the conversion with original input for matching tool_result
|
||||
toolUseIdMap.set(block.id, { ...converted, originalInput: block.input })
|
||||
|
||||
return {
|
||||
...block,
|
||||
name: converted.name,
|
||||
input: converted.input,
|
||||
}
|
||||
}
|
||||
|
||||
// Handle tool_result blocks
|
||||
if (block.type === "tool_result") {
|
||||
const conversion = toolUseIdMap.get(block.tool_use_id)
|
||||
if (conversion) {
|
||||
// Reconstruct the tool_result content to match apply_patch format
|
||||
const reconstructedContent = reconstructApplyPatchResult(
|
||||
block,
|
||||
conversion.name,
|
||||
conversion.input,
|
||||
conversion.originalInput,
|
||||
)
|
||||
return {
|
||||
...block,
|
||||
content: reconstructedContent,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return block
|
||||
})
|
||||
|
||||
return {
|
||||
...message,
|
||||
content: convertedContent,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
interface ConvertedTool {
|
||||
name: string
|
||||
input: any
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse apply_patch input and convert to write_to_file or replace_in_file format
|
||||
*/
|
||||
function convertApplyPatchToToolCalls(input: any): ConvertedTool {
|
||||
const patchInput = typeof input === "string" ? input : input?.input || ""
|
||||
|
||||
// Parse the patch format
|
||||
const patchMatch = patchInput.match(APPLY_PATCH_PATCH_REGEX)
|
||||
if (!patchMatch) {
|
||||
// If we can't parse it, return as-is with write_to_file
|
||||
return {
|
||||
name: "write_to_file",
|
||||
input: input,
|
||||
}
|
||||
}
|
||||
|
||||
const patchContent = patchMatch[1]
|
||||
|
||||
// Extract file operation (Add, Update, or Delete)
|
||||
const fileMatch = patchContent.match(/\*\*\* (Add|Update|Delete) File: (.+?)(?:\n|$)/m)
|
||||
if (!fileMatch) {
|
||||
return {
|
||||
name: "write_to_file",
|
||||
input: input,
|
||||
}
|
||||
}
|
||||
|
||||
const action = fileMatch[1]
|
||||
const filePath = fileMatch[2].trim()
|
||||
|
||||
// If it's an Add operation, convert to write_to_file
|
||||
if (action === "Add") {
|
||||
// Extract the content after the file line
|
||||
const contentAfterFile = patchContent.substring(fileMatch.index! + fileMatch[0].length)
|
||||
return {
|
||||
name: "write_to_file",
|
||||
input: {
|
||||
absolutePath: filePath,
|
||||
content: extractNewContentFromPatch(contentAfterFile),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// If it's Update or Delete, convert to replace_in_file
|
||||
if (action === "Update" || action === "Delete") {
|
||||
const diff = convertPatchToDiff(patchContent.substring(fileMatch.index! + fileMatch[0].length))
|
||||
return {
|
||||
name: "replace_in_file",
|
||||
input: {
|
||||
absolutePath: filePath,
|
||||
diff: diff,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback
|
||||
return {
|
||||
name: "write_to_file",
|
||||
input: input,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract new content from add operation patch
|
||||
*/
|
||||
function extractNewContentFromPatch(patchContent: string): string {
|
||||
// For Add operations, the patch should contain lines starting with +
|
||||
const lines = patchContent.split("\n")
|
||||
const contentLines: string[] = []
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith("+")) {
|
||||
// Remove the + prefix and exactly ONE space if present (but not if it's a tab)
|
||||
let content = line.substring(1)
|
||||
if (content.startsWith(" ") && !content.startsWith("\t")) {
|
||||
content = content.substring(1)
|
||||
}
|
||||
contentLines.push(content)
|
||||
}
|
||||
}
|
||||
|
||||
return contentLines.join("\n")
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert V4A patch format to SEARCH/REPLACE format
|
||||
*/
|
||||
function convertPatchToDiff(patchContent: string): string {
|
||||
const diffBlocks: string[] = []
|
||||
const lines = patchContent.split("\n")
|
||||
|
||||
let i = 0
|
||||
while (i < lines.length) {
|
||||
const line = lines[i]
|
||||
|
||||
// Skip empty lines at the start
|
||||
if (!line.trim() && i === 0) {
|
||||
i++
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if this is the start of a hunk (@@) or a direct change line
|
||||
if (line.trim().startsWith("@@") || line.startsWith("-") || line.startsWith("+")) {
|
||||
const currentSearch: string[] = []
|
||||
const currentReplace: string[] = []
|
||||
|
||||
// Collect @@ context marker lines
|
||||
// @@ prefix marks context lines. If @@something, then "something" is context.
|
||||
// If just @@, then it's an empty context line.
|
||||
while (i < lines.length && lines[i].trim().startsWith("@@")) {
|
||||
const trimmedLine = lines[i].trim()
|
||||
// Extract the actual context content after @@
|
||||
const contextLine = trimmedLine.substring(2)
|
||||
// Always add the context line (even if empty)
|
||||
currentSearch.push(contextLine)
|
||||
currentReplace.push(contextLine)
|
||||
i++
|
||||
}
|
||||
|
||||
if (i >= lines.length) {
|
||||
break
|
||||
}
|
||||
|
||||
// Collect all remaining lines in this hunk until we hit end of content or next @@
|
||||
const hunkLines: string[] = []
|
||||
while (i < lines.length) {
|
||||
// Check if this is a new hunk (starts with @@)
|
||||
if (lines[i].trim().startsWith("@@")) {
|
||||
break
|
||||
}
|
||||
hunkLines.push(lines[i])
|
||||
i++
|
||||
}
|
||||
|
||||
// Now process the hunk to build SEARCH/REPLACE
|
||||
let hasChanges = false
|
||||
for (let j = 0; j < hunkLines.length; j++) {
|
||||
const hunkLine = hunkLines[j]
|
||||
|
||||
if (hunkLine.startsWith("-")) {
|
||||
hasChanges = true
|
||||
// Strip the - prefix and exactly ONE space if present (but not if it's a tab)
|
||||
let content = hunkLine.substring(1)
|
||||
if (content.startsWith(" ") && !content.startsWith(" \t")) {
|
||||
content = content.substring(1)
|
||||
}
|
||||
currentSearch.push(content)
|
||||
} else if (hunkLine.startsWith("+")) {
|
||||
hasChanges = true
|
||||
// Strip the + prefix and exactly ONE space if present (but not if it's a tab)
|
||||
let content = hunkLine.substring(1)
|
||||
if (content.startsWith(" ") && !content.startsWith(" \t")) {
|
||||
content = content.substring(1)
|
||||
}
|
||||
currentReplace.push(content)
|
||||
} else {
|
||||
// Context line without @@ prefix - add to both sides
|
||||
currentSearch.push(hunkLine)
|
||||
currentReplace.push(hunkLine)
|
||||
}
|
||||
}
|
||||
|
||||
// Create the diff block if we have changes
|
||||
if (hasChanges && (currentSearch.length > 0 || currentReplace.length > 0)) {
|
||||
diffBlocks.push(
|
||||
"------- SEARCH\n" +
|
||||
currentSearch.join("\n") +
|
||||
"\n=======\n" +
|
||||
currentReplace.join("\n") +
|
||||
"\n+++++++ REPLACE",
|
||||
)
|
||||
}
|
||||
} else {
|
||||
i++
|
||||
}
|
||||
}
|
||||
|
||||
return diffBlocks.join("\n")
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconstruct tool_result content to match apply_patch format by extracting
|
||||
* the final file content and converting it back to V4A patch format
|
||||
*/
|
||||
function reconstructApplyPatchResult(
|
||||
block: any,
|
||||
convertedToolName: string,
|
||||
_convertedInput: any,
|
||||
originalInput: any,
|
||||
): string | any[] {
|
||||
// Extract the content from the tool_result
|
||||
const content = typeof block.content === "string" ? block.content : ""
|
||||
|
||||
// Try to extract the final_file_content
|
||||
const finalContentMatch = content.match(/<final_file_content path="([^"]+)">\s*([\s\S]*?)\s*<\/final_file_content>/)
|
||||
|
||||
if (!finalContentMatch) {
|
||||
// If no final_file_content found, return original content
|
||||
return block.content
|
||||
}
|
||||
|
||||
const filePath = finalContentMatch[1]
|
||||
const finalContent = finalContentMatch[2]
|
||||
|
||||
// Reconstruct the result message based on the converted tool type
|
||||
if (convertedToolName === "write_to_file") {
|
||||
// For write_to_file, we just need to confirm the file was created/written
|
||||
return `[apply_patch for '${filePath}'] Result:\nThe content was successfully saved to ${filePath}.\n\nThe file has been created/updated with the new content.`
|
||||
}
|
||||
|
||||
if (convertedToolName === "replace_in_file") {
|
||||
// For replace_in_file, we need to reconstruct the V4A patch format result
|
||||
// Try to parse the original patch to get the action and build context
|
||||
const patchInput = typeof originalInput === "string" ? originalInput : originalInput?.input || ""
|
||||
const patchMatch = patchInput.match(APPLY_PATCH_PATCH_REGEX)
|
||||
|
||||
if (patchMatch) {
|
||||
const patchContent = patchMatch[1]
|
||||
const fileMatch = patchContent.match(/\*\*\* (Add|Update|Delete) File: (.+?)(?:\n|$)/m)
|
||||
|
||||
if (fileMatch) {
|
||||
const action = fileMatch[1]
|
||||
return `[apply_patch for '${filePath}'] Result:\nThe content was successfully updated in ${filePath}.\n\nThe file has been modified using ${action} operation.\n\n<final_file_content path="${filePath}">\n${finalContent}\n</final_file_content>\n\nIMPORTANT: For any future changes to this file, use the final_file_content shown above as your reference.`
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback for replace_in_file
|
||||
return `[apply_patch for '${filePath}'] Result:\nThe content was successfully updated in ${filePath}.\n\n<final_file_content path="${filePath}">\n${finalContent}\n</final_file_content>\n\nIMPORTANT: For any future changes to this file, use the final_file_content shown above as your reference.`
|
||||
}
|
||||
|
||||
// Default fallback
|
||||
return block.content
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert write_to_file and replace_in_file tool calls to apply_patch format
|
||||
*/
|
||||
export function convertWriteToFileToolCalls(messages: Array<ClineStorageMessage>): Array<ClineStorageMessage> {
|
||||
// Map to track tool_use_id to converted tool info and original input
|
||||
const toolUseIdMap = new Map<string, { originalName: string; originalInput: any; patchInput?: string }>()
|
||||
|
||||
// First pass: collect tool_use blocks
|
||||
for (const message of messages) {
|
||||
if (!Array.isArray(message.content)) {
|
||||
continue
|
||||
}
|
||||
for (const block of message.content) {
|
||||
if (block.type === "tool_use" && (block.name === "write_to_file" || block.name === "replace_in_file")) {
|
||||
toolUseIdMap.set(block.id, {
|
||||
originalName: block.name,
|
||||
originalInput: block.input,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Second pass: find tool_results and extract final content to build proper patches
|
||||
const finalContentMap = new Map<string, string>()
|
||||
for (const message of messages) {
|
||||
if (!Array.isArray(message.content)) {
|
||||
continue
|
||||
}
|
||||
for (const block of message.content) {
|
||||
if (block.type === "tool_result" && toolUseIdMap.has(block.tool_use_id)) {
|
||||
const content = typeof block.content === "string" ? block.content : ""
|
||||
const finalContentMatch = content.match(
|
||||
/<final_file_content path="([^"]+)">\s*([\s\S]*?)\s*<\/final_file_content>/,
|
||||
)
|
||||
if (finalContentMatch) {
|
||||
finalContentMap.set(block.tool_use_id, finalContentMatch[2])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Third pass: convert messages
|
||||
return messages.map((message) => {
|
||||
if (!Array.isArray(message.content)) {
|
||||
return message
|
||||
}
|
||||
|
||||
const convertedContent = message.content.map((block) => {
|
||||
// Handle tool_use blocks for write_to_file and replace_in_file
|
||||
if (block.type === "tool_use" && (block.name === "write_to_file" || block.name === "replace_in_file")) {
|
||||
const finalContent = finalContentMap.get(block.id)
|
||||
const patchInput = convertToPatchFormat(block.name, block.input, finalContent)
|
||||
|
||||
// Update the map with the generated patch
|
||||
const existingEntry = toolUseIdMap.get(block.id)
|
||||
if (existingEntry) {
|
||||
existingEntry.patchInput = patchInput
|
||||
}
|
||||
|
||||
return {
|
||||
...block,
|
||||
name: "apply_patch",
|
||||
input: {
|
||||
input: patchInput,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Handle tool_result blocks
|
||||
if (block.type === "tool_result") {
|
||||
const conversion = toolUseIdMap.get(block.tool_use_id)
|
||||
if (conversion) {
|
||||
// Reconstruct the tool_result content to match apply_patch format
|
||||
const reconstructedContent = reconstructWriteToFileResult(
|
||||
block,
|
||||
conversion.originalName,
|
||||
conversion.originalInput,
|
||||
)
|
||||
return {
|
||||
...block,
|
||||
content: reconstructedContent,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return block
|
||||
})
|
||||
|
||||
return {
|
||||
...message,
|
||||
content: convertedContent,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert write_to_file or replace_in_file input to apply_patch format
|
||||
*/
|
||||
function convertToPatchFormat(toolName: string, input: any, finalContent?: string): string {
|
||||
const filePath = input.absolutePath || input.path || ""
|
||||
|
||||
if (toolName === "write_to_file") {
|
||||
// Convert write_to_file to Add operation
|
||||
const content = input.content || ""
|
||||
const lines = content.split("\n")
|
||||
const patchLines = ["@@"]
|
||||
patchLines.push(...lines.map((line: string) => `+ ${line}`))
|
||||
|
||||
return `apply_patch <<"EOF"
|
||||
*** Begin Patch
|
||||
*** Add File: ${filePath}
|
||||
${patchLines.join("\n")}
|
||||
*** End Patch
|
||||
EOF`
|
||||
}
|
||||
|
||||
if (toolName === "replace_in_file") {
|
||||
// Convert replace_in_file to Update operation
|
||||
const diff = input.diff || ""
|
||||
|
||||
// Parse SEARCH/REPLACE blocks and convert to V4A format with context
|
||||
const patchContent = convertDiffToPatchWithContext(diff, finalContent)
|
||||
|
||||
return `apply_patch <<"EOF"
|
||||
*** Begin Patch
|
||||
*** Update File: ${filePath}
|
||||
${patchContent}
|
||||
*** End Patch
|
||||
EOF`
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert SEARCH/REPLACE diff format to V4A patch format with additional context from final content
|
||||
*/
|
||||
function convertDiffToPatchWithContext(diff: string, finalContent?: string): string {
|
||||
const patchLines: string[] = []
|
||||
|
||||
// Match all SEARCH/REPLACE blocks
|
||||
const blockRegex = /------- SEARCH\s*\n([\s\S]*?)\n=======\s*\n([\s\S]*?)\n\+{7} REPLACE/g
|
||||
let match
|
||||
|
||||
while ((match = blockRegex.exec(diff)) !== null) {
|
||||
const searchContent = match[1]
|
||||
const replaceContent = match[2]
|
||||
|
||||
const searchLines = searchContent.split("\n")
|
||||
const replaceLines = replaceContent.split("\n")
|
||||
|
||||
// Find common prefix and suffix between search and replace
|
||||
let prefixEnd = 0
|
||||
while (
|
||||
prefixEnd < searchLines.length &&
|
||||
prefixEnd < replaceLines.length &&
|
||||
searchLines[prefixEnd] === replaceLines[prefixEnd]
|
||||
) {
|
||||
prefixEnd++
|
||||
}
|
||||
|
||||
let suffixStart = searchLines.length
|
||||
let replaceSuffixStart = replaceLines.length
|
||||
while (
|
||||
suffixStart > prefixEnd &&
|
||||
replaceSuffixStart > prefixEnd &&
|
||||
searchLines[suffixStart - 1] === replaceLines[replaceSuffixStart - 1]
|
||||
) {
|
||||
suffixStart--
|
||||
replaceSuffixStart--
|
||||
}
|
||||
|
||||
// If we have finalContent, extract additional context from it
|
||||
if (finalContent) {
|
||||
const finalLines = finalContent.split("\n")
|
||||
|
||||
// Find where the replaced content appears in the final file
|
||||
let matchIndex = -1
|
||||
for (let i = 0; i < finalLines.length; i++) {
|
||||
// Try to match the first replace line
|
||||
if (replaceLines.length > 0 && finalLines[i] === replaceLines[0]) {
|
||||
// Check if subsequent lines also match
|
||||
let allMatch = true
|
||||
for (let j = 1; j < replaceLines.length && i + j < finalLines.length; j++) {
|
||||
if (finalLines[i + j] !== replaceLines[j]) {
|
||||
allMatch = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if (allMatch) {
|
||||
matchIndex = i
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (matchIndex >= 0) {
|
||||
// Extract up to 3 lines before as context
|
||||
const contextStart = Math.max(0, matchIndex - 3)
|
||||
const contextLines: string[] = []
|
||||
for (let i = contextStart; i < matchIndex; i++) {
|
||||
contextLines.push(finalLines[i])
|
||||
}
|
||||
|
||||
// Pad to 3 lines if needed (with empty strings)
|
||||
while (contextLines.length < 3) {
|
||||
contextLines.unshift("")
|
||||
}
|
||||
|
||||
// Add @@ marker with the first context line
|
||||
if (contextLines[0] === "") {
|
||||
patchLines.push("@@")
|
||||
} else {
|
||||
patchLines.push(`@@${contextLines[0]}`)
|
||||
}
|
||||
|
||||
// Add remaining context lines (without @@ marker)
|
||||
for (let i = 1; i < contextLines.length; i++) {
|
||||
patchLines.push(contextLines[i])
|
||||
}
|
||||
|
||||
// Add common prefix lines (without +/- markers)
|
||||
for (let i = 0; i < prefixEnd; i++) {
|
||||
patchLines.push(searchLines[i])
|
||||
}
|
||||
|
||||
// Add the actual changes (lines that differ)
|
||||
for (let i = prefixEnd; i < suffixStart; i++) {
|
||||
patchLines.push(`- ${searchLines[i]}`)
|
||||
}
|
||||
for (let i = prefixEnd; i < replaceSuffixStart; i++) {
|
||||
patchLines.push(`+ ${replaceLines[i]}`)
|
||||
}
|
||||
|
||||
// Add common suffix lines (without +/- markers)
|
||||
for (let i = suffixStart; i < searchLines.length; i++) {
|
||||
patchLines.push(searchLines[i])
|
||||
}
|
||||
|
||||
// Extract up to 3 lines after as trailing context (without @@ markers)
|
||||
const contextEnd = Math.min(finalLines.length, matchIndex + replaceLines.length + 3)
|
||||
for (let i = matchIndex + replaceLines.length; i < contextEnd; i++) {
|
||||
patchLines.push(finalLines[i])
|
||||
}
|
||||
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: if no finalContent or couldn't find match, use the prefix/suffix from SEARCH/REPLACE
|
||||
patchLines.push("@@")
|
||||
|
||||
// Add common prefix lines (without +/- markers)
|
||||
for (let i = 0; i < prefixEnd; i++) {
|
||||
patchLines.push(searchLines[i])
|
||||
}
|
||||
|
||||
// Add the actual changes (lines that differ)
|
||||
for (let i = prefixEnd; i < suffixStart; i++) {
|
||||
patchLines.push(`- ${searchLines[i]}`)
|
||||
}
|
||||
for (let i = prefixEnd; i < replaceSuffixStart; i++) {
|
||||
patchLines.push(`+ ${replaceLines[i]}`)
|
||||
}
|
||||
|
||||
// Add common suffix lines (without +/- markers)
|
||||
for (let i = suffixStart; i < searchLines.length; i++) {
|
||||
patchLines.push(searchLines[i])
|
||||
}
|
||||
}
|
||||
|
||||
return patchLines.join("\n")
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconstruct tool_result content to match apply_patch result format
|
||||
*/
|
||||
function reconstructWriteToFileResult(block: any, originalToolName: string, originalInput: any): string | any[] {
|
||||
// Extract the content from the tool_result
|
||||
const content = typeof block.content === "string" ? block.content : ""
|
||||
|
||||
// Try to extract the final_file_content
|
||||
const finalContentMatch = content.match(/<final_file_content path="([^"]+)">\s*([\s\S]*?)\s*<\/final_file_content>/)
|
||||
|
||||
const filePath = originalInput.absolutePath || originalInput.path || ""
|
||||
|
||||
if (!finalContentMatch) {
|
||||
// If no final_file_content found, create a simple success message
|
||||
if (originalToolName === "write_to_file") {
|
||||
return `[apply_patch for '${filePath}'] Result:\nThe content was successfully saved to ${filePath}.\n\nThe file has been created/updated with the new content.`
|
||||
} else {
|
||||
return `[apply_patch for '${filePath}'] Result:\nThe content was successfully updated in ${filePath}.\n\nThe file has been modified.`
|
||||
}
|
||||
}
|
||||
|
||||
const finalContent = finalContentMatch[2]
|
||||
|
||||
// Reconstruct the result message based on the original tool type
|
||||
if (originalToolName === "write_to_file") {
|
||||
return `[apply_patch for '${filePath}'] Result:\nThe content was successfully saved to ${filePath}.\n\nThe file has been created/updated with the new content.`
|
||||
}
|
||||
|
||||
if (originalToolName === "replace_in_file") {
|
||||
return `[apply_patch for '${filePath}'] Result:\nThe content was successfully updated in ${filePath}.\n\nThe file has been modified using Update operation.\n\n<final_file_content path="${filePath}">\n${finalContent}\n</final_file_content>\n\nIMPORTANT: For any future changes to this file, use the final_file_content shown above as your reference.`
|
||||
}
|
||||
|
||||
// Default fallback
|
||||
return block.content
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
import { ClineStorageMessage } from "@/shared/messages/content"
|
||||
import { ClineDefaultTool } from "@/shared/tools"
|
||||
import { convertApplyPatchToolCalls, convertWriteToFileToolCalls } from "./diff-editors"
|
||||
|
||||
/**
|
||||
* Transforms tool call messages between different tool formats based on native tool support.
|
||||
* Converts between apply_patch and write_to_file/replace_in_file formats as needed.
|
||||
*
|
||||
* @param clineMessages - Array of messages containing tool calls to transform
|
||||
* @param nativeTools - Array of tools natively supported by the current provider
|
||||
* @returns Transformed messages array, or original if no transformation needed
|
||||
*/
|
||||
export function transformToolCallMessages(
|
||||
clineMessages: ClineStorageMessage[],
|
||||
nativeTools?: ClineDefaultTool[],
|
||||
): ClineStorageMessage[] {
|
||||
// Early return if no messages or native tools provided
|
||||
if (!clineMessages?.length || !nativeTools?.length) {
|
||||
return clineMessages
|
||||
}
|
||||
|
||||
// Create Sets for O(1) lookup performance
|
||||
const nativeToolSet = new Set(nativeTools)
|
||||
const usedToolSet = new Set<string>()
|
||||
|
||||
// Single pass: collect all tools used in assistant messages
|
||||
for (const msg of clineMessages) {
|
||||
if (msg.role === "assistant" && Array.isArray(msg.content)) {
|
||||
for (const block of msg.content) {
|
||||
if (block.type === "tool_use" && block.name) {
|
||||
usedToolSet.add(block.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Early return if no tools were used
|
||||
if (usedToolSet.size === 0) {
|
||||
return clineMessages
|
||||
}
|
||||
|
||||
// Determine which conversion to apply
|
||||
const hasApplyPatchNative = nativeToolSet.has(ClineDefaultTool.APPLY_PATCH)
|
||||
const hasFileEditNative = nativeToolSet.has(ClineDefaultTool.FILE_EDIT) || nativeToolSet.has(ClineDefaultTool.FILE_NEW)
|
||||
|
||||
const hasApplyPatchUsed = usedToolSet.has(ClineDefaultTool.APPLY_PATCH)
|
||||
const hasFileEditUsed = usedToolSet.has(ClineDefaultTool.FILE_EDIT) || usedToolSet.has(ClineDefaultTool.FILE_NEW)
|
||||
|
||||
// Convert write_to_file/replace_in_file → apply_patch
|
||||
if (hasApplyPatchNative && hasFileEditUsed) {
|
||||
return convertWriteToFileToolCalls(clineMessages)
|
||||
}
|
||||
|
||||
// Convert apply_patch → write_to_file/replace_in_file
|
||||
if (hasFileEditNative && hasApplyPatchUsed) {
|
||||
return convertApplyPatchToolCalls(clineMessages)
|
||||
}
|
||||
|
||||
return clineMessages
|
||||
}
|
||||
@@ -1,65 +1,18 @@
|
||||
import { ApiConfiguration, ModelInfo, QwenApiRegions } from "@shared/api"
|
||||
import { ModelInfo } from "@shared/api"
|
||||
import { Mode } from "@shared/storage/types"
|
||||
import { ClineStorageMessage } from "@/shared/messages/content"
|
||||
import { Logger } from "@/shared/services/Logger"
|
||||
import { ClineTool } from "@/shared/tools"
|
||||
import { AIhubmixHandler } from "./providers/aihubmix"
|
||||
import { AnthropicHandler } from "./providers/anthropic"
|
||||
import { AskSageHandler } from "./providers/asksage"
|
||||
import { BasetenHandler } from "./providers/baseten"
|
||||
import { AwsBedrockHandler } from "./providers/bedrock"
|
||||
import { CerebrasHandler } from "./providers/cerebras"
|
||||
import { ClaudeCodeHandler } from "./providers/claude-code"
|
||||
import { ClineHandler } from "./providers/cline"
|
||||
import { DeepSeekHandler } from "./providers/deepseek"
|
||||
import { DifyHandler } from "./providers/dify"
|
||||
import { DoubaoHandler } from "./providers/doubao"
|
||||
import { FireworksHandler } from "./providers/fireworks"
|
||||
import { GeminiHandler } from "./providers/gemini"
|
||||
import { GroqHandler } from "./providers/groq"
|
||||
import { HicapHandler } from "./providers/hicap"
|
||||
import { HuaweiCloudMaaSHandler } from "./providers/huawei-cloud-maas"
|
||||
import { HuggingFaceHandler } from "./providers/huggingface"
|
||||
import { LiteLlmHandler } from "./providers/litellm"
|
||||
import { LmStudioHandler } from "./providers/lmstudio"
|
||||
import { MinimaxHandler } from "./providers/minimax"
|
||||
import { MistralHandler } from "./providers/mistral"
|
||||
import { MoonshotHandler } from "./providers/moonshot"
|
||||
import { NebiusHandler } from "./providers/nebius"
|
||||
import { NousResearchHandler } from "./providers/nousresearch"
|
||||
import { OcaHandler } from "./providers/oca"
|
||||
import { OllamaHandler } from "./providers/ollama"
|
||||
import { OpenAiHandler } from "./providers/openai"
|
||||
import { OpenAiCodexHandler } from "./providers/openai-codex"
|
||||
import { OpenAiNativeHandler } from "./providers/openai-native"
|
||||
import { OpenRouterHandler } from "./providers/openrouter"
|
||||
import { QwenHandler } from "./providers/qwen"
|
||||
import { QwenCodeHandler } from "./providers/qwen-code"
|
||||
import { RequestyHandler } from "./providers/requesty"
|
||||
import { SambanovaHandler } from "./providers/sambanova"
|
||||
import { SapAiCoreHandler } from "./providers/sapaicore"
|
||||
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"
|
||||
|
||||
export type CommonApiHandlerOptions = {
|
||||
onRetryAttempt?: ApiConfiguration["onRetryAttempt"]
|
||||
}
|
||||
export interface ApiHandler {
|
||||
createMessage(systemPrompt: string, messages: ClineStorageMessage[], tools?: ClineTool[], useResponseApi?: boolean): ApiStream
|
||||
getModel(): ApiHandlerModel
|
||||
getApiStreamUsage?(): Promise<ApiStreamUsageChunk | undefined>
|
||||
abort?(): void
|
||||
}
|
||||
// buildApiHandler now routes inference through the Cline SDK. It lives in
|
||||
// apps/vscode/src/sdk/sdk-api-handler.ts and callers import it directly from
|
||||
// there. It is deliberately NOT re-exported here: this barrel is imported
|
||||
// widely for *types* only, and re-exporting a value from the SDK module would
|
||||
// pull the entire SDK/session-factory runtime graph into every type importer
|
||||
// at module-eval time (which can break extension activation). Keep this file
|
||||
// types-only.
|
||||
|
||||
export interface ApiHandlerModel {
|
||||
id: string
|
||||
info: ModelInfo
|
||||
providerId?: string
|
||||
}
|
||||
|
||||
export interface ApiProviderInfo {
|
||||
@@ -68,440 +21,3 @@ export interface ApiProviderInfo {
|
||||
mode: Mode
|
||||
customPrompt?: string // "compact"
|
||||
}
|
||||
|
||||
export interface SingleCompletionHandler {
|
||||
completePrompt(prompt: string): Promise<string>
|
||||
}
|
||||
|
||||
function createHandlerForProvider(
|
||||
apiProvider: string | undefined,
|
||||
options: Omit<ApiConfiguration, "apiProvider">,
|
||||
mode: Mode,
|
||||
): ApiHandler {
|
||||
switch (apiProvider) {
|
||||
case "anthropic":
|
||||
return new AnthropicHandler({
|
||||
onRetryAttempt: options.onRetryAttempt,
|
||||
apiKey: options.apiKey,
|
||||
anthropicBaseUrl: options.anthropicBaseUrl,
|
||||
apiModelId: mode === "plan" ? options.planModeApiModelId : options.actModeApiModelId,
|
||||
reasoningEffort: mode === "plan" ? options.planModeReasoningEffort : options.actModeReasoningEffort,
|
||||
thinkingBudgetTokens:
|
||||
mode === "plan" ? options.planModeThinkingBudgetTokens : options.actModeThinkingBudgetTokens,
|
||||
})
|
||||
case "openrouter":
|
||||
return new OpenRouterHandler({
|
||||
onRetryAttempt: options.onRetryAttempt,
|
||||
openRouterApiKey: options.openRouterApiKey,
|
||||
openRouterModelId: mode === "plan" ? options.planModeOpenRouterModelId : options.actModeOpenRouterModelId,
|
||||
openRouterModelInfo: mode === "plan" ? options.planModeOpenRouterModelInfo : options.actModeOpenRouterModelInfo,
|
||||
openRouterProviderSorting: options.openRouterProviderSorting,
|
||||
reasoningEffort: mode === "plan" ? options.planModeReasoningEffort : options.actModeReasoningEffort,
|
||||
thinkingBudgetTokens:
|
||||
mode === "plan" ? options.planModeThinkingBudgetTokens : options.actModeThinkingBudgetTokens,
|
||||
enableParallelToolCalling: options.enableParallelToolCalling,
|
||||
})
|
||||
case "bedrock":
|
||||
return new AwsBedrockHandler({
|
||||
onRetryAttempt: options.onRetryAttempt,
|
||||
apiModelId: mode === "plan" ? options.planModeApiModelId : options.actModeApiModelId,
|
||||
awsAccessKey: options.awsAccessKey,
|
||||
awsSecretKey: options.awsSecretKey,
|
||||
awsSessionToken: options.awsSessionToken,
|
||||
awsRegion: options.awsRegion,
|
||||
awsAuthentication: options.awsAuthentication,
|
||||
awsBedrockApiKey: options.awsBedrockApiKey,
|
||||
awsUseCrossRegionInference: options.awsUseCrossRegionInference,
|
||||
awsUseGlobalInference: options.awsUseGlobalInference,
|
||||
awsBedrockUsePromptCache: options.awsBedrockUsePromptCache,
|
||||
awsUseProfile: options.awsUseProfile,
|
||||
awsProfile: options.awsProfile,
|
||||
awsBedrockEndpoint: options.awsBedrockEndpoint,
|
||||
awsBedrockCustomSelected:
|
||||
mode === "plan" ? options.planModeAwsBedrockCustomSelected : options.actModeAwsBedrockCustomSelected,
|
||||
awsBedrockCustomModelBaseId:
|
||||
mode === "plan" ? options.planModeAwsBedrockCustomModelBaseId : options.actModeAwsBedrockCustomModelBaseId,
|
||||
reasoningEffort: mode === "plan" ? options.planModeReasoningEffort : options.actModeReasoningEffort,
|
||||
thinkingBudgetTokens:
|
||||
mode === "plan" ? options.planModeThinkingBudgetTokens : options.actModeThinkingBudgetTokens,
|
||||
})
|
||||
case "vertex":
|
||||
return new VertexHandler({
|
||||
onRetryAttempt: options.onRetryAttempt,
|
||||
vertexProjectId: options.vertexProjectId,
|
||||
vertexRegion: options.vertexRegion,
|
||||
apiModelId: mode === "plan" ? options.planModeApiModelId : options.actModeApiModelId,
|
||||
thinkingBudgetTokens:
|
||||
mode === "plan" ? options.planModeThinkingBudgetTokens : options.actModeThinkingBudgetTokens,
|
||||
geminiApiKey: options.geminiApiKey,
|
||||
geminiBaseUrl: options.geminiBaseUrl,
|
||||
reasoningEffort: mode === "plan" ? options.planModeReasoningEffort : options.actModeReasoningEffort,
|
||||
ulid: options.ulid,
|
||||
})
|
||||
case "openai":
|
||||
return new OpenAiHandler({
|
||||
onRetryAttempt: options.onRetryAttempt,
|
||||
openAiApiKey: options.openAiApiKey,
|
||||
openAiBaseUrl: options.openAiBaseUrl,
|
||||
azureApiVersion: options.azureApiVersion,
|
||||
azureIdentity: options.azureIdentity,
|
||||
openAiHeaders: options.openAiHeaders,
|
||||
openAiModelId: mode === "plan" ? options.planModeOpenAiModelId : options.actModeOpenAiModelId,
|
||||
openAiModelInfo: mode === "plan" ? options.planModeOpenAiModelInfo : options.actModeOpenAiModelInfo,
|
||||
reasoningEffort: mode === "plan" ? options.planModeReasoningEffort : options.actModeReasoningEffort,
|
||||
})
|
||||
case "ollama":
|
||||
return new OllamaHandler({
|
||||
onRetryAttempt: options.onRetryAttempt,
|
||||
ollamaBaseUrl: options.ollamaBaseUrl,
|
||||
ollamaApiKey: options.ollamaApiKey,
|
||||
ollamaModelId: mode === "plan" ? options.planModeOllamaModelId : options.actModeOllamaModelId,
|
||||
ollamaApiOptionsCtxNum: options.ollamaApiOptionsCtxNum,
|
||||
requestTimeoutMs: options.requestTimeoutMs,
|
||||
})
|
||||
case "lmstudio":
|
||||
return new LmStudioHandler({
|
||||
onRetryAttempt: options.onRetryAttempt,
|
||||
lmStudioBaseUrl: options.lmStudioBaseUrl,
|
||||
lmStudioModelId: mode === "plan" ? options.planModeLmStudioModelId : options.actModeLmStudioModelId,
|
||||
lmStudioMaxTokens: options.lmStudioMaxTokens,
|
||||
})
|
||||
case "gemini":
|
||||
return new GeminiHandler({
|
||||
onRetryAttempt: options.onRetryAttempt,
|
||||
vertexProjectId: options.vertexProjectId,
|
||||
vertexRegion: options.vertexRegion,
|
||||
geminiApiKey: options.geminiApiKey,
|
||||
geminiBaseUrl: options.geminiBaseUrl,
|
||||
thinkingBudgetTokens:
|
||||
mode === "plan" ? options.planModeThinkingBudgetTokens : options.actModeThinkingBudgetTokens,
|
||||
reasoningEffort: mode === "plan" ? options.planModeReasoningEffort : options.actModeReasoningEffort,
|
||||
apiModelId: mode === "plan" ? options.planModeApiModelId : options.actModeApiModelId,
|
||||
ulid: options.ulid,
|
||||
})
|
||||
case "openai-native":
|
||||
return new OpenAiNativeHandler({
|
||||
onRetryAttempt: options.onRetryAttempt,
|
||||
openAiNativeApiKey: options.openAiNativeApiKey,
|
||||
reasoningEffort: mode === "plan" ? options.planModeReasoningEffort : options.actModeReasoningEffort,
|
||||
apiModelId: mode === "plan" ? options.planModeApiModelId : options.actModeApiModelId,
|
||||
thinkingBudgetTokens:
|
||||
mode === "plan" ? options.planModeThinkingBudgetTokens : options.actModeThinkingBudgetTokens,
|
||||
})
|
||||
case "openai-codex":
|
||||
return new OpenAiCodexHandler({
|
||||
onRetryAttempt: options.onRetryAttempt,
|
||||
reasoningEffort: mode === "plan" ? options.planModeReasoningEffort : options.actModeReasoningEffort,
|
||||
apiModelId: mode === "plan" ? options.planModeApiModelId : options.actModeApiModelId,
|
||||
})
|
||||
case "deepseek":
|
||||
return new DeepSeekHandler({
|
||||
onRetryAttempt: options.onRetryAttempt,
|
||||
deepSeekApiKey: options.deepSeekApiKey,
|
||||
apiModelId: mode === "plan" ? options.planModeApiModelId : options.actModeApiModelId,
|
||||
})
|
||||
case "requesty":
|
||||
return new RequestyHandler({
|
||||
onRetryAttempt: options.onRetryAttempt,
|
||||
requestyBaseUrl: options.requestyBaseUrl,
|
||||
requestyApiKey: options.requestyApiKey,
|
||||
reasoningEffort: mode === "plan" ? options.planModeReasoningEffort : options.actModeReasoningEffort,
|
||||
thinkingBudgetTokens:
|
||||
mode === "plan" ? options.planModeThinkingBudgetTokens : options.actModeThinkingBudgetTokens,
|
||||
requestyModelId: mode === "plan" ? options.planModeRequestyModelId : options.actModeRequestyModelId,
|
||||
requestyModelInfo: mode === "plan" ? options.planModeRequestyModelInfo : options.actModeRequestyModelInfo,
|
||||
})
|
||||
case "fireworks":
|
||||
return new FireworksHandler({
|
||||
onRetryAttempt: options.onRetryAttempt,
|
||||
fireworksApiKey: options.fireworksApiKey,
|
||||
fireworksModelId: mode === "plan" ? options.planModeFireworksModelId : options.actModeFireworksModelId,
|
||||
})
|
||||
case "together":
|
||||
return new TogetherHandler({
|
||||
onRetryAttempt: options.onRetryAttempt,
|
||||
togetherApiKey: options.togetherApiKey,
|
||||
togetherModelId: mode === "plan" ? options.planModeTogetherModelId : options.actModeTogetherModelId,
|
||||
})
|
||||
case "qwen":
|
||||
return new QwenHandler({
|
||||
onRetryAttempt: options.onRetryAttempt,
|
||||
qwenApiKey: options.qwenApiKey,
|
||||
qwenApiLine:
|
||||
options.qwenApiLine === QwenApiRegions.INTERNATIONAL ? QwenApiRegions.INTERNATIONAL : QwenApiRegions.CHINA,
|
||||
apiModelId: mode === "plan" ? options.planModeApiModelId : options.actModeApiModelId,
|
||||
thinkingBudgetTokens:
|
||||
mode === "plan" ? options.planModeThinkingBudgetTokens : options.actModeThinkingBudgetTokens,
|
||||
})
|
||||
case "qwen-code":
|
||||
return new QwenCodeHandler({
|
||||
onRetryAttempt: options.onRetryAttempt,
|
||||
qwenCodeOauthPath: options.qwenCodeOauthPath,
|
||||
apiModelId: mode === "plan" ? options.planModeApiModelId : options.actModeApiModelId,
|
||||
})
|
||||
case "doubao":
|
||||
return new DoubaoHandler({
|
||||
onRetryAttempt: options.onRetryAttempt,
|
||||
doubaoApiKey: options.doubaoApiKey,
|
||||
apiModelId: mode === "plan" ? options.planModeApiModelId : options.actModeApiModelId,
|
||||
})
|
||||
case "mistral":
|
||||
return new MistralHandler({
|
||||
onRetryAttempt: options.onRetryAttempt,
|
||||
mistralApiKey: options.mistralApiKey,
|
||||
apiModelId: mode === "plan" ? options.planModeApiModelId : options.actModeApiModelId,
|
||||
})
|
||||
case "vscode-lm":
|
||||
return new VsCodeLmHandler({
|
||||
onRetryAttempt: options.onRetryAttempt,
|
||||
vsCodeLmModelSelector:
|
||||
mode === "plan" ? options.planModeVsCodeLmModelSelector : options.actModeVsCodeLmModelSelector,
|
||||
})
|
||||
case "cline": {
|
||||
const clineModelId =
|
||||
(mode === "plan" ? options.planModeClineModelId : options.actModeClineModelId) ||
|
||||
(mode === "plan" ? options.planModeOpenRouterModelId : options.actModeOpenRouterModelId)
|
||||
const clineModelInfo =
|
||||
(mode === "plan" ? options.planModeClineModelInfo : options.actModeClineModelInfo) ||
|
||||
(mode === "plan" ? options.planModeOpenRouterModelInfo : options.actModeOpenRouterModelInfo)
|
||||
return new ClineHandler({
|
||||
onRetryAttempt: options.onRetryAttempt,
|
||||
clineAccountId: options.clineAccountId,
|
||||
clineApiKey: options.clineApiKey,
|
||||
ulid: options.ulid,
|
||||
reasoningEffort: mode === "plan" ? options.planModeReasoningEffort : options.actModeReasoningEffort,
|
||||
thinkingBudgetTokens:
|
||||
mode === "plan" ? options.planModeThinkingBudgetTokens : options.actModeThinkingBudgetTokens,
|
||||
openRouterProviderSorting: options.openRouterProviderSorting,
|
||||
openRouterModelId: clineModelId,
|
||||
openRouterModelInfo: clineModelInfo,
|
||||
enableParallelToolCalling: options.enableParallelToolCalling,
|
||||
})
|
||||
}
|
||||
case "litellm":
|
||||
return new LiteLlmHandler({
|
||||
onRetryAttempt: options.onRetryAttempt,
|
||||
liteLlmApiKey: options.liteLlmApiKey,
|
||||
liteLlmBaseUrl: options.liteLlmBaseUrl,
|
||||
liteLlmModelId: mode === "plan" ? options.planModeLiteLlmModelId : options.actModeLiteLlmModelId,
|
||||
liteLlmModelInfo: mode === "plan" ? options.planModeLiteLlmModelInfo : options.actModeLiteLlmModelInfo,
|
||||
reasoningEffort: mode === "plan" ? options.planModeReasoningEffort : options.actModeReasoningEffort,
|
||||
thinkingBudgetTokens:
|
||||
mode === "plan" ? options.planModeThinkingBudgetTokens : options.actModeThinkingBudgetTokens,
|
||||
liteLlmUsePromptCache: options.liteLlmUsePromptCache,
|
||||
ulid: options.ulid,
|
||||
})
|
||||
case "moonshot":
|
||||
return new MoonshotHandler({
|
||||
onRetryAttempt: options.onRetryAttempt,
|
||||
moonshotApiKey: options.moonshotApiKey,
|
||||
moonshotApiLine: options.moonshotApiLine,
|
||||
apiModelId: mode === "plan" ? options.planModeApiModelId : options.actModeApiModelId,
|
||||
})
|
||||
case "huggingface":
|
||||
return new HuggingFaceHandler({
|
||||
onRetryAttempt: options.onRetryAttempt,
|
||||
huggingFaceApiKey: options.huggingFaceApiKey,
|
||||
huggingFaceModelId: mode === "plan" ? options.planModeHuggingFaceModelId : options.actModeHuggingFaceModelId,
|
||||
huggingFaceModelInfo:
|
||||
mode === "plan" ? options.planModeHuggingFaceModelInfo : options.actModeHuggingFaceModelInfo,
|
||||
})
|
||||
case "nebius":
|
||||
return new NebiusHandler({
|
||||
onRetryAttempt: options.onRetryAttempt,
|
||||
nebiusApiKey: options.nebiusApiKey,
|
||||
apiModelId: mode === "plan" ? options.planModeApiModelId : options.actModeApiModelId,
|
||||
})
|
||||
case "asksage":
|
||||
return new AskSageHandler({
|
||||
onRetryAttempt: options.onRetryAttempt,
|
||||
asksageApiKey: options.asksageApiKey,
|
||||
asksageApiUrl: options.asksageApiUrl,
|
||||
apiModelId: mode === "plan" ? options.planModeApiModelId : options.actModeApiModelId,
|
||||
})
|
||||
case "xai":
|
||||
return new XAIHandler({
|
||||
onRetryAttempt: options.onRetryAttempt,
|
||||
xaiApiKey: options.xaiApiKey,
|
||||
reasoningEffort: mode === "plan" ? options.planModeReasoningEffort : options.actModeReasoningEffort,
|
||||
apiModelId: mode === "plan" ? options.planModeApiModelId : options.actModeApiModelId,
|
||||
})
|
||||
case "sambanova":
|
||||
return new SambanovaHandler({
|
||||
onRetryAttempt: options.onRetryAttempt,
|
||||
sambanovaApiKey: options.sambanovaApiKey,
|
||||
apiModelId: mode === "plan" ? options.planModeApiModelId : options.actModeApiModelId,
|
||||
})
|
||||
case "cerebras":
|
||||
return new CerebrasHandler({
|
||||
onRetryAttempt: options.onRetryAttempt,
|
||||
cerebrasApiKey: options.cerebrasApiKey,
|
||||
apiModelId: mode === "plan" ? options.planModeApiModelId : options.actModeApiModelId,
|
||||
})
|
||||
case "groq":
|
||||
return new GroqHandler({
|
||||
onRetryAttempt: options.onRetryAttempt,
|
||||
groqApiKey: options.groqApiKey,
|
||||
groqModelId: mode === "plan" ? options.planModeGroqModelId : options.actModeGroqModelId,
|
||||
groqModelInfo: mode === "plan" ? options.planModeGroqModelInfo : options.actModeGroqModelInfo,
|
||||
apiModelId: mode === "plan" ? options.planModeApiModelId : options.actModeApiModelId,
|
||||
})
|
||||
case "baseten":
|
||||
return new BasetenHandler({
|
||||
onRetryAttempt: options.onRetryAttempt,
|
||||
basetenApiKey: options.basetenApiKey,
|
||||
basetenModelId: mode === "plan" ? options.planModeBasetenModelId : options.actModeBasetenModelId,
|
||||
basetenModelInfo: mode === "plan" ? options.planModeBasetenModelInfo : options.actModeBasetenModelInfo,
|
||||
apiModelId: mode === "plan" ? options.planModeApiModelId : options.actModeApiModelId,
|
||||
})
|
||||
case "sapaicore":
|
||||
return new SapAiCoreHandler({
|
||||
onRetryAttempt: options.onRetryAttempt,
|
||||
sapAiCoreClientId: options.sapAiCoreClientId,
|
||||
sapAiCoreClientSecret: options.sapAiCoreClientSecret,
|
||||
sapAiCoreTokenUrl: options.sapAiCoreTokenUrl,
|
||||
sapAiResourceGroup: options.sapAiResourceGroup,
|
||||
sapAiCoreBaseUrl: options.sapAiCoreBaseUrl,
|
||||
apiModelId: mode === "plan" ? options.planModeApiModelId : options.actModeApiModelId,
|
||||
thinkingBudgetTokens:
|
||||
mode === "plan" ? options.planModeThinkingBudgetTokens : options.actModeThinkingBudgetTokens,
|
||||
reasoningEffort: mode === "plan" ? options.planModeReasoningEffort : options.actModeReasoningEffort,
|
||||
deploymentId: mode === "plan" ? options.planModeSapAiCoreDeploymentId : options.actModeSapAiCoreDeploymentId,
|
||||
sapAiCoreUseOrchestrationMode: options.sapAiCoreUseOrchestrationMode,
|
||||
})
|
||||
case "claude-code":
|
||||
return new ClaudeCodeHandler({
|
||||
onRetryAttempt: options.onRetryAttempt,
|
||||
claudeCodePath: options.claudeCodePath,
|
||||
apiModelId: mode === "plan" ? options.planModeApiModelId : options.actModeApiModelId,
|
||||
thinkingBudgetTokens:
|
||||
mode === "plan" ? options.planModeThinkingBudgetTokens : options.actModeThinkingBudgetTokens,
|
||||
})
|
||||
case "huawei-cloud-maas":
|
||||
return new HuaweiCloudMaaSHandler({
|
||||
onRetryAttempt: options.onRetryAttempt,
|
||||
huaweiCloudMaasApiKey: options.huaweiCloudMaasApiKey,
|
||||
huaweiCloudMaasModelId:
|
||||
mode === "plan" ? options.planModeHuaweiCloudMaasModelId : options.actModeHuaweiCloudMaasModelId,
|
||||
huaweiCloudMaasModelInfo:
|
||||
mode === "plan" ? options.planModeHuaweiCloudMaasModelInfo : options.actModeHuaweiCloudMaasModelInfo,
|
||||
})
|
||||
case "dify": // Add Dify.ai handler
|
||||
return new DifyHandler({
|
||||
difyApiKey: options.difyApiKey,
|
||||
difyBaseUrl: options.difyBaseUrl,
|
||||
})
|
||||
case "vercel-ai-gateway":
|
||||
return new VercelAIGatewayHandler({
|
||||
onRetryAttempt: options.onRetryAttempt,
|
||||
vercelAiGatewayApiKey: options.vercelAiGatewayApiKey,
|
||||
openRouterModelId:
|
||||
mode === "plan" ? options.planModeVercelAiGatewayModelId : options.actModeVercelAiGatewayModelId,
|
||||
openRouterModelInfo:
|
||||
mode === "plan" ? options.planModeVercelAiGatewayModelInfo : options.actModeVercelAiGatewayModelInfo,
|
||||
reasoningEffort: mode === "plan" ? options.planModeReasoningEffort : options.actModeReasoningEffort,
|
||||
thinkingBudgetTokens:
|
||||
mode === "plan" ? options.planModeThinkingBudgetTokens : options.actModeThinkingBudgetTokens,
|
||||
})
|
||||
case "zai":
|
||||
return new ZAiHandler({
|
||||
onRetryAttempt: options.onRetryAttempt,
|
||||
zaiApiLine: options.zaiApiLine,
|
||||
zaiApiKey: options.zaiApiKey,
|
||||
apiModelId: mode === "plan" ? options.planModeApiModelId : options.actModeApiModelId,
|
||||
})
|
||||
case "oca":
|
||||
return new OcaHandler({
|
||||
ocaMode: options.ocaMode || "internal",
|
||||
ocaBaseUrl: options.ocaBaseUrl,
|
||||
ocaModelId: mode === "plan" ? options.planModeOcaModelId : options.actModeOcaModelId,
|
||||
ocaModelInfo: mode === "plan" ? options.planModeOcaModelInfo : options.actModeOcaModelInfo,
|
||||
ocaReasoningEffort: mode === "plan" ? options.planModeOcaReasoningEffort : options.actModeOcaReasoningEffort,
|
||||
thinkingBudgetTokens:
|
||||
mode === "plan" ? options.planModeThinkingBudgetTokens : options.actModeThinkingBudgetTokens,
|
||||
ocaUsePromptCache:
|
||||
mode === "plan"
|
||||
? options.planModeOcaModelInfo?.supportsPromptCache
|
||||
: options.actModeOcaModelInfo?.supportsPromptCache,
|
||||
taskId: options.ulid,
|
||||
})
|
||||
case "aihubmix":
|
||||
return new AIhubmixHandler({
|
||||
onRetryAttempt: options.onRetryAttempt,
|
||||
apiKey: options.aihubmixApiKey,
|
||||
baseURL: options.aihubmixBaseUrl,
|
||||
appCode: options.aihubmixAppCode,
|
||||
modelId: mode === "plan" ? (options as any).planModeAihubmixModelId : (options as any).actModeAihubmixModelId,
|
||||
modelInfo:
|
||||
mode === "plan" ? (options as any).planModeAihubmixModelInfo : (options as any).actModeAihubmixModelInfo,
|
||||
})
|
||||
case "minimax":
|
||||
return new MinimaxHandler({
|
||||
onRetryAttempt: options.onRetryAttempt,
|
||||
minimaxApiKey: options.minimaxApiKey,
|
||||
minimaxApiLine: options.minimaxApiLine,
|
||||
apiModelId: mode === "plan" ? options.planModeApiModelId : options.actModeApiModelId,
|
||||
thinkingBudgetTokens:
|
||||
mode === "plan" ? options.planModeThinkingBudgetTokens : options.actModeThinkingBudgetTokens,
|
||||
})
|
||||
case "hicap":
|
||||
return new HicapHandler({
|
||||
onRetryAttempt: options.onRetryAttempt,
|
||||
hicapApiKey: options.hicapApiKey,
|
||||
hicapModelId: mode === "plan" ? options.planModeHicapModelId : options.actModeHicapModelId,
|
||||
})
|
||||
case "nousResearch":
|
||||
return new NousResearchHandler({
|
||||
onRetryAttempt: options.onRetryAttempt,
|
||||
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,
|
||||
apiKey: options.apiKey,
|
||||
anthropicBaseUrl: options.anthropicBaseUrl,
|
||||
apiModelId: mode === "plan" ? options.planModeApiModelId : options.actModeApiModelId,
|
||||
reasoningEffort: mode === "plan" ? options.planModeReasoningEffort : options.actModeReasoningEffort,
|
||||
thinkingBudgetTokens:
|
||||
mode === "plan" ? options.planModeThinkingBudgetTokens : options.actModeThinkingBudgetTokens,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export function buildApiHandler(configuration: ApiConfiguration, mode: Mode): ApiHandler {
|
||||
const { planModeApiProvider, actModeApiProvider, ...options } = configuration
|
||||
|
||||
const apiProvider = mode === "plan" ? planModeApiProvider : actModeApiProvider
|
||||
|
||||
// Validate thinking budget tokens against model's maxTokens to prevent API errors
|
||||
// wrapped in a try-catch for safety, but this should never throw
|
||||
try {
|
||||
const thinkingBudgetTokens = mode === "plan" ? options.planModeThinkingBudgetTokens : options.actModeThinkingBudgetTokens
|
||||
if (thinkingBudgetTokens && thinkingBudgetTokens > 0) {
|
||||
const handler = createHandlerForProvider(apiProvider, options, mode)
|
||||
|
||||
const modelInfo = handler.getModel().info
|
||||
if (modelInfo?.maxTokens && modelInfo.maxTokens > 0 && thinkingBudgetTokens > modelInfo.maxTokens) {
|
||||
const clippedValue = modelInfo.maxTokens - 1
|
||||
if (mode === "plan") {
|
||||
options.planModeThinkingBudgetTokens = clippedValue
|
||||
} else {
|
||||
options.actModeThinkingBudgetTokens = clippedValue
|
||||
}
|
||||
} else {
|
||||
return handler // don't rebuild unless its necessary
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
Logger.error("buildApiHandler error:", error)
|
||||
}
|
||||
|
||||
return createHandlerForProvider(apiProvider, options, mode)
|
||||
}
|
||||
|
||||
@@ -1,236 +0,0 @@
|
||||
import { afterEach, describe, it } from "mocha"
|
||||
import sinon from "sinon"
|
||||
import "should"
|
||||
import { anthropicModels } from "@shared/api"
|
||||
import { ANTHROPIC_FAST_MODE_BETA, AnthropicHandler } from "../anthropic"
|
||||
|
||||
describe("AnthropicHandler", () => {
|
||||
afterEach(() => {
|
||||
sinon.restore()
|
||||
})
|
||||
|
||||
const createAsyncIterable = (data: readonly unknown[] = []) => ({
|
||||
[Symbol.asyncIterator]: async function* () {
|
||||
yield* data
|
||||
},
|
||||
})
|
||||
|
||||
describe("getModel", () => {
|
||||
it("should return the fast mode model when configured", () => {
|
||||
const handler = new AnthropicHandler({
|
||||
apiKey: "test-api-key",
|
||||
apiModelId: "claude-opus-4-6:fast",
|
||||
})
|
||||
|
||||
const result = handler.getModel()
|
||||
|
||||
result.id.should.equal("claude-opus-4-6:fast")
|
||||
result.info.should.deepEqual(anthropicModels["claude-opus-4-6:fast"])
|
||||
})
|
||||
|
||||
it("should return the 1m fast mode model when configured", () => {
|
||||
const handler = new AnthropicHandler({
|
||||
apiKey: "test-api-key",
|
||||
apiModelId: "claude-opus-4-6:1m:fast",
|
||||
})
|
||||
|
||||
const result = handler.getModel()
|
||||
|
||||
result.id.should.equal("claude-opus-4-6:1m:fast")
|
||||
result.info.should.deepEqual(anthropicModels["claude-opus-4-6:1m:fast"])
|
||||
})
|
||||
|
||||
it("should return the 4.7 model when configured", () => {
|
||||
const handler = new AnthropicHandler({
|
||||
apiKey: "test-api-key",
|
||||
apiModelId: "claude-opus-4-7",
|
||||
})
|
||||
|
||||
const result = handler.getModel()
|
||||
|
||||
result.id.should.equal("claude-opus-4-7")
|
||||
result.info.should.deepEqual(anthropicModels["claude-opus-4-7"])
|
||||
})
|
||||
|
||||
it("should return the 4.7 1m model when configured", () => {
|
||||
const handler = new AnthropicHandler({
|
||||
apiKey: "test-api-key",
|
||||
apiModelId: "claude-opus-4-7:1m",
|
||||
})
|
||||
|
||||
const result = handler.getModel()
|
||||
|
||||
result.id.should.equal("claude-opus-4-7:1m")
|
||||
result.info.should.deepEqual(anthropicModels["claude-opus-4-7:1m"])
|
||||
})
|
||||
|
||||
it("should return the 4.8 model when configured", () => {
|
||||
const handler = new AnthropicHandler({
|
||||
apiKey: "test-api-key",
|
||||
apiModelId: "claude-opus-4-8",
|
||||
})
|
||||
|
||||
const result = handler.getModel()
|
||||
|
||||
result.id.should.equal("claude-opus-4-8")
|
||||
result.info.should.deepEqual(anthropicModels["claude-opus-4-8"])
|
||||
})
|
||||
|
||||
it("should return the 4.8 1m model when configured", () => {
|
||||
const handler = new AnthropicHandler({
|
||||
apiKey: "test-api-key",
|
||||
apiModelId: "claude-opus-4-8:1m",
|
||||
})
|
||||
|
||||
const result = handler.getModel()
|
||||
|
||||
result.id.should.equal("claude-opus-4-8:1m")
|
||||
result.info.should.deepEqual(anthropicModels["claude-opus-4-8:1m"])
|
||||
})
|
||||
})
|
||||
|
||||
describe("createMessage", () => {
|
||||
it("should route fast mode requests through the beta messages API", async () => {
|
||||
const handler = new AnthropicHandler({
|
||||
apiKey: "test-api-key",
|
||||
apiModelId: "claude-opus-4-6:fast",
|
||||
})
|
||||
|
||||
const standardCreate = sinon.stub().resolves(createAsyncIterable())
|
||||
const betaCreate = sinon.stub().callsFake(function (this: { _client?: object }, _params: unknown) {
|
||||
should.exist(this._client)
|
||||
return Promise.resolve(createAsyncIterable())
|
||||
})
|
||||
|
||||
sinon.stub(handler as unknown as { ensureClient: () => unknown }, "ensureClient").returns({
|
||||
messages: {
|
||||
create: standardCreate,
|
||||
},
|
||||
beta: {
|
||||
messages: {
|
||||
_client: {},
|
||||
create: betaCreate,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
for await (const _chunk of handler.createMessage("system prompt", [{ role: "user", content: "Hello" }])) {
|
||||
}
|
||||
|
||||
sinon.assert.notCalled(standardCreate)
|
||||
sinon.assert.calledOnce(betaCreate)
|
||||
sinon.assert.calledWithMatch(betaCreate, {
|
||||
model: "claude-opus-4-6",
|
||||
betas: [ANTHROPIC_FAST_MODE_BETA],
|
||||
speed: "fast",
|
||||
stream: true,
|
||||
})
|
||||
})
|
||||
|
||||
it("should include the 1m beta when routing 1m fast mode requests through the beta messages API", async () => {
|
||||
const handler = new AnthropicHandler({
|
||||
apiKey: "test-api-key",
|
||||
apiModelId: "claude-opus-4-6:1m:fast",
|
||||
})
|
||||
|
||||
const standardCreate = sinon.stub().resolves(createAsyncIterable())
|
||||
const betaCreate = sinon.stub().callsFake(function (this: { _client?: object }, _params: unknown) {
|
||||
should.exist(this._client)
|
||||
return Promise.resolve(createAsyncIterable())
|
||||
})
|
||||
|
||||
sinon.stub(handler as unknown as { ensureClient: () => unknown }, "ensureClient").returns({
|
||||
messages: {
|
||||
create: standardCreate,
|
||||
},
|
||||
beta: {
|
||||
messages: {
|
||||
_client: {},
|
||||
create: betaCreate,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
for await (const _chunk of handler.createMessage("system prompt", [{ role: "user", content: "Hello" }])) {
|
||||
}
|
||||
|
||||
sinon.assert.notCalled(standardCreate)
|
||||
sinon.assert.calledOnce(betaCreate)
|
||||
sinon.assert.calledWithMatch(betaCreate, {
|
||||
model: "claude-opus-4-6",
|
||||
betas: [ANTHROPIC_FAST_MODE_BETA, "context-1m-2025-08-07"],
|
||||
speed: "fast",
|
||||
stream: true,
|
||||
})
|
||||
})
|
||||
|
||||
it("should include the 1m beta header for Claude Opus 4.7 1m requests", async () => {
|
||||
const handler = new AnthropicHandler({
|
||||
apiKey: "test-api-key",
|
||||
apiModelId: "claude-opus-4-7:1m",
|
||||
reasoningEffort: "high",
|
||||
})
|
||||
|
||||
const standardCreate = sinon.stub().resolves(createAsyncIterable())
|
||||
|
||||
sinon.stub(handler as unknown as { ensureClient: () => unknown }, "ensureClient").returns({
|
||||
messages: {
|
||||
create: standardCreate,
|
||||
},
|
||||
beta: {
|
||||
messages: {
|
||||
_client: {},
|
||||
create: sinon.stub().resolves(createAsyncIterable()),
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
for await (const _chunk of handler.createMessage("system prompt", [{ role: "user", content: "Hello" }])) {
|
||||
}
|
||||
|
||||
sinon.assert.calledOnce(standardCreate)
|
||||
const requestBody = standardCreate.firstCall.args[0] as Record<string, any>
|
||||
const requestOptions = standardCreate.firstCall.args[1] as Record<string, any>
|
||||
requestBody.model.should.equal("claude-opus-4-7")
|
||||
requestBody.thinking.should.deepEqual({ type: "adaptive" })
|
||||
requestOptions.should.deepEqual({
|
||||
headers: {
|
||||
"anthropic-beta": "context-1m-2025-08-07",
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it("should use adaptive thinking and output_config for Claude Opus adaptive models", async () => {
|
||||
const handler = new AnthropicHandler({
|
||||
apiKey: "test-api-key",
|
||||
apiModelId: "claude-opus-4-7",
|
||||
reasoningEffort: "xhigh",
|
||||
})
|
||||
|
||||
const standardCreate = sinon.stub().resolves(createAsyncIterable())
|
||||
|
||||
sinon.stub(handler as unknown as { ensureClient: () => unknown }, "ensureClient").returns({
|
||||
messages: {
|
||||
create: standardCreate,
|
||||
},
|
||||
beta: {
|
||||
messages: {
|
||||
_client: {},
|
||||
create: sinon.stub().resolves(createAsyncIterable()),
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
for await (const _chunk of handler.createMessage("system prompt", [{ role: "user", content: "Hello" }])) {
|
||||
}
|
||||
|
||||
sinon.assert.calledOnce(standardCreate)
|
||||
const requestBody = standardCreate.firstCall.args[0] as Record<string, any>
|
||||
requestBody.should.have.property("thinking")
|
||||
requestBody.thinking.should.deepEqual({ type: "adaptive" })
|
||||
requestBody.should.have.property("output_config")
|
||||
requestBody.output_config.should.deepEqual({ effort: "xhigh" })
|
||||
should(requestBody.temperature).equal(undefined)
|
||||
})
|
||||
})
|
||||
})
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,468 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, it } from "mocha"
|
||||
import sinon from "sinon"
|
||||
import "should"
|
||||
import { ClaudeCodeHandler } from "@core/api/providers/claude-code"
|
||||
import { ClineStorageMessage } from "@/shared/messages/content"
|
||||
|
||||
describe("ClaudeCodeHandler", () => {
|
||||
let handler: ClaudeCodeHandler
|
||||
let sandbox: sinon.SinonSandbox
|
||||
|
||||
beforeEach(() => {
|
||||
sandbox = sinon.createSandbox()
|
||||
handler = new ClaudeCodeHandler({
|
||||
claudeCodePath: "/mock/path",
|
||||
apiModelId: "claude-opus-4-1-20250805",
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
sandbox.restore()
|
||||
})
|
||||
|
||||
describe("token counting", () => {
|
||||
it("should correctly handle token usage from assistant messages", async () => {
|
||||
// The 'input_tokens' field represents the TOTAL number of input tokens used.
|
||||
// See https://docs.anthropic.com/en/api/messages#usage-object
|
||||
|
||||
// Mock the runClaudeCode function
|
||||
const runClaudeCodeModule = await import("@/integrations/claude-code/run")
|
||||
const runClaudeCodeStub = sandbox.stub(runClaudeCodeModule, "runClaudeCode")
|
||||
|
||||
// Create a proper async generator mock for the Claude Code response
|
||||
async function* mockGenerator() {
|
||||
// First yield the system init
|
||||
yield {
|
||||
type: "system",
|
||||
subtype: "init",
|
||||
apiKeySource: "api",
|
||||
}
|
||||
|
||||
// Yield assistant message with usage data
|
||||
// Example: If base input is 70 tokens, cache read is 20, and cache creation is 10,
|
||||
// then input_tokens from Anthropic API will be 100 (70 + 20 + 10)
|
||||
yield {
|
||||
type: "assistant",
|
||||
message: {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "Test response",
|
||||
},
|
||||
],
|
||||
usage: {
|
||||
input_tokens: 100, // Total including cache (per Anthropic docs)
|
||||
output_tokens: 50,
|
||||
cache_read_input_tokens: 20, // Already included in input_tokens
|
||||
cache_creation_input_tokens: 10, // Already included in input_tokens
|
||||
},
|
||||
stop_reason: "end_turn",
|
||||
},
|
||||
}
|
||||
|
||||
// Yield result with cost
|
||||
yield {
|
||||
type: "result",
|
||||
result: {},
|
||||
total_cost_usd: 0.005,
|
||||
}
|
||||
}
|
||||
|
||||
runClaudeCodeStub.returns(mockGenerator() as any)
|
||||
|
||||
const systemPrompt = "You are a helpful assistant."
|
||||
const messages: ClineStorageMessage[] = [{ role: "user", content: "Hello" }]
|
||||
|
||||
const usageData: any[] = []
|
||||
|
||||
// Collect the results
|
||||
for await (const chunk of handler.createMessage(systemPrompt, messages)) {
|
||||
if (chunk.type === "usage") {
|
||||
usageData.push({
|
||||
inputTokens: chunk.inputTokens,
|
||||
outputTokens: chunk.outputTokens,
|
||||
cacheReadTokens: chunk.cacheReadTokens,
|
||||
cacheWriteTokens: chunk.cacheWriteTokens,
|
||||
totalCost: chunk.totalCost,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Verify token counting follows Anthropic API specification
|
||||
usageData.should.have.length(1)
|
||||
usageData[0].should.deepEqual({
|
||||
inputTokens: 100, // Total including cache tokens (per Anthropic API docs)
|
||||
outputTokens: 50,
|
||||
cacheReadTokens: 20, // Tracked separately for reporting
|
||||
cacheWriteTokens: 10, // Tracked separately for reporting
|
||||
totalCost: 0.005,
|
||||
})
|
||||
|
||||
// CRITICAL ASSERTION: Verify that input_tokens is NOT inflated by re-adding cache tokens
|
||||
// The bug would have caused inputTokens to be incorrectly calculated as 130 (100 + 20 + 10)
|
||||
// The fix ensures it remains 100, as per Anthropic's specification
|
||||
usageData[0].inputTokens.should.equal(100) // Correct: matches API response
|
||||
usageData[0].inputTokens.should.not.equal(130) // Would be wrong: double-counting cache tokens
|
||||
})
|
||||
|
||||
it("should handle missing usage fields with nullish coalescing", async () => {
|
||||
// Mock the runClaudeCode function
|
||||
const runClaudeCodeModule = await import("@/integrations/claude-code/run")
|
||||
const runClaudeCodeStub = sandbox.stub(runClaudeCodeModule, "runClaudeCode")
|
||||
|
||||
// Create a proper async generator mock with missing/undefined usage fields
|
||||
async function* mockGenerator() {
|
||||
yield {
|
||||
type: "assistant",
|
||||
message: {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "Test response",
|
||||
},
|
||||
],
|
||||
usage: {
|
||||
input_tokens: 100,
|
||||
output_tokens: 50,
|
||||
// cache fields are undefined/missing
|
||||
},
|
||||
stop_reason: "end_turn",
|
||||
},
|
||||
}
|
||||
|
||||
yield {
|
||||
type: "result",
|
||||
result: {},
|
||||
total_cost_usd: 0.005,
|
||||
}
|
||||
}
|
||||
|
||||
runClaudeCodeStub.returns(mockGenerator() as any)
|
||||
|
||||
const systemPrompt = "You are a helpful assistant."
|
||||
const messages: ClineStorageMessage[] = [{ role: "user", content: "Hello" }]
|
||||
|
||||
const usageData: any[] = []
|
||||
|
||||
// Collect the results
|
||||
for await (const chunk of handler.createMessage(systemPrompt, messages)) {
|
||||
if (chunk.type === "usage") {
|
||||
usageData.push({
|
||||
inputTokens: chunk.inputTokens,
|
||||
outputTokens: chunk.outputTokens,
|
||||
cacheReadTokens: chunk.cacheReadTokens,
|
||||
cacheWriteTokens: chunk.cacheWriteTokens,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Verify that undefined cache tokens default to 0
|
||||
usageData.should.have.length(1)
|
||||
usageData[0].should.deepEqual({
|
||||
inputTokens: 100,
|
||||
outputTokens: 50,
|
||||
cacheReadTokens: 0, // Should default to 0
|
||||
cacheWriteTokens: 0, // Should default to 0
|
||||
})
|
||||
})
|
||||
|
||||
it("should handle completely missing usage object", async () => {
|
||||
// Mock the runClaudeCode function
|
||||
const runClaudeCodeModule = await import("@/integrations/claude-code/run")
|
||||
const runClaudeCodeStub = sandbox.stub(runClaudeCodeModule, "runClaudeCode")
|
||||
|
||||
// Create a proper async generator mock with missing usage object
|
||||
async function* mockGenerator() {
|
||||
yield {
|
||||
type: "assistant",
|
||||
message: {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "Test response",
|
||||
},
|
||||
],
|
||||
// usage is undefined
|
||||
usage: undefined,
|
||||
stop_reason: "end_turn",
|
||||
},
|
||||
}
|
||||
|
||||
// Need to yield a result chunk to trigger usage data emission
|
||||
yield {
|
||||
type: "result",
|
||||
result: {},
|
||||
total_cost_usd: 0,
|
||||
}
|
||||
}
|
||||
|
||||
runClaudeCodeStub.returns(mockGenerator() as any)
|
||||
|
||||
const systemPrompt = "You are a helpful assistant."
|
||||
const messages: ClineStorageMessage[] = [{ role: "user", content: "Hello" }]
|
||||
|
||||
const usageData: any[] = []
|
||||
|
||||
// Collect the results
|
||||
for await (const chunk of handler.createMessage(systemPrompt, messages)) {
|
||||
if (chunk.type === "usage") {
|
||||
usageData.push({
|
||||
inputTokens: chunk.inputTokens,
|
||||
outputTokens: chunk.outputTokens,
|
||||
cacheReadTokens: chunk.cacheReadTokens,
|
||||
cacheWriteTokens: chunk.cacheWriteTokens,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// All token counts should default to 0 when usage is undefined
|
||||
usageData.should.have.length(1)
|
||||
usageData[0].should.deepEqual({
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
cacheReadTokens: 0,
|
||||
cacheWriteTokens: 0,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
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({
|
||||
apiModelId: "claude-sonnet-4-5-20250929",
|
||||
})
|
||||
|
||||
const model = handler.getModel()
|
||||
model.id.should.equal("claude-sonnet-4-5-20250929")
|
||||
})
|
||||
|
||||
it("should support Opus 4.6 1m model id", () => {
|
||||
const handler = new ClaudeCodeHandler({
|
||||
apiModelId: "claude-opus-4-6[1m]",
|
||||
})
|
||||
|
||||
const model = handler.getModel()
|
||||
model.id.should.equal("claude-opus-4-6[1m]")
|
||||
model.info.contextWindow.should.equal(1_000_000)
|
||||
})
|
||||
|
||||
it("should support Opus 4.7 model id", () => {
|
||||
const handler = new ClaudeCodeHandler({
|
||||
apiModelId: "claude-opus-4-7",
|
||||
})
|
||||
|
||||
const model = handler.getModel()
|
||||
model.id.should.equal("claude-opus-4-7")
|
||||
model.info.contextWindow.should.equal(200_000)
|
||||
})
|
||||
|
||||
it("should support Opus 4.7 1m model id", () => {
|
||||
const handler = new ClaudeCodeHandler({
|
||||
apiModelId: "claude-opus-4-7[1m]",
|
||||
})
|
||||
|
||||
const model = handler.getModel()
|
||||
model.id.should.equal("claude-opus-4-7[1m]")
|
||||
model.info.contextWindow.should.equal(1_000_000)
|
||||
})
|
||||
|
||||
it("should support Opus 4.8 model id", () => {
|
||||
const handler = new ClaudeCodeHandler({
|
||||
apiModelId: "claude-opus-4-8",
|
||||
})
|
||||
|
||||
const model = handler.getModel()
|
||||
model.id.should.equal("claude-opus-4-8")
|
||||
model.info.contextWindow.should.equal(200_000)
|
||||
})
|
||||
|
||||
it("should support Opus 4.8 1m model id", () => {
|
||||
const handler = new ClaudeCodeHandler({
|
||||
apiModelId: "claude-opus-4-8[1m]",
|
||||
})
|
||||
|
||||
const model = handler.getModel()
|
||||
model.id.should.equal("claude-opus-4-8[1m]")
|
||||
model.info.contextWindow.should.equal(1_000_000)
|
||||
})
|
||||
|
||||
it("should support Opus 1m alias model id", () => {
|
||||
const handler = new ClaudeCodeHandler({
|
||||
apiModelId: "opus[1m]",
|
||||
})
|
||||
|
||||
const model = handler.getModel()
|
||||
model.id.should.equal("opus[1m]")
|
||||
model.info.contextWindow.should.equal(1_000_000)
|
||||
})
|
||||
|
||||
it("should support Sonnet 1m alias model id", () => {
|
||||
const handler = new ClaudeCodeHandler({
|
||||
apiModelId: "sonnet[1m]",
|
||||
})
|
||||
|
||||
const model = handler.getModel()
|
||||
model.id.should.equal("sonnet[1m]")
|
||||
model.info.contextWindow.should.equal(1_000_000)
|
||||
})
|
||||
|
||||
it("should support Sonnet 4.5 1m model id", () => {
|
||||
const handler = new ClaudeCodeHandler({
|
||||
apiModelId: "claude-sonnet-4-5-20250929[1m]",
|
||||
})
|
||||
|
||||
const model = handler.getModel()
|
||||
model.id.should.equal("claude-sonnet-4-5-20250929[1m]")
|
||||
model.info.contextWindow.should.equal(1_000_000)
|
||||
})
|
||||
|
||||
it("should support Sonnet 4.6 1m model id", () => {
|
||||
const handler = new ClaudeCodeHandler({
|
||||
apiModelId: "claude-sonnet-4-6[1m]",
|
||||
})
|
||||
|
||||
const model = handler.getModel()
|
||||
model.id.should.equal("claude-sonnet-4-6[1m]")
|
||||
model.info.contextWindow.should.equal(1_000_000)
|
||||
})
|
||||
|
||||
it("should return default model when not specified", () => {
|
||||
const handler = new ClaudeCodeHandler({})
|
||||
|
||||
const model = handler.getModel()
|
||||
// The default model should be set
|
||||
model.id.should.be.type("string")
|
||||
model.info.should.be.type("object")
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,166 +0,0 @@
|
||||
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", () => {
|
||||
afterEach(() => {
|
||||
sinon.restore()
|
||||
})
|
||||
|
||||
const createAsyncIterable = (data: any[] = []) => ({
|
||||
[Symbol.asyncIterator]: async function* () {
|
||||
yield* data
|
||||
},
|
||||
})
|
||||
|
||||
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 = createHandler({})
|
||||
const fakeClient = {
|
||||
chat: {
|
||||
completions: {
|
||||
create: sinon.stub().resolves(
|
||||
createAsyncIterable([
|
||||
{
|
||||
choices: [{}],
|
||||
usage: {
|
||||
prompt_tokens: 17,
|
||||
completion_tokens: 9,
|
||||
},
|
||||
},
|
||||
]),
|
||||
),
|
||||
},
|
||||
},
|
||||
}
|
||||
sinon.stub(handler as any, "ensureClient").resolves(fakeClient as any)
|
||||
sinon.stub(handler, "getModel").returns({
|
||||
id: "openai/gpt-4o-mini",
|
||||
info: openRouterDefaultModelInfo,
|
||||
})
|
||||
|
||||
const chunks: any[] = []
|
||||
for await (const chunk of handler.createMessage("system", [{ role: "user", content: "hi" }])) {
|
||||
chunks.push(chunk)
|
||||
}
|
||||
|
||||
chunks.should.deepEqual([
|
||||
{
|
||||
type: "usage",
|
||||
cacheWriteTokens: 0,
|
||||
cacheReadTokens: 0,
|
||||
inputTokens: 17,
|
||||
outputTokens: 9,
|
||||
totalCost: 0,
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it("should read Anthropic-style cache creation and read tokens from usage chunks", async () => {
|
||||
const handler = createHandler({})
|
||||
const fakeClient = {
|
||||
chat: {
|
||||
completions: {
|
||||
create: sinon.stub().resolves(
|
||||
createAsyncIterable([
|
||||
{
|
||||
choices: [{}],
|
||||
usage: {
|
||||
prompt_tokens: 1000,
|
||||
completion_tokens: 200,
|
||||
prompt_tokens_details: {
|
||||
cached_tokens: 500,
|
||||
},
|
||||
cache_creation_input_tokens: 300,
|
||||
},
|
||||
},
|
||||
]),
|
||||
),
|
||||
},
|
||||
},
|
||||
}
|
||||
sinon.stub(handler as any, "ensureClient").resolves(fakeClient as any)
|
||||
sinon.stub(handler, "getModel").returns({
|
||||
id: "anthropic/claude-sonnet-4.6",
|
||||
info: openRouterDefaultModelInfo,
|
||||
})
|
||||
|
||||
const chunks: any[] = []
|
||||
for await (const chunk of handler.createMessage("system", [{ role: "user", content: "hi" }])) {
|
||||
chunks.push(chunk)
|
||||
}
|
||||
|
||||
chunks.should.deepEqual([
|
||||
{
|
||||
type: "usage",
|
||||
cacheWriteTokens: 300,
|
||||
cacheReadTokens: 500,
|
||||
inputTokens: 200,
|
||||
outputTokens: 200,
|
||||
totalCost: 0,
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
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)
|
||||
})
|
||||
|
||||
it("should send cache_control for qwen3.7-max without changing the selected Cline model id", async () => {
|
||||
const handler = createHandler({
|
||||
openRouterModelId: "qwen/qwen3.7-max",
|
||||
openRouterModelInfo: openRouterDefaultModelInfo,
|
||||
})
|
||||
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 as any, "getFreeModelIdSet").resolves(new Set())
|
||||
|
||||
for await (const _chunk of handler.createMessage("system", [{ role: "user", content: "hi" }])) {
|
||||
// drain stream
|
||||
}
|
||||
|
||||
handler.getModel().id.should.equal("qwen/qwen3.7-max")
|
||||
const payload = createStub.firstCall.args[0]
|
||||
payload.model.should.equal("qwen/qwen3.7-max")
|
||||
payload.messages[0].content[0].cache_control.should.deepEqual({ type: "ephemeral" })
|
||||
payload.messages[1].content[0].cache_control.should.deepEqual({ type: "ephemeral" })
|
||||
})
|
||||
})
|
||||
@@ -1,97 +0,0 @@
|
||||
import "should"
|
||||
import sinon from "sinon"
|
||||
import { FireworksHandler } from "../fireworks"
|
||||
|
||||
describe("FireworksHandler", () => {
|
||||
afterEach(() => {
|
||||
sinon.restore()
|
||||
})
|
||||
|
||||
const createAsyncIterable = (data: any[] = []) => ({
|
||||
[Symbol.asyncIterator]: async function* () {
|
||||
yield* data
|
||||
},
|
||||
})
|
||||
|
||||
it("should handle usage-only chunks when delta is missing", 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: 19,
|
||||
completion_tokens: 4,
|
||||
},
|
||||
},
|
||||
]),
|
||||
),
|
||||
},
|
||||
},
|
||||
}
|
||||
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: 19,
|
||||
outputTokens: 4,
|
||||
cacheReadTokens: 0,
|
||||
cacheWriteTokens: 0,
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
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,
|
||||
},
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -1,235 +0,0 @@
|
||||
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("supports Gemini 3.5 Flash model metadata", async () => {
|
||||
const handler = new GeminiHandler({
|
||||
geminiApiKey: "test-api-key",
|
||||
apiModelId: "gemini-3.5-flash",
|
||||
})
|
||||
|
||||
const model = handler.getModel()
|
||||
model.id.should.equal("gemini-3.5-flash")
|
||||
model.info.contextWindow!.should.equal(1_048_576)
|
||||
model.info.inputPrice!.should.equal(1.5)
|
||||
model.info.outputPrice!.should.equal(9)
|
||||
model.info.cacheReadsPrice!.should.equal(0.15)
|
||||
model.info.supportsReasoning!.should.equal(true)
|
||||
|
||||
const generateContentStream = sinon.stub().resolves(
|
||||
createAsyncIterable([
|
||||
{
|
||||
responseId: "resp-35",
|
||||
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.model.should.equal("gemini-3.5-flash")
|
||||
requestArgs.config.should.have.property("maxOutputTokens", 8_192)
|
||||
requestArgs.config.thinkingConfig.should.deepEqual({
|
||||
thinkingBudget: undefined,
|
||||
thinkingLevel: "LOW",
|
||||
includeThoughts: true,
|
||||
})
|
||||
})
|
||||
|
||||
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")
|
||||
})
|
||||
})
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user