mirror of
https://github.com/cline/cline.git
synced 2026-09-11 05:47:07 +08:00
Compare commits
72
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1f9fb196b3 | ||
|
|
718b2260fa | ||
|
|
5c5f80ada0 | ||
|
|
5bb298ee01 | ||
|
|
cf25cd66a7 | ||
|
|
7e19d85b9c | ||
|
|
689156d216 | ||
|
|
bf892de5c2 | ||
|
|
ebd72368a8 | ||
|
|
78bae93b38 | ||
|
|
6b3b4dfc0b | ||
|
|
414d970a2d | ||
|
|
e994486820 | ||
|
|
e48560dc21 | ||
|
|
9520826cc1 | ||
|
|
10a6c06a15 | ||
|
|
b475a0d029 | ||
|
|
1820360468 | ||
|
|
39f5e564f6 | ||
|
|
3f2fe65c19 | ||
|
|
ede87d82f7 | ||
|
|
e6bb1a14ec | ||
|
|
42ab1b94a2 | ||
|
|
97d8a33db0 | ||
|
|
4961bf2898 | ||
|
|
47f3654b70 | ||
|
|
7b4a0bf40a | ||
|
|
cf814e1479 | ||
|
|
40e64baa0a | ||
|
|
540b9234dc | ||
|
|
52828aab71 | ||
|
|
33dafb193b | ||
|
|
643d945d65 | ||
|
|
f3a215cd0e | ||
|
|
4fce10248e | ||
|
|
154f9e0e11 | ||
|
|
c041089a6d | ||
|
|
62f11cd1d3 | ||
|
|
ea38d1049d | ||
|
|
cfb4ef49be | ||
|
|
a94d1b2c08 | ||
|
|
5685c2fa36 | ||
|
|
ba28c556b4 | ||
|
|
0fbcbc45f5 | ||
|
|
b37d8e466b | ||
|
|
482ae279f8 | ||
|
|
ddb16f7dc6 | ||
|
|
4aace9e226 | ||
|
|
437f7eb745 | ||
|
|
1107df80d3 | ||
|
|
a1a88c4258 | ||
|
|
5320885770 | ||
|
|
6fcbd039fa | ||
|
|
79ffd2f5fb | ||
|
|
9a83fcb4fa | ||
|
|
0ad9de2317 | ||
|
|
1bba06ae6f | ||
|
|
5575f681f2 | ||
|
|
4922935564 | ||
|
|
7e39120191 | ||
|
|
8c36159c43 | ||
|
|
a193f19468 | ||
|
|
855d31c86f | ||
|
|
1acacda3f5 | ||
|
|
6f2f159f7e | ||
|
|
96da30d8c7 | ||
|
|
7a0d48c2e4 | ||
|
|
18a29b7563 | ||
|
|
3d8a849f03 | ||
|
|
e7e0e2b559 | ||
|
|
695492a97b | ||
|
|
7460d460ac |
+13
-9
@@ -97,10 +97,12 @@ Adding a new key to global state requires updates in multiple places. Missing an
|
||||
|
||||
Required steps:
|
||||
1. Type definition in `src/shared/storage/state-keys.ts` - Add to `GlobalState` or `Settings` interface
|
||||
2. Add any default value or transform in `src/shared/storage/state-keys.ts` if the key needs one
|
||||
3. Read and write the value through `StateManager` (`setGlobalState()` / `getGlobalStateKey()`) after initialization
|
||||
2. Read from globalState in `src/core/storage/utils/state-helpers.ts`:
|
||||
- Add `const myKey = context.globalState.get<GlobalStateAndSettings["myKey"]>("myKey")` in `readGlobalStateFromDisk()`
|
||||
- Add to the return object: `myKey: myKey ?? defaultValue,`
|
||||
3. StateManager handles read/write via `setGlobalState()`/`getGlobalStateKey()` after initialization
|
||||
|
||||
Persistent state is file-backed through `StateManager`; do not add new runtime reads or writes against VS Code `ExtensionContext` storage. That storage is only a legacy migration source.
|
||||
Common mistake: Adding only the return value without the `context.globalState.get()` call. This compiles but the value is always `undefined` on load.
|
||||
|
||||
Settings plumbing gotcha: if a key is user-toggleable from settings, wire both controller update paths:
|
||||
- `src/core/controller/state/updateSettings.ts` for webview `updateSetting(...)`
|
||||
@@ -114,20 +116,22 @@ Webview toggle gotcha: settings changes must also round-trip back in state paylo
|
||||
If this round-trip wiring is missing, the backend value can update but the toggle in webview appears stuck or reverts.
|
||||
|
||||
## StateManager Cache vs Direct globalState Access
|
||||
StateManager uses an in-memory cache populated during `StateManager.initialize()` from file-backed storage. For most state, use `controller.stateManager.setGlobalState()`/`getGlobalStateKey()`.
|
||||
StateManager uses an in-memory cache populated during `StateManager.initialize(context)` in `common.ts`. For most state, use `controller.stateManager.setGlobalState()`/`getGlobalStateKey()`.
|
||||
|
||||
Exception: host migration code may read legacy VS Code storage before file-backed storage is initialized.
|
||||
Exception: State needed immediately at extension startup (before cache is ready)
|
||||
|
||||
Example pattern:
|
||||
When Window A sets state and immediately opens Window B, the new window's StateManager cache is populated from `context.globalState` during initialization. If you need to read state in Window B right at startup (e.g., in `common.ts` during `initialize()`), read directly from `context.globalState.get()` instead of StateManager's cache.
|
||||
|
||||
Example pattern (see `lastShownAnnouncementId` and `worktreeAutoOpenPath`):
|
||||
```typescript
|
||||
// Writing (normal pattern)
|
||||
controller.stateManager.setGlobalState("myKey", value)
|
||||
|
||||
// Reading after initialization
|
||||
const value = controller.stateManager.getGlobalStateKey("myKey")
|
||||
// Reading at startup in common.ts (bypass cache)
|
||||
const value = context.globalState.get<string>("myKey")
|
||||
```
|
||||
|
||||
Use `context.globalState` only in VS Code migration code that copies legacy ExtensionContext values into the shared file-backed stores.
|
||||
This is only needed for cross-window state read during the brief startup window before StateManager cache is fully usable. Normal state access after initialization should use StateManager.
|
||||
|
||||
## ChatRow Cancelled/Interrupted States
|
||||
When a ChatRow displays a loading/in-progress state (spinner), you must handle what happens when the task is cancelled. This is non-obvious because cancellation doesn't update the message content—you have to infer it from context.
|
||||
|
||||
@@ -44,7 +44,7 @@ For Responses API providers: add to `isNextGenModelProvider()` in `src/utils/mod
|
||||
Modular: `components/` (shared) + `variants/` (model-specific) + `templates/` (`{{PLACEHOLDER}}`). Variants override components via `componentOverrides` in `config.ts` or custom `template.ts`. XS variant is heavily condensed inline. Always regenerate snapshots after changes.
|
||||
|
||||
## Global State Keys (silent failure risk)
|
||||
Adding a key requires updating the typed storage definitions in `src/shared/storage/state-keys.ts`; runtime reads and writes should go through `StateManager`, not VS Code `ExtensionContext` storage. Persistent state is file-backed so it works across VS Code, CLI, and JetBrains hosts.
|
||||
Adding a key requires: type in `src/shared/storage/state-keys.ts`, read via `context.globalState.get()` in `src/core/storage/utils/state-helpers.ts` `readGlobalStateFromDisk()`, and add to return object. Missing the `.get()` call compiles fine but value is always `undefined`.
|
||||
|
||||
## Slash Commands (3 places)
|
||||
- `src/core/slash-commands/index.ts` — definitions.
|
||||
|
||||
@@ -128,15 +128,13 @@ jobs:
|
||||
restore-keys: |
|
||||
playwright-browsers-${{ runner.os }}-
|
||||
|
||||
- name: Install extension dependencies
|
||||
working-directory: ${{ github.workspace }}
|
||||
run: npm --prefix apps/vscode ci
|
||||
- name: Install root dependencies
|
||||
run: npm ci
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Install webview-ui dependencies
|
||||
working-directory: ${{ github.workspace }}
|
||||
run: npm --prefix apps/vscode/webview-ui ci
|
||||
run: cd webview-ui && npm ci
|
||||
|
||||
- name: Install vsce
|
||||
run: npm install -g @vscode/vsce
|
||||
|
||||
@@ -212,12 +212,8 @@ cline schedule create "PR summary" \
|
||||
Chat with your agent from any messaging platform: Telegram, Slack, Discord, Google Chat, WhatsApp, and Linear. Each conversation thread maps to an agent session with full context. Set up access control to restrict who can interact with your agent.
|
||||
|
||||
```bash
|
||||
# Connect to Telegram
|
||||
cline connect telegram -k $BOT_TOKEN
|
||||
# Connect to Slack through webhook
|
||||
cline connect slack --bot-token $SLACK_TOKEN --signing-secret $SECRET --base-url $URL
|
||||
# Connect to Slack using socket mode
|
||||
cline connect slack --bot-token $SLACK_TOKEN --app-token $SLACK_APP_TOKEN
|
||||
cline connect slack --token $SLACK_TOKEN --signing-secret $SECRET --base-url $URL
|
||||
```
|
||||
|
||||
## Headless CLI for CI/CD
|
||||
|
||||
@@ -9,13 +9,12 @@ Use this skill when the user asks to release the CLI, publish `cline`, bump the
|
||||
|
||||
The CLI is npm-only. Do not add alternate distribution or signing steps.
|
||||
|
||||
> Working directory: run every command below from the repository root. Paths and scripts (e.g. `apps/cli/package.json`, `sdk/packages/`, `bun release cli`, `bun run version`) are written relative to the repo root.
|
||||
> Working directory: this skill lives in the SDK sub-monorepo. Run `cd sdk` (from the repo root) before any of the shell commands below. Paths in commands and instructions (e.g. `apps/cli/package.json`, `bun release cli`) are written relative to `sdk/`.
|
||||
|
||||
The skill should guide the user through one release preparation flow, then offer the publish path options. The two normal publish paths are GitHub Actions and local publishing from an authenticated machine.
|
||||
|
||||
## Release contract
|
||||
|
||||
- SDK prerequisite: the CLI depends on the SDK via `workspace:*` (`@cline/core`, `@cline/shared`, and friends). If the SDK changed since its last release, release the SDK first and wait for it to finish publishing before releasing the CLI. See "Step 0: Release the SDK first if it changed" below.
|
||||
- Version source: `apps/cli/package.json`.
|
||||
- Main release tag: `cli-vX.Y.Z`, where `X.Y.Z` matches `apps/cli/package.json`.
|
||||
- Nightly release version: `X.Y.Z-nightly.TIMESTAMP`.
|
||||
@@ -31,93 +30,8 @@ The skill should guide the user through one release preparation flow, then offer
|
||||
- Always ask before pushing commits or tags.
|
||||
- Do not amend commits unless explicitly requested.
|
||||
|
||||
## Step 0: Release the SDK first if it changed
|
||||
|
||||
Do this before anything else in the Workflow below.
|
||||
|
||||
The CLI builds and ships against the SDK source in the monorepo (`workspace:*` for `@cline/core`, `@cline/shared`, and the rest), so a CLI release always contains the latest SDK code whether or not the SDK was released. The build and tests use that source too, not anything from npm. Releasing the SDK alongside the CLI is still worth doing for two reasons:
|
||||
|
||||
- Hub freshness. The hub daemon lives in `@cline/core` and stamps a `buildId` that defaults to the `@cline/core` package version (`resolveHubBuildId` in `sdk/packages/core/src/hub/discovery/index.ts`). A running hub is only retired and respawned when that `buildId` changes (`isCompatibleHubRecord` / `retireIncompatibleHub` in `sdk/packages/core/src/hub/daemon/index.ts`). So if the SDK code changed but the version did not, a user who upgrades the CLI keeps talking to their already-running hub, which is still executing the old SDK code. Bumping the SDK version makes the new CLI's `buildId` differ, so the stale hub is detected as incompatible and respawned with the fresh code.
|
||||
- Release hygiene. We want regular SDK releases; cutting one whenever we cut a CLI release keeps the published SDK in step with what the CLI ships.
|
||||
|
||||
So when the SDK has changed, release it first (which bumps the `@cline/core` version), then cut the CLI release on top of that bump. Leave the CLI's SDK dependency as `workspace:*` — the fix is to release the SDK, not to pin the CLI.
|
||||
|
||||
1. Check for unreleased SDK changes.
|
||||
|
||||
```sh
|
||||
git fetch origin --tags
|
||||
git tag --list 'sdk/sdk/v*' 'sdk-v*' --sort=-v:refname | head -1
|
||||
git log <last-sdk-tag>..origin/main --oneline --no-merges -- sdk/packages
|
||||
```
|
||||
|
||||
`sdk/<pkg>/v*` tags are created by the `sdk-publish.yml` workflow; `sdk-v*` tags are created by the local `bun release sdk` helper. Use whichever is newest as the baseline.
|
||||
|
||||
If `git log` prints no commits, the SDK is already up to date. Skip the rest of Step 0 and continue with the Workflow below.
|
||||
|
||||
If it prints commits, sanity-check the diff (ignore entries that are only the previous version-bump commit's lockfile or generated files), then release the SDK.
|
||||
|
||||
2. Decide the SDK version bump.
|
||||
|
||||
All SDK packages share one version, read from `sdk/packages/llms/package.json`. Ask whether this is patch, minor, major, or an explicit version. Patch is the default. Do not guess if the user has not made it clear.
|
||||
|
||||
3. Draft the SDK release notes and update the changelog.
|
||||
|
||||
Draft user-facing notes from the SDK commits found in step 1, translating commit messages into user-facing language (same approach as the CLI release notes below). Prepend a new `## <version>` section with those notes to the top of `sdk/CHANGELOG.md`, using the header format `## <version>` with no date — the same flat, newest-on-top format as `apps/cli/CHANGELOG.md`. This is the SDK changelog (all SDK packages share one version) and it is maintained by hand; the `sdk-publish.yml` workflow does not read it.
|
||||
|
||||
4. Bump versions and regenerate.
|
||||
|
||||
```sh
|
||||
bun run version <version>
|
||||
```
|
||||
|
||||
This bumps every SDK `package.json` to the new version, regenerates the lockfile and the generated model catalog, formats, and builds. Review the result.
|
||||
|
||||
5. Commit and push the bump to `main`.
|
||||
|
||||
The `sdk-publish.yml` workflow publishes the version that is committed on `main` and tags that commit, so the bump must land on `main` before the workflow runs.
|
||||
|
||||
```sh
|
||||
git add -A
|
||||
git commit -m "chore(sdk): release v<version>"
|
||||
```
|
||||
|
||||
Ask before pushing:
|
||||
|
||||
```sh
|
||||
git push origin HEAD
|
||||
```
|
||||
|
||||
6. Trigger the SDK publish workflow on the `latest` channel.
|
||||
|
||||
```sh
|
||||
gh workflow run sdk-publish.yml -f channel=latest -f confirm_publish=publish
|
||||
gh run list --workflow=sdk-publish.yml --limit=1 --json databaseId,url,status,createdAt --jq '.[0]'
|
||||
```
|
||||
|
||||
The workflow runs the SDK tests, publishes `@cline/shared`, `@cline/llms`, `@cline/agents`, `@cline/core`, and `@cline/sdk` to npm with the `latest` dist-tag in dependency order, and pushes `sdk/<pkg>/v<version>` git tags.
|
||||
|
||||
7. Wait for the SDK workflow to succeed before starting the CLI release.
|
||||
|
||||
```sh
|
||||
gh run watch <run-id> --exit-status
|
||||
```
|
||||
|
||||
Do not start the CLI release until this run has finished successfully. The CLI does not install the SDK from npm, but cutting the CLI release on top of a clean, completed SDK release keeps the two in step: the CLI release commit then sits on top of the `@cline/core` version bump, so the shipped CLI carries the new version that forces a running hub to respawn with the new code, and you are not building a CLI release on top of an SDK release that failed midway.
|
||||
|
||||
After the SDK release succeeds, pull `main` so the CLI release is prepared on top of the SDK version bump:
|
||||
|
||||
```sh
|
||||
git checkout main && git pull --ff-only
|
||||
```
|
||||
|
||||
Then continue with the Workflow below.
|
||||
|
||||
For a local SDK publish from an authenticated machine instead of the workflow, `bun release sdk <version>` exists, but prefer the `sdk-publish.yml` workflow for normal releases so the CLI release can gate on a single GitHub Actions run.
|
||||
|
||||
## Workflow
|
||||
|
||||
Complete Step 0 first. Only proceed once the SDK is released (or you confirmed no SDK release was needed).
|
||||
|
||||
1. Gather context.
|
||||
|
||||
```sh
|
||||
@@ -132,10 +46,10 @@ Find the latest CLI tag. If there is no `cli-v*` tag, use the first relevant CLI
|
||||
2. Collect release commits.
|
||||
|
||||
```sh
|
||||
git log <last-cli-tag>..HEAD --oneline --no-merges -- apps/cli sdk/packages sdk/scripts .github/workflows/cli-publish.yml
|
||||
git log <last-cli-tag>..HEAD --oneline --no-merges -- apps/cli packages scripts .github/workflows/cli-publish.yml
|
||||
```
|
||||
|
||||
The `sdk/packages` commits matter here even though the SDK was released separately in Step 0: the CLI bundles the SDK, so SDK changes ship in this CLI release too. Read those commits and fold anything user-relevant to the CLI into the release notes (provider/model updates, behavior changes, fixes the CLI inherits). Skip SDK changes that are purely internal or have no CLI-visible effect.
|
||||
If the release includes broader SDK changes that affect the CLI, also inspect commits outside `apps/cli`.
|
||||
|
||||
3. Draft user-facing release notes.
|
||||
|
||||
@@ -1,38 +1,5 @@
|
||||
# Cline CLI Changelog
|
||||
|
||||
## 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.
|
||||
|
||||
## 3.0.19
|
||||
|
||||
- Fixed CLI auto-update to use `npm update` so updates apply reliably, while preserving the installed release channel (e.g. nightly).
|
||||
|
||||
## 3.0.18
|
||||
|
||||
- Fix Slack channel mentions so replies post in the original message's thread.
|
||||
- Fix the abort indicator to clear immediately when a task is cancelled.
|
||||
- Sync the Fireworks AI model registry and refresh the bundled model catalog with current platform offerings.
|
||||
- Bump the bundled SDK to v0.0.43, which forces a running Cline Hub to restart so it picks up the latest SDK code.
|
||||
|
||||
## 3.0.17
|
||||
|
||||
- Fix a regression introduced in 3.0.15 where the interactive CLI could get stuck after stopping and restarting Cline Hub and then pressing Escape to cancel a request. The CLI now detects stale or missing sessions, recovers any pending messages, and starts a fresh session instead of failing with "session not found".
|
||||
- Fix Ctrl+C and Hub shutdown races that surfaced as "hook dispatch failed" and WebSocket connection errors from late hook events racing against Hub shutdown.
|
||||
- Fix the Hub daemon being shut down prematurely when a runtime request was aborted, so the daemon now stays alive.
|
||||
- Improve the Telegram connector with a new `--allowed-user-id` flag to restrict which Telegram users are authorized to interact with the agent.
|
||||
|
||||
## 3.0.16
|
||||
|
||||
- Install official Cline plugins by slug off the new github.com/cline/plugins collection.
|
||||
- Uninstall plugins using `cline plugin uninstall <plugin>` or in the TUI.
|
||||
- Plugins can now bundle skills, and plugin skills are grouped together in settings.
|
||||
- Add Slack socket mode support.
|
||||
- Allow a custom base URL for Anthropic vendor-type providers.
|
||||
- Fix OAuth token migration for users signed in through the old extension.
|
||||
- Use a union schema for read-files tool input validation.
|
||||
- Add a `CLINE_PLUGIN_IMPORT_TIMEOUT_MS` env override to control the plugin import timeout.
|
||||
|
||||
## 3.0.15
|
||||
|
||||
- Add Cline Hub, a web app for monitoring connected clients, viewing and driving sessions, streaming assistant output, and restarting the local hub, with local, LAN, and tunnel usage gated by a room secret.
|
||||
|
||||
@@ -416,7 +416,7 @@ Then attach VS Code or Chrome DevTools to `ws://127.0.0.1:6499`.
|
||||
|
||||
## Publishing
|
||||
|
||||
The CLI is published as the `cline` wrapper package on npm with platform-specific binaries under `@cline/cli-*`. The release flow lives in the `publish-cli` skill (`.cline/skills/publish-cli/SKILL.md` at the repo root).
|
||||
The CLI is published as the `cline` wrapper package on npm with platform-specific binaries under `@cline/cli-*`. The release flow lives in the `publish-cli` skill (`apps/cli/.cline/skills/publish-cli/SKILL.md`).
|
||||
|
||||
From the `apps/cli` workspace:
|
||||
|
||||
|
||||
@@ -174,9 +174,6 @@ cline connect telegram -k 123456:ABCDEF...
|
||||
# Slack (webhook mode)
|
||||
cline connect slack --bot-token $SLACK_BOT_TOKEN --signing-secret $SLACK_SIGNING_SECRET --base-url https://your-domain.com
|
||||
|
||||
# Slack (socket mode)
|
||||
cline connect slack --bot-token $SLACK_BOT_TOKEN --app-token $SLACK_APP_TOKEN
|
||||
|
||||
# Google Chat (webhook mode)
|
||||
cline connect gchat --base-url https://your-domain.com
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@cline/cli",
|
||||
"displayName": "cline",
|
||||
"version": "3.0.20",
|
||||
"version": "3.0.15",
|
||||
"description": "Autonomous coding agent CLI - capable of creating/editing files, running commands, using the browser, and more",
|
||||
"type": "module",
|
||||
"publishConfig": {
|
||||
|
||||
@@ -21,7 +21,6 @@ import {
|
||||
isOfficialPluginSlug,
|
||||
parsePluginSource,
|
||||
runPluginInstallCommand,
|
||||
runPluginUninstallCommand,
|
||||
} from "./plugin";
|
||||
|
||||
type FetchCall = (
|
||||
@@ -239,10 +238,6 @@ describe("plugin install command", () => {
|
||||
expect(readFileSync(result.entryPaths[0] ?? "", "utf8")).toContain(
|
||||
"official-web-search",
|
||||
);
|
||||
const wrapperManifest = JSON.parse(
|
||||
readFileSync(join(result.installPath, "package.json"), "utf8"),
|
||||
) as { name?: string };
|
||||
expect(wrapperManifest.name).toBe("web-search");
|
||||
expect(existsSync(join(result.installPath, "repo"))).toBe(false);
|
||||
expect(
|
||||
existsSync(join(result.installPath, "package", "other-plugin")),
|
||||
@@ -332,10 +327,6 @@ describe("plugin install command", () => {
|
||||
expect(result.installPath).toContain(
|
||||
join(workspace, ".cline", "plugins", "_installed", "local"),
|
||||
);
|
||||
const wrapperManifest = JSON.parse(
|
||||
readFileSync(join(result.installPath, "package.json"), "utf8"),
|
||||
) as { name?: string };
|
||||
expect(wrapperManifest.name).toBe("web-search");
|
||||
expect(readFileSync(result.entryPaths[0] ?? "", "utf8")).toContain(
|
||||
"local-web-search",
|
||||
);
|
||||
@@ -464,8 +455,7 @@ describe("plugin install command", () => {
|
||||
|
||||
const wrapperManifest = JSON.parse(
|
||||
readFileSync(join(result.installPath, "package.json"), "utf8"),
|
||||
) as { name?: string; cline?: { plugins?: Array<{ paths?: string[] }> } };
|
||||
expect(wrapperManifest.name).toBe("plugin-package");
|
||||
) as { cline?: { plugins?: Array<{ paths?: string[] }> } };
|
||||
expect(wrapperManifest.cline?.plugins?.[0]?.paths).toHaveLength(1);
|
||||
expect(wrapperManifest.cline?.plugins?.[0]?.paths?.[0]).toContain(
|
||||
"package/index.ts",
|
||||
@@ -603,54 +593,6 @@ describe("plugin install command", () => {
|
||||
).toContain("installed-v1");
|
||||
});
|
||||
|
||||
it("uninstalls a package plugin by package name", async () => {
|
||||
const source = join(root, "uninstall-package");
|
||||
const npmCommandPath = join(root, "fake-npm.sh");
|
||||
await mkdir(source, { recursive: true });
|
||||
await writeFile(
|
||||
join(source, "package.json"),
|
||||
JSON.stringify(
|
||||
{
|
||||
name: "cli-uninstall-plugin",
|
||||
cline: {
|
||||
plugins: [{ paths: ["./index.ts"], capabilities: ["tools"] }],
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
"utf8",
|
||||
);
|
||||
await writeFile(
|
||||
join(source, "index.ts"),
|
||||
"export default { name: 'cli-uninstall-plugin', manifest: { capabilities: ['tools'] } };",
|
||||
"utf8",
|
||||
);
|
||||
writeFileSync(npmCommandPath, "#!/bin/sh\nexit 0\n", {
|
||||
encoding: "utf8",
|
||||
mode: 0o755,
|
||||
});
|
||||
|
||||
const installed = await installPlugin({
|
||||
source,
|
||||
npmCommand: npmCommandPath,
|
||||
});
|
||||
const output: string[] = [];
|
||||
const code = await runPluginUninstallCommand({
|
||||
name: "cli-uninstall-plugin",
|
||||
io: {
|
||||
writeln: (text = "") => output.push(text),
|
||||
writeErr: (text) => output.push(text),
|
||||
},
|
||||
});
|
||||
|
||||
expect(code).toBe(0);
|
||||
expect(existsSync(installed.installPath)).toBe(false);
|
||||
expect(output.join("\n")).toContain(
|
||||
"Uninstalled plugin cli-uninstall-plugin",
|
||||
);
|
||||
});
|
||||
|
||||
it("prints JSON output for command callers", async () => {
|
||||
const source = join(root, "json.ts");
|
||||
writeFileSync(
|
||||
|
||||
@@ -12,16 +12,7 @@ import {
|
||||
} from "node:fs";
|
||||
import { cp, mkdir, writeFile } from "node:fs/promises";
|
||||
import { homedir } from "node:os";
|
||||
import {
|
||||
basename,
|
||||
dirname,
|
||||
extname,
|
||||
join,
|
||||
relative,
|
||||
resolve,
|
||||
sep,
|
||||
} from "node:path";
|
||||
import { type PluginUninstallOptions, uninstallPlugin } from "@cline/core";
|
||||
import { basename, dirname, join, relative, resolve, sep } from "node:path";
|
||||
import {
|
||||
isPluginModulePath,
|
||||
resolveClineDir,
|
||||
@@ -477,25 +468,6 @@ function getInstallSourceKey(
|
||||
return `local:${resolve(cwd, resolveHomePath(parsed.path))}`;
|
||||
}
|
||||
|
||||
function getWrapperPackageName(
|
||||
parsed: ParsedPluginSource,
|
||||
cwd: string,
|
||||
): string {
|
||||
if (parsed.type === "npm") {
|
||||
return parsed.name;
|
||||
}
|
||||
if (parsed.type === "git") {
|
||||
return sanitizeSegment(basename(parsed.path));
|
||||
}
|
||||
if (parsed.type === "remote") {
|
||||
return sanitizeSegment(basename(parsed.filename, extname(parsed.filename)));
|
||||
}
|
||||
if (parsed.type === "official") {
|
||||
return parsed.slug;
|
||||
}
|
||||
return sanitizeSegment(basename(resolve(cwd, resolveHomePath(parsed.path))));
|
||||
}
|
||||
|
||||
async function runCommand(
|
||||
command: string,
|
||||
args: string[],
|
||||
@@ -692,7 +664,6 @@ function toWrapperEntryPaths(
|
||||
async function writeWrapperManifest(
|
||||
wrapperRoot: string,
|
||||
packageRoot: string,
|
||||
packageName: string,
|
||||
): Promise<string[]> {
|
||||
const entryPaths = toWrapperEntryPaths(wrapperRoot, packageRoot);
|
||||
await writeFile(
|
||||
@@ -700,7 +671,7 @@ async function writeWrapperManifest(
|
||||
JSON.stringify(
|
||||
{
|
||||
...WRAPPER_PACKAGE_JSON,
|
||||
name: packageName,
|
||||
name: `cline-installed-plugin-${hashSource(wrapperRoot)}`,
|
||||
cline: {
|
||||
plugins: [{ paths: entryPaths }],
|
||||
},
|
||||
@@ -1016,7 +987,6 @@ export async function installPlugin(
|
||||
);
|
||||
const sourceKey = getInstallSourceKey(parsed, cwd, officialPluginsRepo);
|
||||
const installPath = getInstallPath(pluginRoot, parsed, sourceKey);
|
||||
const wrapperPackageName = getWrapperPackageName(parsed, cwd);
|
||||
const stagingParent = join(pluginRoot, INSTALLS_DIRECTORY_NAME, ".tmp");
|
||||
const stagingRoot = join(
|
||||
stagingParent,
|
||||
@@ -1059,11 +1029,7 @@ export async function installPlugin(
|
||||
? collectPluginEntries(stagingRoot).map(
|
||||
(entry) => `./${toPosixPath(relative(stagingRoot, entry))}`,
|
||||
)
|
||||
: await writeWrapperManifest(
|
||||
stagingRoot,
|
||||
packageRoot,
|
||||
wrapperPackageName,
|
||||
);
|
||||
: await writeWrapperManifest(stagingRoot, packageRoot);
|
||||
if (entryPaths.length === 0) {
|
||||
throw new Error(`No plugin entry files found for ${source}`);
|
||||
}
|
||||
@@ -1098,22 +1064,3 @@ export async function runPluginInstallCommand(
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
export async function runPluginUninstallCommand(
|
||||
options: PluginUninstallOptions & { json?: boolean; io?: PluginInstallIo },
|
||||
): Promise<number> {
|
||||
try {
|
||||
const result = await uninstallPlugin(options);
|
||||
if (options.json) {
|
||||
process.stdout.write(JSON.stringify(result));
|
||||
return 0;
|
||||
}
|
||||
options.io?.writeln(`Uninstalled plugin ${result.name}`);
|
||||
options.io?.writeln(` Removed: ${result.installPath}`);
|
||||
return 0;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
options.io?.writeErr(message);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,7 +45,7 @@ describe("getInstallationInfo", () => {
|
||||
expect(getInstallationInfo("1.2.3")).toEqual({
|
||||
packageManager: PackageManager.NPM,
|
||||
packageName: "cline",
|
||||
updateCommand: "npm update -g cline --tag latest",
|
||||
updateCommand: "npm install -g cline@latest",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -57,7 +57,7 @@ describe("getInstallationInfo", () => {
|
||||
expect(getInstallationInfo("1.2.3-nightly.456")).toEqual({
|
||||
packageManager: PackageManager.NPM,
|
||||
packageName: "cline",
|
||||
updateCommand: "npm update -g cline --tag nightly",
|
||||
updateCommand: "npm install -g cline@nightly",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -76,10 +76,10 @@ describe("withMinimumReleaseAgeBypass", () => {
|
||||
it("adds the package-manager-specific cooldown bypass", () => {
|
||||
expect(
|
||||
withMinimumReleaseAgeBypass(
|
||||
"npm update -g cline --tag latest",
|
||||
"npm install -g cline@latest",
|
||||
PackageManager.NPM,
|
||||
).command,
|
||||
).toBe("npm update -g cline --tag latest --min-release-age=0");
|
||||
).toBe("npm install -g cline@latest --min-release-age=0");
|
||||
expect(
|
||||
withMinimumReleaseAgeBypass("bun add -g cline@latest", PackageManager.BUN)
|
||||
.command,
|
||||
|
||||
@@ -126,7 +126,7 @@ export function getInstallationInfo(currentVersion: string): InstallationInfo {
|
||||
return {
|
||||
packageManager: PackageManager.NPM,
|
||||
packageName: DEFAULT_PACKAGE_NAME,
|
||||
updateCommand: `npm update -g ${DEFAULT_PACKAGE_NAME} --tag ${tag}`,
|
||||
updateCommand: `npm install -g ${DEFAULT_PACKAGE_NAME}@${tag}`,
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
@@ -341,25 +341,18 @@ export function autoUpdateOnStartup(): void {
|
||||
if (process.env.IS_DEV === "true") return;
|
||||
if (process.env.CLINE_NO_AUTO_UPDATE === "1") return;
|
||||
|
||||
const { packageName, packageManager, updateCommand } =
|
||||
getInstallationInfo(version);
|
||||
const { packageName, updateCommand } = getInstallationInfo(version);
|
||||
if (!updateCommand) return;
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
const latest = await getLatestVersion(packageName, version);
|
||||
if (!latest || compareVersions(version, latest) >= 0) return;
|
||||
const autoUpdateCommand = withMinimumReleaseAgeBypass(
|
||||
updateCommand,
|
||||
packageManager,
|
||||
);
|
||||
const child = spawn(autoUpdateCommand.command, {
|
||||
const child = spawn(updateCommand, {
|
||||
shell: true,
|
||||
detached: true,
|
||||
stdio: "ignore",
|
||||
env: autoUpdateCommand.env
|
||||
? { ...process.env, ...autoUpdateCommand.env }
|
||||
: process.env,
|
||||
env: process.env,
|
||||
});
|
||||
const exitCode = await waitForProcessExit(child);
|
||||
if (exitCode === 0) {
|
||||
|
||||
@@ -1,68 +1,9 @@
|
||||
import type { ConnectSlackOptions } from "@cline/shared";
|
||||
import { type Message, ThreadImpl } from "chat";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { __test__, slackConnector } from "./slack";
|
||||
|
||||
const parseSlackArgs = (rawArgs: string[]): ConnectSlackOptions =>
|
||||
(
|
||||
slackConnector as unknown as {
|
||||
parseArgs(rawArgs: string[]): ConnectSlackOptions;
|
||||
}
|
||||
).parseArgs(rawArgs);
|
||||
import { __test__ } from "./slack";
|
||||
|
||||
describe("slack binding lookup", () => {
|
||||
const participantKey = __test__.buildSlackParticipantKey("T123", "U123");
|
||||
|
||||
it("infers Slack webhook mode from a base URL", () => {
|
||||
expect(__test__.inferSlackConnectionMode("https://example.test")).toBe(
|
||||
"webhook",
|
||||
);
|
||||
expect(__test__.inferSlackConnectionMode(" ")).toBe("socket");
|
||||
expect(__test__.inferSlackConnectionMode(undefined)).toBe("socket");
|
||||
});
|
||||
|
||||
it("uses webhook mode when Slack args include a base URL", () => {
|
||||
const options = parseSlackArgs([
|
||||
"--bot-token",
|
||||
"xoxb-token",
|
||||
"--signing-secret",
|
||||
"secret",
|
||||
"--app-token",
|
||||
"xapp-ignored",
|
||||
"--base-url",
|
||||
"https://example.test",
|
||||
]);
|
||||
|
||||
expect(options.connectionMode).toBe("webhook");
|
||||
expect(options.baseUrl).toBe("https://example.test");
|
||||
expect(options.signingSecret).toBe("secret");
|
||||
expect(options.appToken).toBeUndefined();
|
||||
});
|
||||
|
||||
it("uses socket mode when Slack args omit a base URL", () => {
|
||||
const previousBaseUrl = process.env.BASE_URL;
|
||||
delete process.env.BASE_URL;
|
||||
let options: ConnectSlackOptions;
|
||||
try {
|
||||
options = parseSlackArgs([
|
||||
"--bot-token",
|
||||
"xoxb-token",
|
||||
"--app-token",
|
||||
"xapp-token",
|
||||
]);
|
||||
} finally {
|
||||
if (previousBaseUrl === undefined) {
|
||||
delete process.env.BASE_URL;
|
||||
} else {
|
||||
process.env.BASE_URL = previousBaseUrl;
|
||||
}
|
||||
}
|
||||
|
||||
expect(options.connectionMode).toBe("socket");
|
||||
expect(options.baseUrl).toBeUndefined();
|
||||
expect(options.appToken).toBe("xapp-token");
|
||||
});
|
||||
|
||||
it("falls back to channel identity when a restarted connector gets a new thread id", () => {
|
||||
const result = __test__.findBindingForThread(
|
||||
{
|
||||
@@ -216,105 +157,6 @@ describe("slack binding lookup", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("normalizes top-level channel mentions to the original Slack post thread", () => {
|
||||
const original = new ThreadImpl({
|
||||
adapterName: "slack",
|
||||
channelId: "slack:C123",
|
||||
id: "slack:C123:",
|
||||
isDM: false,
|
||||
});
|
||||
const message = {
|
||||
raw: {
|
||||
channel: "C123",
|
||||
text: "<@U999> help",
|
||||
ts: "1710000000.123456",
|
||||
type: "app_mention",
|
||||
user: "U123",
|
||||
},
|
||||
} as Message;
|
||||
|
||||
const normalized = __test__.resolveSlackChannelMentionThread(
|
||||
original,
|
||||
message,
|
||||
);
|
||||
|
||||
expect(normalized.id).toBe("slack:C123:1710000000.123456");
|
||||
expect(normalized.channelId).toBe("slack:C123");
|
||||
expect(normalized.isDM).toBe(false);
|
||||
});
|
||||
|
||||
it("uses Slack thread_ts instead of reply ts for in-thread mentions", () => {
|
||||
const original = new ThreadImpl({
|
||||
adapterName: "slack",
|
||||
channelId: "slack:C123",
|
||||
id: "slack:C123:1710000001.654321",
|
||||
isDM: false,
|
||||
});
|
||||
const message = {
|
||||
raw: {
|
||||
channel: "C123",
|
||||
text: "<@U999> follow up",
|
||||
thread_ts: "1710000000.123456",
|
||||
ts: "1710000001.654321",
|
||||
type: "app_mention",
|
||||
user: "U123",
|
||||
},
|
||||
} as Message;
|
||||
|
||||
const normalized = __test__.resolveSlackChannelMentionThread(
|
||||
original,
|
||||
message,
|
||||
);
|
||||
|
||||
expect(normalized.id).toBe("slack:C123:1710000000.123456");
|
||||
expect(normalized.channelId).toBe("slack:C123");
|
||||
expect(normalized.isDM).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps Slack mention threads that already target the original post", () => {
|
||||
const original = new ThreadImpl({
|
||||
adapterName: "slack",
|
||||
channelId: "slack:C123",
|
||||
id: "slack:C123:1710000000.123456",
|
||||
isDM: false,
|
||||
});
|
||||
const message = {
|
||||
raw: {
|
||||
channel: "C123",
|
||||
text: "<@U999> help",
|
||||
ts: "1710000000.123456",
|
||||
type: "app_mention",
|
||||
user: "U123",
|
||||
},
|
||||
} as Message;
|
||||
|
||||
expect(__test__.resolveSlackChannelMentionThread(original, message)).toBe(
|
||||
original,
|
||||
);
|
||||
});
|
||||
|
||||
it("does not rewrite Slack DM mention threads", () => {
|
||||
const original = new ThreadImpl({
|
||||
adapterName: "slack",
|
||||
channelId: "slack:D123",
|
||||
id: "slack:D123:",
|
||||
isDM: true,
|
||||
});
|
||||
const message = {
|
||||
raw: {
|
||||
channel: "D123",
|
||||
text: "help",
|
||||
ts: "1710000000.123456",
|
||||
type: "message",
|
||||
user: "U123",
|
||||
},
|
||||
} as Message;
|
||||
|
||||
expect(__test__.resolveSlackChannelMentionThread(original, message)).toBe(
|
||||
original,
|
||||
);
|
||||
});
|
||||
|
||||
it("routes Slack posts through the installation bot token for a team", async () => {
|
||||
const calls: string[] = [];
|
||||
const result = await __test__.withSlackTeamBotToken({
|
||||
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
type Adapter,
|
||||
Chat,
|
||||
ConsoleLogger,
|
||||
type Message,
|
||||
type Thread,
|
||||
ThreadImpl,
|
||||
} from "chat";
|
||||
@@ -80,14 +79,6 @@ type SlackThreadState = ConnectorThreadState & {
|
||||
teamId?: string;
|
||||
};
|
||||
|
||||
type SlackConnectionMode = ConnectSlackOptions["connectionMode"];
|
||||
|
||||
function inferSlackConnectionMode(
|
||||
baseUrl: string | undefined,
|
||||
): SlackConnectionMode {
|
||||
return baseUrl?.trim() ? "webhook" : "socket";
|
||||
}
|
||||
|
||||
function truncateText(value: string, maxLength = 160): string {
|
||||
return truncateConnectorText(value, maxLength);
|
||||
}
|
||||
@@ -193,56 +184,6 @@ function extractSlackTeamId(raw: unknown): string | undefined {
|
||||
return value?.trim() || undefined;
|
||||
}
|
||||
|
||||
function extractSlackMessageRecord(
|
||||
raw: unknown,
|
||||
): Record<string, unknown> | undefined {
|
||||
const record = asRecord(raw);
|
||||
return asRecord(record?.event) ?? asRecord(record?.message) ?? record;
|
||||
}
|
||||
|
||||
function extractSlackChannelFromId(id: string): string | undefined {
|
||||
const parts = id.split(":");
|
||||
return parts[0] === "slack" ? readString(parts[1]) : undefined;
|
||||
}
|
||||
|
||||
function resolveSlackChannelMentionThread(
|
||||
thread: Thread<SlackThreadState>,
|
||||
message: Message,
|
||||
): Thread<SlackThreadState> {
|
||||
if (thread.isDM) {
|
||||
return thread;
|
||||
}
|
||||
const event = extractSlackMessageRecord(message.raw);
|
||||
const threadTs = readString(event?.thread_ts) ?? readString(event?.ts);
|
||||
if (!threadTs) {
|
||||
return thread;
|
||||
}
|
||||
const channel =
|
||||
readString(event?.channel) ??
|
||||
extractSlackChannelFromId(thread.id) ??
|
||||
extractSlackChannelFromId(thread.channelId);
|
||||
if (!channel) {
|
||||
return thread;
|
||||
}
|
||||
const threadId = `slack:${channel}:${threadTs}`;
|
||||
const channelId = `slack:${channel}`;
|
||||
if (thread.id === threadId && thread.channelId === channelId) {
|
||||
return thread;
|
||||
}
|
||||
return new ThreadImpl<SlackThreadState>({
|
||||
adapterName: "slack",
|
||||
channelId,
|
||||
channelVisibility: thread.channelVisibility,
|
||||
currentMessage: message,
|
||||
fallbackStreamingPlaceholderText: null,
|
||||
id: threadId,
|
||||
initialMessage: message,
|
||||
isDM: false,
|
||||
isSubscribedContext: false,
|
||||
streamingUpdateIntervalMs: 500,
|
||||
});
|
||||
}
|
||||
|
||||
async function withSlackBindingBotToken<T>(input: {
|
||||
slack: Pick<SlackAdapter, "getInstallation" | "withBotToken">;
|
||||
binding: ConnectorThreadBinding<SlackThreadState>;
|
||||
@@ -439,10 +380,7 @@ class SlackConnector extends ConnectorBase<
|
||||
SlackConnectorState
|
||||
> {
|
||||
constructor() {
|
||||
super(
|
||||
"slack",
|
||||
"Slack webhook/socket bridge backed by RPC runtime sessions",
|
||||
);
|
||||
super("slack", "Slack webhook bridge backed by RPC runtime sessions");
|
||||
}
|
||||
|
||||
protected override createCommand(): Command {
|
||||
@@ -455,7 +393,6 @@ class SlackConnector extends ConnectorBase<
|
||||
"Slack bot token for single-workspace mode",
|
||||
)
|
||||
.option("--signing-secret <secret>", "Slack signing secret")
|
||||
.option("--app-token <token>", "Slack app-level token for socket mode")
|
||||
.option("--client-id <id>", "Slack OAuth client id")
|
||||
.option("--client-secret <secret>", "Slack OAuth client secret")
|
||||
.option(
|
||||
@@ -496,7 +433,6 @@ class SlackConnector extends ConnectorBase<
|
||||
"Environment:",
|
||||
" SLACK_BOT_TOKEN Single-workspace bot token",
|
||||
" SLACK_SIGNING_SECRET Slack signing secret",
|
||||
" SLACK_APP_TOKEN App-level token for socket mode",
|
||||
" SLACK_CLIENT_ID OAuth client id",
|
||||
" SLACK_CLIENT_SECRET OAuth client secret",
|
||||
" SLACK_ENCRYPTION_KEY Optional installation encryption key",
|
||||
@@ -509,7 +445,6 @@ class SlackConnector extends ConnectorBase<
|
||||
userName?: string;
|
||||
botToken?: string;
|
||||
signingSecret?: string;
|
||||
appToken?: string;
|
||||
clientId?: string;
|
||||
clientSecret?: string;
|
||||
encryptionKey?: string;
|
||||
@@ -532,50 +467,17 @@ class SlackConnector extends ConnectorBase<
|
||||
this.parseOptionalInteger(opts.port, "port") ??
|
||||
Number.parseInt(process.env.PORT ?? "8787", 10);
|
||||
const port = Number.isFinite(parsedPort) ? parsedPort : 8787;
|
||||
const baseUrl = opts.baseUrl?.trim() || process.env.BASE_URL?.trim();
|
||||
const connectionMode = inferSlackConnectionMode(baseUrl);
|
||||
const isSocketMode = connectionMode === "socket";
|
||||
if (isSocketMode && (opts.clientId?.trim() || opts.clientSecret?.trim())) {
|
||||
throw new Error(
|
||||
"Slack socket mode does not support --client-id or --client-secret",
|
||||
);
|
||||
}
|
||||
const botToken =
|
||||
opts.botToken?.trim() || process.env.SLACK_BOT_TOKEN?.trim();
|
||||
const appToken = isSocketMode
|
||||
? opts.appToken?.trim() || process.env.SLACK_APP_TOKEN?.trim()
|
||||
: undefined;
|
||||
if (isSocketMode && !appToken) {
|
||||
throw new Error(
|
||||
"Slack socket mode requires --app-token or SLACK_APP_TOKEN",
|
||||
);
|
||||
}
|
||||
if (isSocketMode && !botToken) {
|
||||
throw new Error(
|
||||
"Slack socket mode requires --bot-token or SLACK_BOT_TOKEN",
|
||||
);
|
||||
}
|
||||
return {
|
||||
userName:
|
||||
opts.userName?.trim() ||
|
||||
process.env.SLACK_BOT_USERNAME?.trim() ||
|
||||
"cline-slack",
|
||||
connectionMode,
|
||||
botToken,
|
||||
botToken: opts.botToken?.trim() || process.env.SLACK_BOT_TOKEN?.trim(),
|
||||
signingSecret:
|
||||
connectionMode === "webhook"
|
||||
? opts.signingSecret?.trim() ||
|
||||
process.env.SLACK_SIGNING_SECRET?.trim()
|
||||
: opts.signingSecret?.trim(),
|
||||
appToken,
|
||||
clientId:
|
||||
connectionMode === "webhook"
|
||||
? opts.clientId?.trim() || process.env.SLACK_CLIENT_ID?.trim()
|
||||
: undefined,
|
||||
opts.signingSecret?.trim() || process.env.SLACK_SIGNING_SECRET?.trim(),
|
||||
clientId: opts.clientId?.trim() || process.env.SLACK_CLIENT_ID?.trim(),
|
||||
clientSecret:
|
||||
connectionMode === "webhook"
|
||||
? opts.clientSecret?.trim() || process.env.SLACK_CLIENT_SECRET?.trim()
|
||||
: undefined,
|
||||
opts.clientSecret?.trim() || process.env.SLACK_CLIENT_SECRET?.trim(),
|
||||
encryptionKey:
|
||||
opts.encryptionKey?.trim() || process.env.SLACK_ENCRYPTION_KEY?.trim(),
|
||||
installationKeyPrefix:
|
||||
@@ -598,7 +500,10 @@ class SlackConnector extends ConnectorBase<
|
||||
process.env.CLINE_CONNECT_HOOK_COMMAND?.trim(),
|
||||
port,
|
||||
host: opts.host?.trim() || process.env.HOST?.trim() || "0.0.0.0",
|
||||
baseUrl,
|
||||
baseUrl:
|
||||
opts.baseUrl?.trim() ||
|
||||
process.env.BASE_URL?.trim() ||
|
||||
`http://127.0.0.1:${port}`,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -694,11 +599,9 @@ class SlackConnector extends ConnectorBase<
|
||||
readState: (path) => this.readConnectorState(path),
|
||||
isRunning: (state) => isProcessRunning(state.pid),
|
||||
formatAlreadyRunningMessage: (state) =>
|
||||
state.connectionMode === "socket"
|
||||
? `[slack] connector already running pid=${state.pid} rpc=${state.rpcAddress} mode=socket`
|
||||
: `[slack] connector already running pid=${state.pid} rpc=${state.rpcAddress} url=${state.baseUrl}`,
|
||||
`[slack] connector already running pid=${state.pid} rpc=${state.rpcAddress} url=${state.baseUrl}`,
|
||||
formatBackgroundStartMessage: (pid) =>
|
||||
`[slack] starting background connector pid=${pid} user=${options.userName} mode=${options.connectionMode}`,
|
||||
`[slack] starting background connector pid=${pid} user=${options.userName}`,
|
||||
foregroundHint:
|
||||
"[slack] use `cline connect slack -i ...` to run in the foreground",
|
||||
launchFailureMessage: "failed to launch Slack connector in background",
|
||||
@@ -715,7 +618,6 @@ class SlackConnector extends ConnectorBase<
|
||||
const consoleLogger = new ConsoleLogger("info", "slack-connect");
|
||||
const slackConfig: Record<string, unknown> = {
|
||||
logger: consoleLogger,
|
||||
mode: options.connectionMode,
|
||||
userName: options.userName,
|
||||
};
|
||||
if (options.botToken?.trim()) {
|
||||
@@ -724,9 +626,6 @@ class SlackConnector extends ConnectorBase<
|
||||
if (options.signingSecret?.trim()) {
|
||||
slackConfig.signingSecret = options.signingSecret.trim();
|
||||
}
|
||||
if (options.appToken?.trim()) {
|
||||
slackConfig.appToken = options.appToken.trim();
|
||||
}
|
||||
if (options.clientId?.trim()) {
|
||||
slackConfig.clientId = options.clientId.trim();
|
||||
}
|
||||
@@ -795,12 +694,10 @@ class SlackConnector extends ConnectorBase<
|
||||
await client.connect();
|
||||
this.writeConnectorState(statePath, {
|
||||
userName: options.userName,
|
||||
connectionMode: options.connectionMode,
|
||||
pid: process.pid,
|
||||
rpcAddress,
|
||||
...(options.connectionMode === "webhook"
|
||||
? { port: options.port, baseUrl: options.baseUrl }
|
||||
: {}),
|
||||
port: options.port,
|
||||
baseUrl: options.baseUrl,
|
||||
startedAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
@@ -945,10 +842,9 @@ class SlackConnector extends ConnectorBase<
|
||||
};
|
||||
|
||||
bot.onNewMention(async (thread, message) => {
|
||||
const mentionThread = resolveSlackChannelMentionThread(thread, message);
|
||||
await mentionThread.subscribe();
|
||||
await thread.subscribe();
|
||||
await persistSlackThreadContext({
|
||||
thread: mentionThread,
|
||||
thread,
|
||||
bindingsPath,
|
||||
baseStartRequest: startRequest,
|
||||
rawMessage: message.raw,
|
||||
@@ -956,7 +852,7 @@ class SlackConnector extends ConnectorBase<
|
||||
});
|
||||
if (
|
||||
await maybeHandleConnectorApprovalReply({
|
||||
thread: mentionThread,
|
||||
thread,
|
||||
text: message.text,
|
||||
client,
|
||||
clientId,
|
||||
@@ -966,7 +862,7 @@ class SlackConnector extends ConnectorBase<
|
||||
) {
|
||||
return;
|
||||
}
|
||||
await handleTurn(mentionThread, message.text);
|
||||
await handleTurn(thread, message.text);
|
||||
});
|
||||
|
||||
bot.onSubscribedMessage(async (thread, message) => {
|
||||
@@ -1052,64 +948,48 @@ class SlackConnector extends ConnectorBase<
|
||||
},
|
||||
});
|
||||
|
||||
let webhookUrl: string | undefined;
|
||||
let oauthCallbackUrl: string | undefined;
|
||||
const server =
|
||||
options.connectionMode === "webhook"
|
||||
? await (async () => {
|
||||
const baseUrl = options.baseUrl?.trim();
|
||||
if (!baseUrl) {
|
||||
throw new Error(
|
||||
"Slack webhook mode requires --base-url or BASE_URL",
|
||||
);
|
||||
}
|
||||
webhookUrl = `${baseUrl.replace(/\/$/, "")}/api/webhooks/slack`;
|
||||
oauthCallbackUrl = `${baseUrl.replace(/\/$/, "")}/api/oauth/slack/callback`;
|
||||
return startConnectorWebhookServer({
|
||||
host: options.host,
|
||||
port: options.port,
|
||||
routes: {
|
||||
"/api/webhooks/slack": async (request) =>
|
||||
bot.webhooks.slack(request),
|
||||
"/api/oauth/slack/callback": async (request) => {
|
||||
try {
|
||||
const result = await slack.handleOAuthCallback(request);
|
||||
return new Response(
|
||||
`Slack installation stored for team ${result.teamId}. You can return to Slack.`,
|
||||
);
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
loggerAdapter.core.log("Slack OAuth callback failed", {
|
||||
severity: "warn",
|
||||
transport: "slack",
|
||||
error: message,
|
||||
});
|
||||
return new Response(`Slack OAuth error: ${message}`, {
|
||||
status: 500,
|
||||
});
|
||||
}
|
||||
},
|
||||
"/health": () => new Response("ok"),
|
||||
"/": () =>
|
||||
new Response(
|
||||
[
|
||||
"Slack connector is running.",
|
||||
"Connection mode: webhook",
|
||||
`Webhook URL: ${webhookUrl}`,
|
||||
`OAuth callback URL: ${oauthCallbackUrl}`,
|
||||
options.botToken?.trim()
|
||||
? "Auth mode: single workspace"
|
||||
: options.clientId?.trim() &&
|
||||
options.clientSecret?.trim()
|
||||
? "Auth mode: multi-workspace OAuth"
|
||||
: "Auth mode: incomplete (set bot token or OAuth credentials)",
|
||||
].join("\n"),
|
||||
),
|
||||
},
|
||||
const webhookUrl = `${options.baseUrl.replace(/\/$/, "")}/api/webhooks/slack`;
|
||||
const oauthCallbackUrl = `${options.baseUrl.replace(/\/$/, "")}/api/oauth/slack/callback`;
|
||||
const server = await startConnectorWebhookServer({
|
||||
host: options.host,
|
||||
port: options.port,
|
||||
routes: {
|
||||
"/api/webhooks/slack": async (request) => bot.webhooks.slack(request),
|
||||
"/api/oauth/slack/callback": async (request) => {
|
||||
try {
|
||||
const result = await slack.handleOAuthCallback(request);
|
||||
return new Response(
|
||||
`Slack installation stored for team ${result.teamId}. You can return to Slack.`,
|
||||
);
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
loggerAdapter.core.log("Slack OAuth callback failed", {
|
||||
severity: "warn",
|
||||
transport: "slack",
|
||||
error: message,
|
||||
});
|
||||
})()
|
||||
: undefined;
|
||||
return new Response(`Slack OAuth error: ${message}`, {
|
||||
status: 500,
|
||||
});
|
||||
}
|
||||
},
|
||||
"/health": () => new Response("ok"),
|
||||
"/": () =>
|
||||
new Response(
|
||||
[
|
||||
"Slack connector is running.",
|
||||
`Webhook URL: ${webhookUrl}`,
|
||||
`OAuth callback URL: ${oauthCallbackUrl}`,
|
||||
options.botToken?.trim()
|
||||
? "Auth mode: single workspace"
|
||||
: options.clientId?.trim() && options.clientSecret?.trim()
|
||||
? "Auth mode: multi-workspace OAuth"
|
||||
: "Auth mode: incomplete (set bot token or OAuth credentials)",
|
||||
].join("\n"),
|
||||
),
|
||||
},
|
||||
});
|
||||
|
||||
const stopEventStream = client.streamEvents(
|
||||
{ clientId: `${clientId}-server-events` },
|
||||
@@ -1172,22 +1052,17 @@ class SlackConnector extends ConnectorBase<
|
||||
process.once("SIGINT", () => requestStop("sigint"));
|
||||
process.once("SIGTERM", () => requestStop("sigterm"));
|
||||
|
||||
if (options.connectionMode === "webhook") {
|
||||
io.writeln(`[slack] listening on ${options.host}:${options.port}`);
|
||||
io.writeln(`[slack] configure Slack webhook URL: ${webhookUrl}`);
|
||||
io.writeln(
|
||||
`[slack] configure Slack OAuth callback URL: ${oauthCallbackUrl}`,
|
||||
);
|
||||
} else {
|
||||
io.writeln("[slack] socket mode connected");
|
||||
}
|
||||
io.writeln(`[slack] listening on ${options.host}:${options.port}`);
|
||||
io.writeln(`[slack] configure Slack webhook URL: ${webhookUrl}`);
|
||||
io.writeln(
|
||||
`[slack] configure Slack OAuth callback URL: ${oauthCallbackUrl}`,
|
||||
);
|
||||
|
||||
await stopPromise;
|
||||
clearBindingSessionIds<SlackThreadState>(bindingsPath);
|
||||
stopTaskUpdateStream();
|
||||
stopEventStream();
|
||||
await server?.close();
|
||||
await bot.shutdown();
|
||||
await server.close();
|
||||
userInstructionService.stop();
|
||||
client.close();
|
||||
this.removeStateFile(statePath);
|
||||
@@ -1198,11 +1073,9 @@ class SlackConnector extends ConnectorBase<
|
||||
export const slackConnector: ConnectCommandDefinition = new SlackConnector();
|
||||
|
||||
export const __test__ = {
|
||||
inferSlackConnectionMode,
|
||||
buildSlackParticipantKey,
|
||||
resolveSlackParticipant,
|
||||
normalizeSlackMessageEventChannelType,
|
||||
resolveSlackChannelMentionThread,
|
||||
withSlackTeamBotToken,
|
||||
isSlackInvalidThreadTsError,
|
||||
findBindingForThread: (
|
||||
|
||||
@@ -76,15 +76,7 @@ cline connect telegram -k "$TELEGRAM_BOT_TOKEN" --no-tools
|
||||
|
||||
When the connector starts with `--no-tools`, chat commands such as `/tools on` and `/yolo on` cannot re-enable tools for that connector run.
|
||||
|
||||
For participant restrictions, run the interactive connector wizard with `cline connect`. The Telegram wizard asks whether to restrict access, points you to `@userinfobot`, and configures your numeric Telegram user ID.
|
||||
|
||||
You can also pass the user ID directly:
|
||||
|
||||
```bash
|
||||
cline connect telegram -k "$TELEGRAM_BOT_TOKEN" --allowed-user-id 12345
|
||||
```
|
||||
|
||||
You can also pass a manual `--hook-command` that returns `{"action":"deny"}` for unauthorized `session.authorize` events. If neither access option is configured, messages are allowed.
|
||||
For participant restrictions, run the interactive connector wizard with `cline connect` or pass a `--hook-command` that returns `{"action":"deny"}` for unauthorized `session.authorize` events. If no hook is configured, messages are allowed.
|
||||
|
||||
## Message Delivery
|
||||
|
||||
|
||||
@@ -62,72 +62,6 @@ describe("telegramConnector", () => {
|
||||
expect(options.enableTools).toBe(true);
|
||||
});
|
||||
|
||||
it("builds an authorization hook from --allowed-user-id", () => {
|
||||
const options = parseTelegramArgs([
|
||||
"--bot-token",
|
||||
"123:test",
|
||||
"--cwd",
|
||||
"/tmp/work",
|
||||
"--allowed-user-id",
|
||||
"1201547643",
|
||||
]);
|
||||
|
||||
expect(options.hookCommand).toBe(
|
||||
`jq -r ".payload.actor.participantKey" | grep -qx "telegram:id:1201547643" && echo '{"action":"allow"}' || echo '{"action":"deny","message":"unauthorized","reason":"not_on_allowlist"}'`,
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects unsafe --allowed-user-id values", () => {
|
||||
expect(() =>
|
||||
parseTelegramArgs([
|
||||
"--bot-token",
|
||||
"123:test",
|
||||
"--cwd",
|
||||
"/tmp/work",
|
||||
"--allowed-user-id",
|
||||
"123; rm -rf /",
|
||||
]),
|
||||
).toThrow("digits only");
|
||||
});
|
||||
|
||||
it("rejects mixing --allowed-user-id with --hook-command", () => {
|
||||
expect(() =>
|
||||
parseTelegramArgs([
|
||||
"--bot-token",
|
||||
"123:test",
|
||||
"--cwd",
|
||||
"/tmp/work",
|
||||
"--allowed-user-id",
|
||||
"1201547643",
|
||||
"--hook-command",
|
||||
"echo noop",
|
||||
]),
|
||||
).toThrow("either --allowed-user-id or --hook-command");
|
||||
});
|
||||
|
||||
it("rejects mixing --allowed-user-id with the hook command env var", () => {
|
||||
const originalHookCommand = process.env.CLINE_CONNECT_HOOK_COMMAND;
|
||||
process.env.CLINE_CONNECT_HOOK_COMMAND = "echo noop";
|
||||
try {
|
||||
expect(() =>
|
||||
parseTelegramArgs([
|
||||
"--bot-token",
|
||||
"123:test",
|
||||
"--cwd",
|
||||
"/tmp/work",
|
||||
"--allowed-user-id",
|
||||
"1201547643",
|
||||
]),
|
||||
).toThrow("either --allowed-user-id or --hook-command");
|
||||
} finally {
|
||||
if (originalHookCommand === undefined) {
|
||||
delete process.env.CLINE_CONNECT_HOOK_COMMAND;
|
||||
} else {
|
||||
process.env.CLINE_CONNECT_HOOK_COMMAND = originalHookCommand;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("does not require the bot username", () => {
|
||||
const options = parseTelegramArgs([
|
||||
"--bot-token",
|
||||
|
||||
@@ -89,20 +89,6 @@ function readTelegramBotId(botToken: string): string | undefined {
|
||||
return /^\d+$/.test(botId) ? botId : undefined;
|
||||
}
|
||||
|
||||
function normalizeAllowedTelegramUserId(value: string): string {
|
||||
const userId = value.trim();
|
||||
if (!/^\d+$/.test(userId)) {
|
||||
throw new Error(
|
||||
"connect telegram --allowed-user-id must contain digits only",
|
||||
);
|
||||
}
|
||||
return userId;
|
||||
}
|
||||
|
||||
function buildTelegramAllowedUserHookCommand(userId: string): string {
|
||||
return `jq -r ".payload.actor.participantKey" | grep -qx "telegram:id:${userId}" && echo '{"action":"allow"}' || echo '{"action":"deny","message":"unauthorized","reason":"not_on_allowlist"}'`;
|
||||
}
|
||||
|
||||
function describeTelegramGetMeFailure(
|
||||
response: Response,
|
||||
body: string,
|
||||
@@ -432,10 +418,6 @@ class TelegramConnector extends ConnectorBase<
|
||||
.option("--mode <act|plan>", "Agent mode", "act")
|
||||
.option("-i, --interactive", "Keep connector in foreground")
|
||||
.option("--no-tools", "Disable tools for Telegram sessions")
|
||||
.option(
|
||||
"--allowed-user-id <id>",
|
||||
"Only allow this Telegram user ID to use the bot",
|
||||
)
|
||||
.option(
|
||||
"--hook-command <command>",
|
||||
"Run a shell command for connector events",
|
||||
@@ -452,7 +434,6 @@ class TelegramConnector extends ConnectorBase<
|
||||
"Notes:",
|
||||
" - Without -i, the connector is launched in the background.",
|
||||
" - Tools are enabled by default for Telegram sessions.",
|
||||
" - Use --allowed-user-id or `cline connect` to restrict Telegram access.",
|
||||
" - Bot username is discovered from the Telegram bot token when omitted.",
|
||||
" - Provider/model default to the CLI's last-used provider settings.",
|
||||
].join("\n"),
|
||||
@@ -473,7 +454,6 @@ class TelegramConnector extends ConnectorBase<
|
||||
tools?: boolean;
|
||||
rpcAddress?: string;
|
||||
hookCommand?: string;
|
||||
allowedUserId?: string;
|
||||
}>();
|
||||
const botUsername =
|
||||
normalizeTelegramBotUsername(opts.botUsername ?? "") ||
|
||||
@@ -485,15 +465,6 @@ class TelegramConnector extends ConnectorBase<
|
||||
if (!botToken) {
|
||||
throw new Error("connect telegram requires -k/--bot-token <token>");
|
||||
}
|
||||
const hookCommand =
|
||||
opts.hookCommand?.trim() ||
|
||||
process.env.CLINE_CONNECT_HOOK_COMMAND?.trim();
|
||||
const allowedUserId = opts.allowedUserId?.trim();
|
||||
if (hookCommand && allowedUserId) {
|
||||
throw new Error(
|
||||
"connect telegram accepts either --allowed-user-id or --hook-command, not both",
|
||||
);
|
||||
}
|
||||
return {
|
||||
botToken,
|
||||
...(botUsername ? { botUsername } : {}),
|
||||
@@ -509,11 +480,9 @@ class TelegramConnector extends ConnectorBase<
|
||||
opts.rpcAddress?.trim() ||
|
||||
process.env.CLINE_RPC_ADDRESS?.trim() ||
|
||||
resolveDefaultCliRpcAddress(),
|
||||
hookCommand: allowedUserId
|
||||
? buildTelegramAllowedUserHookCommand(
|
||||
normalizeAllowedTelegramUserId(allowedUserId),
|
||||
)
|
||||
: hookCommand,
|
||||
hookCommand:
|
||||
opts.hookCommand?.trim() ||
|
||||
process.env.CLINE_CONNECT_HOOK_COMMAND?.trim(),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ export const CONNECTOR_CATALOG: ConnectorCatalogEntry[] = [
|
||||
},
|
||||
{
|
||||
name: "slack",
|
||||
description: "Slack webhook/socket bridge backed by RPC runtime sessions",
|
||||
description: "Slack webhook bridge backed by RPC runtime sessions",
|
||||
},
|
||||
{
|
||||
name: "telegram",
|
||||
|
||||
@@ -26,7 +26,6 @@ export type ActiveConnectorRecord = {
|
||||
phoneNumberId?: string;
|
||||
port?: number;
|
||||
baseUrl?: string;
|
||||
connectionMode?: string;
|
||||
};
|
||||
|
||||
function listConnectorStatePaths(
|
||||
@@ -69,8 +68,6 @@ const connectorFieldExtractors: Record<
|
||||
startedAt: (p) => (typeof p.startedAt === "string" ? p.startedAt : undefined),
|
||||
port: (p) => (typeof p.port === "number" ? p.port : undefined),
|
||||
baseUrl: (p) => (typeof p.baseUrl === "string" ? p.baseUrl : undefined),
|
||||
connectionMode: (p) =>
|
||||
typeof p.connectionMode === "string" ? p.connectionMode : undefined,
|
||||
userName: (p) => (typeof p.userName === "string" ? p.userName : undefined),
|
||||
botUsername: (p) =>
|
||||
typeof p.botUsername === "string" ? p.botUsername : undefined,
|
||||
@@ -94,10 +91,7 @@ const connectorConfigs: Record<
|
||||
required: ["userName"],
|
||||
optional: ["startedAt", "port", "baseUrl"],
|
||||
},
|
||||
slack: {
|
||||
required: ["userName"],
|
||||
optional: ["startedAt", "connectionMode", "port", "baseUrl"],
|
||||
},
|
||||
slack: { required: ["userName"], optional: ["startedAt", "port", "baseUrl"] },
|
||||
whatsapp: {
|
||||
required: ["userName"],
|
||||
optional: ["startedAt", "phoneNumberId", "port", "baseUrl"],
|
||||
|
||||
@@ -284,30 +284,6 @@ export async function runCli(): Promise<void> {
|
||||
io,
|
||||
});
|
||||
});
|
||||
const pluginUninstallCmd = pluginCmd
|
||||
.command("uninstall")
|
||||
.alias("remove")
|
||||
.alias("rm")
|
||||
.description("Uninstall a Cline Plugin by name or path")
|
||||
.argument("<name>", "plugin package name, installed slug, or plugin path")
|
||||
.option("--json", "Output as JSON")
|
||||
.option(
|
||||
"--cwd <path>",
|
||||
"Search <path>/.cline/plugins before global plugins",
|
||||
)
|
||||
.action(async (name: string) => {
|
||||
const opts = pluginUninstallCmd.opts<{
|
||||
json?: boolean;
|
||||
cwd?: string;
|
||||
}>();
|
||||
const { runPluginUninstallCommand } = await import("./commands/plugin");
|
||||
ctx.exitCode = await runPluginUninstallCommand({
|
||||
name,
|
||||
cwd: opts.cwd,
|
||||
json: opts.json === true || program.opts().json === true,
|
||||
io,
|
||||
});
|
||||
});
|
||||
const connectCmd = program
|
||||
.command("connect")
|
||||
.description("Connect to an external channel")
|
||||
|
||||
@@ -455,112 +455,6 @@ Find installable skills.`,
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("deletes a package-backed plugin and refreshes bundled slash commands", async () => {
|
||||
const tempRoot = await mkdtemp(join(tmpdir(), "cli-config-data-"));
|
||||
tempRoots.push(tempRoot);
|
||||
process.env.CLINE_GLOBAL_SETTINGS_PATH = join(
|
||||
tempRoot,
|
||||
"global-settings.json",
|
||||
);
|
||||
const packageDir = join(tempRoot, ".cline", "plugins", "delete-plugin");
|
||||
const pluginPath = join(packageDir, "index.ts");
|
||||
const skillPath = join(packageDir, "skills", "erase", "SKILL.md");
|
||||
await mkdir(join(packageDir, "skills", "erase"), { recursive: true });
|
||||
await writeFile(
|
||||
join(packageDir, "package.json"),
|
||||
JSON.stringify(
|
||||
{
|
||||
name: "delete-plugin",
|
||||
cline: {
|
||||
plugins: [{ paths: ["./index.ts"] }],
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
await writeFile(pluginPath, "export default {};\n");
|
||||
await writeFile(
|
||||
skillPath,
|
||||
`---
|
||||
name: erase
|
||||
---
|
||||
Erase stale plugin commands.`,
|
||||
);
|
||||
await writeFile(
|
||||
process.env.CLINE_GLOBAL_SETTINGS_PATH,
|
||||
JSON.stringify({ disabledPlugins: [pluginPath] }, null, 2),
|
||||
);
|
||||
const refreshCalls: string[] = [];
|
||||
let refreshed = false;
|
||||
const userInstructionService = {
|
||||
async refreshType(type: string) {
|
||||
refreshCalls.push(type);
|
||||
refreshed = true;
|
||||
},
|
||||
listRuntimeCommands() {
|
||||
return refreshed
|
||||
? []
|
||||
: [
|
||||
{
|
||||
name: "erase",
|
||||
instructions: "Erase stale plugin commands.",
|
||||
description: "Erase",
|
||||
kind: "skill",
|
||||
},
|
||||
];
|
||||
},
|
||||
listRecords(type: string) {
|
||||
if (type !== "skill") {
|
||||
return [];
|
||||
}
|
||||
return [
|
||||
{
|
||||
id: "erase",
|
||||
type: "skill",
|
||||
filePath: skillPath,
|
||||
item: {
|
||||
name: "erase",
|
||||
disabled: false,
|
||||
description: "Erase",
|
||||
instructions: "Erase stale plugin commands.",
|
||||
frontmatter: {},
|
||||
},
|
||||
},
|
||||
];
|
||||
},
|
||||
} as unknown as UserInstructionConfigService;
|
||||
const loader = createInteractiveConfigDataLoader({
|
||||
config: createConfig(tempRoot),
|
||||
userInstructionService,
|
||||
});
|
||||
const data = await loader.loadConfigData({ includePluginTools: false });
|
||||
const plugin = data.plugins.find((item) => item.path === pluginPath);
|
||||
if (!plugin) {
|
||||
throw new Error("Expected package plugin to be listed");
|
||||
}
|
||||
|
||||
const nextData = await loader.onDeleteConfigItem(plugin, {
|
||||
includePluginTools: false,
|
||||
});
|
||||
const settings = JSON.parse(
|
||||
await readFile(process.env.CLINE_GLOBAL_SETTINGS_PATH, "utf8"),
|
||||
) as { disabledPlugins?: string[] };
|
||||
|
||||
await expect(readFile(pluginPath, "utf8")).rejects.toThrow();
|
||||
await expect(readFile(skillPath, "utf8")).rejects.toThrow();
|
||||
expect(settings.disabledPlugins).toBeUndefined();
|
||||
expect(refreshCalls).toEqual(
|
||||
expect.arrayContaining(["workflow", "rule", "skill"]),
|
||||
);
|
||||
expect(nextData?.plugins.some((item) => item.path === pluginPath)).toBe(
|
||||
false,
|
||||
);
|
||||
expect(
|
||||
nextData?.workflowSlashCommands.map((command) => command.name),
|
||||
).not.toContain("erase");
|
||||
});
|
||||
|
||||
it("uses the package name for package-backed plugin entries", async () => {
|
||||
const tempRoot = await mkdtemp(join(tmpdir(), "cli-config-data-"));
|
||||
tempRoots.push(tempRoot);
|
||||
|
||||
@@ -3,7 +3,6 @@ import {
|
||||
setDisabledPlugin,
|
||||
setDisabledTools,
|
||||
type UserInstructionConfigService,
|
||||
uninstallPlugin,
|
||||
} from "@cline/core";
|
||||
import {
|
||||
type InteractiveConfigData,
|
||||
@@ -37,18 +36,6 @@ export function createInteractiveConfigDataLoader(input: {
|
||||
includePluginTools: options.includePluginTools,
|
||||
});
|
||||
|
||||
const refreshUserInstructionConfigs = async (): Promise<void> => {
|
||||
const service = input.userInstructionService;
|
||||
if (!service) {
|
||||
return;
|
||||
}
|
||||
await Promise.all([
|
||||
service.refreshType("workflow"),
|
||||
service.refreshType("rule"),
|
||||
service.refreshType("skill"),
|
||||
]);
|
||||
};
|
||||
|
||||
const onToggleConfigItem = async (
|
||||
item: InteractiveConfigItem,
|
||||
options: LoadInteractiveConfigDataOptions = {},
|
||||
@@ -119,26 +106,8 @@ export function createInteractiveConfigDataLoader(input: {
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const onDeleteConfigItem = async (
|
||||
item: InteractiveConfigItem,
|
||||
options: LoadInteractiveConfigDataOptions = {},
|
||||
): Promise<InteractiveConfigData | undefined> => {
|
||||
if (item.kind !== "plugin") {
|
||||
return undefined;
|
||||
}
|
||||
await uninstallPlugin({
|
||||
path: item.path,
|
||||
name: item.name,
|
||||
cwd: input.config.cwd,
|
||||
workspaceRoot: workspaceRoot(),
|
||||
});
|
||||
await refreshUserInstructionConfigs();
|
||||
return await loadConfigData(options);
|
||||
};
|
||||
|
||||
return {
|
||||
loadConfigData,
|
||||
onToggleConfigItem,
|
||||
onDeleteConfigItem,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -5,8 +5,7 @@ import type {
|
||||
ToolApprovalRequest,
|
||||
ToolApprovalResult,
|
||||
} from "@cline/core";
|
||||
import { SessionNotFoundError } from "@cline/core";
|
||||
import type { AgentTool, Message } from "@cline/shared";
|
||||
import type { AgentTool } from "@cline/shared";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { ChatCommandState } from "../../utils/chat-commands";
|
||||
import type { Config } from "../../utils/types";
|
||||
@@ -113,7 +112,7 @@ function makeManager() {
|
||||
abort: vi.fn(),
|
||||
dispose: vi.fn(),
|
||||
get: vi.fn(),
|
||||
readMessages: vi.fn(async (): Promise<Message[]> => []),
|
||||
readMessages: vi.fn(async () => []),
|
||||
readTranscript: vi.fn(),
|
||||
ingestHookEvent: vi.fn(),
|
||||
subscribe: vi.fn(),
|
||||
@@ -125,31 +124,6 @@ function makeManager() {
|
||||
};
|
||||
}
|
||||
|
||||
function makeTurnResult() {
|
||||
return {
|
||||
text: "ok",
|
||||
usage: { inputTokens: 0, outputTokens: 0 },
|
||||
messages: [],
|
||||
toolCalls: [],
|
||||
iterations: 1,
|
||||
finishReason: "completed" as const,
|
||||
model: { id: "openai/gpt-5.3-codex", provider: "cline" },
|
||||
startedAt: new Date("2026-01-01T00:00:00.000Z"),
|
||||
endedAt: new Date("2026-01-01T00:00:00.100Z"),
|
||||
durationMs: 100,
|
||||
};
|
||||
}
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (error?: unknown) => void;
|
||||
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
|
||||
resolve = resolvePromise;
|
||||
reject = rejectPromise;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
function makeRuntime(
|
||||
manager: ReturnType<typeof makeManager>,
|
||||
options: { resumeSessionId?: string } = {},
|
||||
@@ -257,84 +231,4 @@ describe("createInteractiveSessionRuntime", () => {
|
||||
expect(manager.start).toHaveBeenCalledTimes(2);
|
||||
expect(runtime.getActiveSessionId()).toBe("session-2");
|
||||
});
|
||||
|
||||
it("recovers and retries when the active interactive session disappeared", async () => {
|
||||
const manager = makeManager();
|
||||
const messages = [
|
||||
{
|
||||
role: "user" as const,
|
||||
content: [{ type: "text" as const, text: "hi" }],
|
||||
},
|
||||
];
|
||||
manager.readMessages.mockResolvedValue(messages);
|
||||
manager.send
|
||||
.mockRejectedValueOnce(new SessionNotFoundError("session-1"))
|
||||
.mockResolvedValueOnce(makeTurnResult());
|
||||
const runtime = makeRuntime(manager);
|
||||
|
||||
await runtime.ensureReady();
|
||||
const result = await runtime.sendCurrentTurn({
|
||||
prompt: "second hi",
|
||||
mode: "act",
|
||||
});
|
||||
|
||||
expect(result?.finishReason).toBe("completed");
|
||||
expect(manager.readMessages).toHaveBeenCalledWith("session-1");
|
||||
expect(manager.start).toHaveBeenCalledTimes(2);
|
||||
expect(manager.start).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.objectContaining({
|
||||
initialMessages: messages,
|
||||
}),
|
||||
);
|
||||
expect(manager.send).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expect.objectContaining({ sessionId: "session-1" }),
|
||||
);
|
||||
expect(manager.send).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.objectContaining({ sessionId: "session-2" }),
|
||||
);
|
||||
expect(runtime.getActiveSessionId()).toBe("session-2");
|
||||
});
|
||||
|
||||
it("waits for missing-session recovery before cleanup disposes the manager", async () => {
|
||||
const manager = makeManager();
|
||||
const recoveryRead = deferred<Message[]>();
|
||||
manager.readMessages
|
||||
.mockImplementationOnce(() => recoveryRead.promise)
|
||||
.mockResolvedValue([]);
|
||||
manager.get.mockResolvedValue(undefined);
|
||||
manager.getAccumulatedUsage.mockResolvedValue(undefined);
|
||||
manager.send.mockRejectedValueOnce(new SessionNotFoundError("session-1"));
|
||||
const runtime = makeRuntime(manager);
|
||||
|
||||
await runtime.ensureReady();
|
||||
const sendPromise = runtime
|
||||
.sendCurrentTurn({
|
||||
prompt: "second hi",
|
||||
mode: "act",
|
||||
})
|
||||
.catch((error) => error);
|
||||
await vi.waitFor(() => {
|
||||
expect(manager.readMessages).toHaveBeenCalledWith("session-1");
|
||||
});
|
||||
|
||||
let cleanupSettled = false;
|
||||
const cleanupPromise = runtime.cleanup().finally(() => {
|
||||
cleanupSettled = true;
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
expect(cleanupSettled).toBe(false);
|
||||
expect(manager.get).not.toHaveBeenCalled();
|
||||
expect(manager.dispose).not.toHaveBeenCalled();
|
||||
|
||||
recoveryRead.resolve([]);
|
||||
await cleanupPromise;
|
||||
const sendError = await sendPromise;
|
||||
|
||||
expect(sendError).toBeInstanceOf(SessionNotFoundError);
|
||||
expect(manager.dispose).toHaveBeenCalledWith("cli_interactive_shutdown");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import {
|
||||
type AgentEvent,
|
||||
type CheckpointEntry,
|
||||
isSessionNotFoundError,
|
||||
type PendingPromptMutationResult,
|
||||
type ProviderSettingsManager,
|
||||
readSessionCheckpointHistory,
|
||||
@@ -75,7 +74,6 @@ export function createInteractiveSessionRuntime(input: {
|
||||
let shutdownRequested = false;
|
||||
let activeSessionId = "";
|
||||
let abortRequested = false;
|
||||
let missingSessionRecoveryPromise: Promise<void> | undefined;
|
||||
// A reset can happen while an earlier manager.start() is still in flight.
|
||||
// Bump this before resets and restarts so stale starts cannot become active.
|
||||
let sessionStartGeneration = 0;
|
||||
@@ -250,37 +248,6 @@ export function createInteractiveSessionRuntime(input: {
|
||||
return (await sessionManager.readMessages(activeSessionId)) ?? [];
|
||||
};
|
||||
|
||||
const recoverMissingActiveSession = async (error: unknown): Promise<void> => {
|
||||
if (missingSessionRecoveryPromise) {
|
||||
return await missingSessionRecoveryPromise;
|
||||
}
|
||||
missingSessionRecoveryPromise = (async () => {
|
||||
const manager = sessionManager;
|
||||
const missingSessionId = activeSessionId;
|
||||
if (!manager || !missingSessionId || shutdownRequested) {
|
||||
return;
|
||||
}
|
||||
const messages = await manager
|
||||
.readMessages(missingSessionId)
|
||||
.catch(() => []);
|
||||
input.config.logger?.log("Recovering missing interactive session", {
|
||||
sessionId: missingSessionId,
|
||||
messageCount: messages.length,
|
||||
error,
|
||||
severity: "warn",
|
||||
});
|
||||
sessionStartGeneration += 1;
|
||||
pendingResumeSessionId = undefined;
|
||||
startupPromise = undefined;
|
||||
startupError = undefined;
|
||||
clearActiveSession();
|
||||
await startFreshSession(messages);
|
||||
})().finally(() => {
|
||||
missingSessionRecoveryPromise = undefined;
|
||||
});
|
||||
return await missingSessionRecoveryPromise;
|
||||
};
|
||||
|
||||
const stopCurrentSession = async (): Promise<void> => {
|
||||
const sessionId = activeSessionId;
|
||||
if (sessionManager && sessionId) {
|
||||
@@ -367,29 +334,10 @@ export function createInteractiveSessionRuntime(input: {
|
||||
? startupError
|
||||
: new Error("interactive session manager is unavailable");
|
||||
}
|
||||
const manager = sessionManager;
|
||||
try {
|
||||
return await manager.send({
|
||||
sessionId: activeSessionId,
|
||||
...turnInput,
|
||||
});
|
||||
} catch (error) {
|
||||
if (
|
||||
abortRequested ||
|
||||
shutdownRequested ||
|
||||
!isSessionNotFoundError(error)
|
||||
) {
|
||||
throw error;
|
||||
}
|
||||
await recoverMissingActiveSession(error);
|
||||
if (!activeSessionId || abortRequested || shutdownRequested) {
|
||||
throw error;
|
||||
}
|
||||
return await manager.send({
|
||||
sessionId: activeSessionId,
|
||||
...turnInput,
|
||||
});
|
||||
}
|
||||
return await sessionManager.send({
|
||||
sessionId: activeSessionId,
|
||||
...turnInput,
|
||||
});
|
||||
};
|
||||
|
||||
const updatePendingPrompt = async (input: {
|
||||
@@ -602,20 +550,20 @@ export function createInteractiveSessionRuntime(input: {
|
||||
let exitSummary: InteractiveExitSummary | undefined;
|
||||
try {
|
||||
await startupPromise?.catch(() => {});
|
||||
await missingSessionRecoveryPromise?.catch(() => {});
|
||||
} finally {
|
||||
unsubscribeAgent();
|
||||
unsubscribePendingPrompts();
|
||||
}
|
||||
try {
|
||||
exitSummary = await getExitSummary();
|
||||
// Mark hooks shut down before session disposal so late abort/stop
|
||||
// emissions cannot dispatch over a closing hub transport.
|
||||
await runtimeHooks?.shutdown();
|
||||
await stopCurrentSession();
|
||||
} finally {
|
||||
if (sessionManager) {
|
||||
await sessionManager.dispose("cli_interactive_shutdown");
|
||||
try {
|
||||
if (sessionManager) {
|
||||
await sessionManager.dispose("cli_interactive_shutdown");
|
||||
}
|
||||
} finally {
|
||||
await runtimeHooks?.shutdown();
|
||||
}
|
||||
}
|
||||
return exitSummary;
|
||||
|
||||
@@ -228,11 +228,11 @@ export async function runAgent(
|
||||
process.off("SIGINT", handleSigint);
|
||||
process.off("SIGTERM", handleSigterm);
|
||||
unsubscribe();
|
||||
await runtimeHooks.shutdown().catch(() => {});
|
||||
if (activeSessionId) {
|
||||
await sessionManager.stop(activeSessionId).catch(() => {});
|
||||
}
|
||||
await sessionManager.dispose("cli_run_shutdown").catch(() => {});
|
||||
await runtimeHooks.shutdown().catch(() => {});
|
||||
setActiveRuntimeAbort(undefined);
|
||||
})();
|
||||
return cleanupDone;
|
||||
|
||||
@@ -322,18 +322,6 @@ export async function runInteractive(
|
||||
}
|
||||
return data;
|
||||
};
|
||||
const onDeleteConfigItem = async (
|
||||
item: InteractiveConfigItem,
|
||||
options: LoadInteractiveConfigDataOptions = {},
|
||||
): Promise<
|
||||
Awaited<ReturnType<typeof configDataLoader.onDeleteConfigItem>>
|
||||
> => {
|
||||
const data = await configDataLoader.onDeleteConfigItem(item, options);
|
||||
if (data && shouldRefreshInteractiveSessionForConfigItem(item)) {
|
||||
await refreshInteractiveSessionPolicies();
|
||||
}
|
||||
return data;
|
||||
};
|
||||
const toQueuedPromptItem = (prompt: {
|
||||
id: string;
|
||||
prompt: string;
|
||||
@@ -409,7 +397,6 @@ export async function runInteractive(
|
||||
}),
|
||||
loadConfigData: configDataLoader.loadConfigData,
|
||||
onToggleConfigItem,
|
||||
onDeleteConfigItem,
|
||||
subscribeToEvents: ({
|
||||
onAgentEvent: onAgent,
|
||||
onTeamEvent: onTeam,
|
||||
|
||||
@@ -137,54 +137,3 @@ export function ExtDetailContent(
|
||||
</box>
|
||||
);
|
||||
}
|
||||
|
||||
export function DeleteConfigItemConfirmContent(
|
||||
props: ChoiceContext<boolean> & {
|
||||
item: InteractiveConfigItem;
|
||||
},
|
||||
) {
|
||||
useDialogKeyboard((key) => {
|
||||
if (key.name === "return" || key.name === "y") {
|
||||
props.resolve(true);
|
||||
} else if (key.name === "escape" || key.name === "n") {
|
||||
props.dismiss();
|
||||
}
|
||||
}, props.dialogId);
|
||||
|
||||
return (
|
||||
<box flexDirection="column" paddingX={1}>
|
||||
<text>Delete plugin {props.item.name}?</text>
|
||||
<text fg="gray" marginTop={1}>
|
||||
This removes the installed plugin files from {props.item.path}.
|
||||
</text>
|
||||
<text fg="gray" marginTop={1}>
|
||||
<em>Y/Enter to confirm, N/Esc to cancel</em>
|
||||
</text>
|
||||
</box>
|
||||
);
|
||||
}
|
||||
|
||||
export function ConfigErrorContent(
|
||||
props: ChoiceContext<void> & {
|
||||
title: string;
|
||||
message: string;
|
||||
},
|
||||
) {
|
||||
useDialogKeyboard((key) => {
|
||||
if (key.name === "return" || key.name === "escape") {
|
||||
props.dismiss();
|
||||
}
|
||||
}, props.dialogId);
|
||||
|
||||
return (
|
||||
<box flexDirection="column" paddingX={1}>
|
||||
<text fg="red">{props.title}</text>
|
||||
<text fg="gray" marginTop={1}>
|
||||
{props.message}
|
||||
</text>
|
||||
<text fg="gray" marginTop={1}>
|
||||
<em>Enter/Esc to close</em>
|
||||
</text>
|
||||
</box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,11 +9,7 @@ import type {
|
||||
LoadInteractiveConfigDataOptions,
|
||||
} from "../../tui/interactive-config";
|
||||
import type { CliCompactionMode, Config } from "../../utils/types";
|
||||
import {
|
||||
ConfigErrorContent,
|
||||
DeleteConfigItemConfirmContent,
|
||||
ExtDetailContent,
|
||||
} from "../components/dialogs/config-dialogs";
|
||||
import { ExtDetailContent } from "../components/dialogs/config-dialogs";
|
||||
import { withLoadingDialog } from "../components/dialogs/loading-dialog";
|
||||
import { ConfigPanelContent } from "../views/config-view";
|
||||
import type { ConfigAction } from "../views/config-view-helpers";
|
||||
@@ -39,10 +35,6 @@ export function useConfigPanel(opts: {
|
||||
item: InteractiveConfigItem,
|
||||
options?: LoadInteractiveConfigDataOptions,
|
||||
) => Promise<InteractiveConfigData | undefined>;
|
||||
onDeleteConfigItem?: (
|
||||
item: InteractiveConfigItem,
|
||||
options?: LoadInteractiveConfigDataOptions,
|
||||
) => Promise<InteractiveConfigData | undefined>;
|
||||
openModelSelector: (options?: OpenModelSelectorOptions) => Promise<void>;
|
||||
openMcpManager: (options?: { refocus?: boolean }) => Promise<boolean>;
|
||||
refocusTextarea: () => void;
|
||||
@@ -98,7 +90,6 @@ export function useConfigPanel(opts: {
|
||||
activeTab = tab;
|
||||
}}
|
||||
onToggleConfigItem={opts.onToggleConfigItem}
|
||||
onDeleteConfigItem={opts.onDeleteConfigItem}
|
||||
onToggleMode={opts.toggleMode}
|
||||
onToggleAutoApprove={opts.toggleAutoApprove}
|
||||
onSetCompactionMode={opts.setCompactionMode}
|
||||
@@ -120,38 +111,6 @@ export function useConfigPanel(opts: {
|
||||
await opts.openModelSelector({ onCancel: () => {} });
|
||||
} else if (action.kind === "toggle-item") {
|
||||
await opts.onToggleConfigItem?.(action.item);
|
||||
} else if (action.kind === "delete-item") {
|
||||
const confirmed = await opts.dialog.choice<boolean>({
|
||||
closeOnEscape: true,
|
||||
content: (ctx: ChoiceContext<boolean>) => (
|
||||
<DeleteConfigItemConfirmContent {...ctx} item={action.item} />
|
||||
),
|
||||
});
|
||||
if (confirmed && opts.onDeleteConfigItem) {
|
||||
try {
|
||||
await withLoadingDialog(
|
||||
opts.dialog,
|
||||
`Deleting ${action.item.name}...`,
|
||||
async () =>
|
||||
await opts.onDeleteConfigItem?.(action.item, {
|
||||
includePluginTools: false,
|
||||
}),
|
||||
);
|
||||
} catch (error) {
|
||||
await opts.dialog.choice<void>({
|
||||
closeOnEscape: true,
|
||||
content: (ctx: ChoiceContext<void>) => (
|
||||
<ConfigErrorContent
|
||||
{...ctx}
|
||||
title="Plugin delete failed"
|
||||
message={
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}
|
||||
/>
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
} else if (action.kind === "ext-detail") {
|
||||
await opts.dialog.choice<void>({
|
||||
style: { maxHeight: opts.termHeight - 2 },
|
||||
|
||||
@@ -234,8 +234,6 @@ export function useRootKeyboard(input: {
|
||||
const abortStarted = input.onAbort();
|
||||
if (abortStarted) {
|
||||
session.setAbortRequested(true);
|
||||
session.setIsStreaming(false);
|
||||
session.closeInlineStream();
|
||||
}
|
||||
} else if (selectedQueuedPromptId) {
|
||||
queuedSelection.select(null);
|
||||
|
||||
@@ -207,19 +207,6 @@ function App(props: TuiProps) {
|
||||
return data;
|
||||
};
|
||||
}, [propsOnToggleConfigItem]);
|
||||
const propsOnDeleteConfigItem = props.onDeleteConfigItem;
|
||||
const onDeleteConfigItem = useMemo<TuiProps["onDeleteConfigItem"]>(() => {
|
||||
if (!propsOnDeleteConfigItem) {
|
||||
return undefined;
|
||||
}
|
||||
return async (item, options) => {
|
||||
const data = await propsOnDeleteConfigItem(item, options);
|
||||
if (data) {
|
||||
setWorkflowSlashCommands(data.workflowSlashCommands);
|
||||
}
|
||||
return data;
|
||||
};
|
||||
}, [propsOnDeleteConfigItem]);
|
||||
|
||||
const openConfig = useConfigPanel({
|
||||
dialog,
|
||||
@@ -232,7 +219,6 @@ function App(props: TuiProps) {
|
||||
termHeight,
|
||||
loadConfigData: props.loadConfigData,
|
||||
onToggleConfigItem,
|
||||
onDeleteConfigItem,
|
||||
openModelSelector,
|
||||
openMcpManager,
|
||||
refocusTextarea: () => refocusTextareaRef.current(),
|
||||
|
||||
@@ -137,10 +137,6 @@ export interface TuiProps {
|
||||
item: InteractiveConfigItem,
|
||||
options?: LoadInteractiveConfigDataOptions,
|
||||
) => Promise<InteractiveConfigData | undefined>;
|
||||
onDeleteConfigItem?: (
|
||||
item: InteractiveConfigItem,
|
||||
options?: LoadInteractiveConfigDataOptions,
|
||||
) => Promise<InteractiveConfigData | undefined>;
|
||||
subscribeToEvents: (handlers: {
|
||||
onAgentEvent: (event: AgentEvent) => void;
|
||||
onTeamEvent: (event: TeamEvent) => void;
|
||||
|
||||
@@ -9,7 +9,6 @@ export type ConfigAction =
|
||||
| { kind: "open-provider" }
|
||||
| { kind: "open-model" }
|
||||
| { kind: "toggle-item"; item: InteractiveConfigItem }
|
||||
| { kind: "delete-item"; item: InteractiveConfigItem }
|
||||
| {
|
||||
kind: "ext-detail";
|
||||
item: InteractiveConfigItem;
|
||||
@@ -131,10 +130,6 @@ export function isToggleableConfigItem(item: InteractiveConfigItem): boolean {
|
||||
return isToggleableInteractiveConfigItem(item);
|
||||
}
|
||||
|
||||
export function isDeletableConfigItem(item: InteractiveConfigItem): boolean {
|
||||
return item.kind === "plugin";
|
||||
}
|
||||
|
||||
export function resolveConfigItemSelectAction(
|
||||
item: InteractiveConfigItem,
|
||||
): ConfigAction {
|
||||
@@ -161,15 +156,6 @@ export function resolveConfigItemToggleAction(
|
||||
return { kind: "toggle-item", item };
|
||||
}
|
||||
|
||||
export function resolveConfigItemDeleteAction(
|
||||
item: InteractiveConfigItem,
|
||||
): ConfigAction | undefined {
|
||||
if (!isDeletableConfigItem(item)) {
|
||||
return undefined;
|
||||
}
|
||||
return { kind: "delete-item", item };
|
||||
}
|
||||
|
||||
export function isInlineConfigAction(
|
||||
action: ConfigAction | undefined,
|
||||
): boolean {
|
||||
@@ -197,33 +183,14 @@ export function canToggleConfigFooterRow(
|
||||
);
|
||||
}
|
||||
|
||||
export function canDeleteConfigFooterRow(
|
||||
row:
|
||||
| { kind: "ext"; item: InteractiveConfigItem }
|
||||
| { kind: string }
|
||||
| undefined,
|
||||
): boolean {
|
||||
return (
|
||||
row?.kind === "ext" && "item" in row && isDeletableConfigItem(row.item)
|
||||
);
|
||||
}
|
||||
|
||||
export function getConfigFooterText({
|
||||
canToggle = false,
|
||||
canDelete = false,
|
||||
}: {
|
||||
canToggle?: boolean;
|
||||
canDelete?: boolean;
|
||||
} = {}): string {
|
||||
const actions = ["←/→ switch tabs", "↑/↓ navigate", "Tab/Enter select"];
|
||||
if (canToggle) {
|
||||
actions.push("Space toggle");
|
||||
}
|
||||
if (canDelete) {
|
||||
actions.push("D delete");
|
||||
}
|
||||
actions.push("Esc close");
|
||||
return actions.join(", ");
|
||||
return canToggle
|
||||
? "←/→ switch tabs, ↑/↓ navigate, Tab/Enter select, Space toggle, Esc close"
|
||||
: "←/→ switch tabs, ↑/↓ navigate, Tab/Enter select, Esc close";
|
||||
}
|
||||
|
||||
export function getConfigItemDisplayName(name: string): string {
|
||||
|
||||
@@ -18,7 +18,6 @@ import { resolveModelDisplayName } from "../components/status-bar";
|
||||
import { getModeAccent, palette } from "../palette";
|
||||
import {
|
||||
type ConfigAction,
|
||||
canDeleteConfigFooterRow,
|
||||
canToggleConfigFooterRow,
|
||||
getAdjacentConfigTab,
|
||||
getConfigFooterText,
|
||||
@@ -27,7 +26,6 @@ import {
|
||||
isInlineConfigAction,
|
||||
isToggleableConfigItem,
|
||||
resolveActiveConfigItems,
|
||||
resolveConfigItemDeleteAction,
|
||||
resolveConfigItemSelectAction,
|
||||
resolveConfigItemToggleAction,
|
||||
resolveInitialConfigTab,
|
||||
@@ -135,10 +133,6 @@ export interface ConfigPanelProps extends ChoiceContext<ConfigAction> {
|
||||
item: InteractiveConfigItem,
|
||||
options?: LoadInteractiveConfigDataOptions,
|
||||
) => Promise<InteractiveConfigData | undefined>;
|
||||
onDeleteConfigItem?: (
|
||||
item: InteractiveConfigItem,
|
||||
options?: LoadInteractiveConfigDataOptions,
|
||||
) => Promise<InteractiveConfigData | undefined>;
|
||||
onToggleMode: () => void;
|
||||
onToggleAutoApprove: () => void;
|
||||
onSetCompactionMode: (mode: CliCompactionMode) => void;
|
||||
@@ -524,9 +518,6 @@ export function ConfigPanelContent(props: ConfigPanelProps) {
|
||||
const selectedRowIdx = navIndices[clampedNavPos] ?? 0;
|
||||
const selectedRow = rows[selectedRowIdx];
|
||||
const canToggleSelectedRow = canToggleConfigFooterRow(selectedRow);
|
||||
const canDeleteSelectedRow = Boolean(
|
||||
props.onDeleteConfigItem && canDeleteConfigFooterRow(selectedRow),
|
||||
);
|
||||
|
||||
const setNavPosition = (nextNavPos: number) => {
|
||||
setNavPos(nextNavPos);
|
||||
@@ -627,20 +618,6 @@ export function ConfigPanelContent(props: ConfigPanelProps) {
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteSelected = () => {
|
||||
if (!props.onDeleteConfigItem) {
|
||||
return;
|
||||
}
|
||||
const row = rows[selectedRowIdx];
|
||||
if (!row || row.kind !== "ext") {
|
||||
return;
|
||||
}
|
||||
const action = resolveConfigItemDeleteAction(row.item);
|
||||
if (action) {
|
||||
resolve(action);
|
||||
}
|
||||
};
|
||||
|
||||
useDialogKeyboard((key) => {
|
||||
if (key.name === "escape") {
|
||||
dismiss();
|
||||
@@ -671,16 +648,6 @@ export function ConfigPanelContent(props: ConfigPanelProps) {
|
||||
handleToggleSelected();
|
||||
return;
|
||||
}
|
||||
if (
|
||||
key.name === "d" &&
|
||||
!key.ctrl &&
|
||||
!key.meta &&
|
||||
!key.option &&
|
||||
!key.shift
|
||||
) {
|
||||
handleDeleteSelected();
|
||||
return;
|
||||
}
|
||||
if (key.name === "return" || key.name === "tab") {
|
||||
handleSelect();
|
||||
}
|
||||
@@ -877,10 +844,7 @@ export function ConfigPanelContent(props: ConfigPanelProps) {
|
||||
<em>
|
||||
{togglingItemId
|
||||
? "Applying settings"
|
||||
: getConfigFooterText({
|
||||
canToggle: canToggleSelectedRow,
|
||||
canDelete: canDeleteSelectedRow,
|
||||
})}
|
||||
: getConfigFooterText({ canToggle: canToggleSelectedRow })}
|
||||
</em>
|
||||
</text>
|
||||
</box>
|
||||
|
||||
@@ -215,21 +215,4 @@ describe("createRuntimeHooks", () => {
|
||||
expect(outputMocks.write).toHaveBeenCalledWith("\n[hook:prompt_submit]\n");
|
||||
expect(eventMocks.closeInlineStreamIfNeeded).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("does not dispatch hooks after shutdown", async () => {
|
||||
const dispatchHookEvent = vi.fn().mockResolvedValue(undefined);
|
||||
const runtimeHooks = createRuntimeHooks({
|
||||
yolo: false,
|
||||
cwd: "/workspace",
|
||||
workspaceRoot: "/workspace",
|
||||
verbose: true,
|
||||
dispatchHookEvent,
|
||||
});
|
||||
|
||||
await runtimeHooks.shutdown();
|
||||
await emitRunStartAndPrompt(runtimeHooks.hooks!);
|
||||
|
||||
expect(dispatchHookEvent).not.toHaveBeenCalled();
|
||||
expect(outputMocks.write).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -138,23 +138,13 @@ async function dispatchHookPayload(
|
||||
payload: HookEventPayload,
|
||||
options: {
|
||||
dispatchHookEvent: (payload: HookEventPayload) => Promise<void>;
|
||||
isShuttingDown: () => boolean;
|
||||
verbose: boolean;
|
||||
},
|
||||
): Promise<void> {
|
||||
if (options.isShuttingDown()) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await options.dispatchHookEvent(payload);
|
||||
if (options.isShuttingDown()) {
|
||||
return;
|
||||
}
|
||||
writeHookInvocation(payload, { verbose: options.verbose });
|
||||
} catch (error) {
|
||||
if (options.isShuttingDown()) {
|
||||
return;
|
||||
}
|
||||
if (isDev) {
|
||||
writeErr(
|
||||
`hook dispatch failed: ${error instanceof Error ? error.message : String(error)}`,
|
||||
@@ -182,8 +172,6 @@ export function createRuntimeHooks(options: {
|
||||
const verbose = options.verbose === true;
|
||||
const cwd = options.cwd?.trim() || process.cwd();
|
||||
const workspaceRoot = options.workspaceRoot?.trim() || cwd;
|
||||
let shuttingDown = false;
|
||||
const isShuttingDown = () => shuttingDown;
|
||||
return {
|
||||
hooks: {
|
||||
beforeRun: async (ctx) => {
|
||||
@@ -209,7 +197,6 @@ export function createRuntimeHooks(options: {
|
||||
},
|
||||
{
|
||||
dispatchHookEvent: options.dispatchHookEvent,
|
||||
isShuttingDown,
|
||||
verbose,
|
||||
},
|
||||
);
|
||||
@@ -236,7 +223,6 @@ export function createRuntimeHooks(options: {
|
||||
},
|
||||
{
|
||||
dispatchHookEvent: options.dispatchHookEvent,
|
||||
isShuttingDown,
|
||||
verbose,
|
||||
},
|
||||
);
|
||||
@@ -275,7 +261,6 @@ export function createRuntimeHooks(options: {
|
||||
},
|
||||
{
|
||||
dispatchHookEvent: options.dispatchHookEvent,
|
||||
isShuttingDown,
|
||||
verbose,
|
||||
},
|
||||
);
|
||||
@@ -294,7 +279,6 @@ export function createRuntimeHooks(options: {
|
||||
},
|
||||
{
|
||||
dispatchHookEvent: options.dispatchHookEvent,
|
||||
isShuttingDown,
|
||||
verbose,
|
||||
},
|
||||
);
|
||||
@@ -322,7 +306,6 @@ export function createRuntimeHooks(options: {
|
||||
},
|
||||
{
|
||||
dispatchHookEvent: options.dispatchHookEvent,
|
||||
isShuttingDown,
|
||||
verbose,
|
||||
},
|
||||
);
|
||||
@@ -345,14 +328,11 @@ export function createRuntimeHooks(options: {
|
||||
},
|
||||
{
|
||||
dispatchHookEvent: options.dispatchHookEvent,
|
||||
isShuttingDown,
|
||||
verbose,
|
||||
},
|
||||
);
|
||||
},
|
||||
},
|
||||
shutdown: async () => {
|
||||
shuttingDown = true;
|
||||
},
|
||||
shutdown: async () => {},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,11 +1,6 @@
|
||||
import * as p from "@clack/prompts";
|
||||
import { runConnectAdapter } from "../../commands/connect";
|
||||
import {
|
||||
PLATFORMS,
|
||||
type PlatformDef,
|
||||
type SecurityDef,
|
||||
shouldIncludeField,
|
||||
} from "./platforms";
|
||||
import { PLATFORMS, type PlatformDef, type SecurityDef } from "./platforms";
|
||||
|
||||
function isCancel(value: unknown): value is symbol {
|
||||
return p.isCancel(value);
|
||||
@@ -15,7 +10,6 @@ const SENSITIVE_FLAGS = new Set([
|
||||
"-k",
|
||||
"--access-token",
|
||||
"--api-key",
|
||||
"--app-token",
|
||||
"--app-secret",
|
||||
"--bot-token",
|
||||
"--credentials-json",
|
||||
@@ -39,40 +33,28 @@ function redactCommandArgs(args: string[]): string {
|
||||
|
||||
async function collectFields(platform: PlatformDef): Promise<string[] | null> {
|
||||
const args: string[] = [];
|
||||
const values: Record<string, string> = {};
|
||||
|
||||
for (const field of platform.fields) {
|
||||
if (!shouldIncludeField(field, values)) {
|
||||
continue;
|
||||
}
|
||||
if (field.help) {
|
||||
for (const line of field.help) {
|
||||
p.log.info(line);
|
||||
}
|
||||
}
|
||||
|
||||
const value = field.options
|
||||
? await p.select({
|
||||
message: field.label,
|
||||
options: field.options,
|
||||
initialValue: field.initialValue,
|
||||
})
|
||||
: await p.text({
|
||||
message: field.label,
|
||||
placeholder: field.placeholder,
|
||||
defaultValue: field.initialValue,
|
||||
validate: field.required
|
||||
? (v) => {
|
||||
if (!v?.trim()) return `${field.label} is required`;
|
||||
return undefined;
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
const value = await p.text({
|
||||
message: field.label,
|
||||
placeholder: field.placeholder,
|
||||
validate: field.required
|
||||
? (v) => {
|
||||
if (!v?.trim()) return `${field.label} is required`;
|
||||
return undefined;
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
|
||||
if (isCancel(value)) return null;
|
||||
|
||||
const trimmed = (value as string).trim();
|
||||
values[field.flag] = trimmed;
|
||||
if (trimmed) {
|
||||
args.push(field.flag, trimmed);
|
||||
}
|
||||
@@ -121,9 +103,9 @@ async function collectSecurity(
|
||||
values[field.key] = (value as string).trim();
|
||||
}
|
||||
|
||||
const args = security.buildArgs(values);
|
||||
const hookCmd = security.buildHookCommand(values);
|
||||
p.log.success("Access restriction enabled");
|
||||
return args;
|
||||
return ["--hook-command", hookCmd];
|
||||
}
|
||||
|
||||
export async function runConnectWizard(): Promise<number> {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { PLATFORMS, shouldIncludeField } from "./platforms";
|
||||
import { PLATFORMS } from "./platforms";
|
||||
|
||||
describe("connect wizard platform security fields", () => {
|
||||
it("does not ask Telegram users to re-enter the bot username", () => {
|
||||
@@ -30,52 +30,4 @@ describe("connect wizard platform security fields", () => {
|
||||
expect(slackUser?.validate?.("U01ABC123")).toBeUndefined();
|
||||
expect(slackUser?.validate?.("U01$(bad)")).toContain("Slack member");
|
||||
});
|
||||
|
||||
it("uses the Telegram allowed user ID flag for wizard security", () => {
|
||||
const telegram = PLATFORMS.find((platform) => platform.id === "telegram");
|
||||
|
||||
const args = telegram?.security?.buildArgs({
|
||||
userId: "123456",
|
||||
});
|
||||
|
||||
expect(args).toEqual(["--allowed-user-id", "123456"]);
|
||||
});
|
||||
|
||||
it("builds an exact-match Slack authorization hook", () => {
|
||||
const slack = PLATFORMS.find((platform) => platform.id === "slack");
|
||||
|
||||
const args = slack?.security?.buildArgs({
|
||||
teamId: "T01ABC123",
|
||||
userId: "U01ABC123",
|
||||
});
|
||||
|
||||
expect(args).toEqual([
|
||||
"--hook-command",
|
||||
`jq -r ".payload.actor.participantKey" | grep -qx "slack:team:T01ABC123:user:U01ABC123" && echo '{"action":"allow"}' || echo '{"action":"deny"}'`,
|
||||
]);
|
||||
});
|
||||
|
||||
it("asks Slack users for mode-specific setup fields", () => {
|
||||
const slack = PLATFORMS.find((platform) => platform.id === "slack");
|
||||
const fields = slack?.fields ?? [];
|
||||
const webhookValues = { "--base-url": "https://example.test" };
|
||||
const socketValues = { "--base-url": "" };
|
||||
|
||||
expect(fields.map((field) => field.flag)).toEqual([
|
||||
"--bot-token",
|
||||
"--base-url",
|
||||
"--signing-secret",
|
||||
"--app-token",
|
||||
]);
|
||||
expect(
|
||||
fields
|
||||
.filter((field) => shouldIncludeField(field, webhookValues))
|
||||
.map((field) => field.flag),
|
||||
).toEqual(["--bot-token", "--base-url", "--signing-secret"]);
|
||||
expect(
|
||||
fields
|
||||
.filter((field) => shouldIncludeField(field, socketValues))
|
||||
.map((field) => field.flag),
|
||||
).toEqual(["--bot-token", "--base-url", "--app-token"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
export interface PlatformDef {
|
||||
id: string;
|
||||
name: string;
|
||||
type: "polling" | "webhook" | "hybrid";
|
||||
type: "polling" | "webhook";
|
||||
hint: string;
|
||||
fields: FieldDef[];
|
||||
security?: SecurityDef;
|
||||
@@ -13,17 +13,8 @@ export interface FieldDef {
|
||||
placeholder?: string;
|
||||
required?: boolean;
|
||||
help?: string[];
|
||||
initialValue?: string;
|
||||
options?: Array<{ value: string; label: string; hint?: string }>;
|
||||
includeWhen?: FieldCondition;
|
||||
}
|
||||
|
||||
export type FieldCondition = {
|
||||
flag: string;
|
||||
equals?: string;
|
||||
notEquals?: string;
|
||||
};
|
||||
|
||||
export interface SecurityFieldDef {
|
||||
key: string;
|
||||
label: string;
|
||||
@@ -36,25 +27,7 @@ export interface SecurityFieldDef {
|
||||
export interface SecurityDef {
|
||||
prompt: string;
|
||||
fields: SecurityFieldDef[];
|
||||
buildArgs: (values: Record<string, string>) => string[];
|
||||
}
|
||||
|
||||
export function shouldIncludeField(
|
||||
field: FieldDef,
|
||||
values: Record<string, string>,
|
||||
): boolean {
|
||||
const condition = field.includeWhen;
|
||||
if (!condition) {
|
||||
return true;
|
||||
}
|
||||
const value = values[condition.flag] ?? "";
|
||||
if (condition.equals !== undefined && value !== condition.equals) {
|
||||
return false;
|
||||
}
|
||||
if (condition.notEquals !== undefined && value === condition.notEquals) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
buildHookCommand: (values: Record<string, string>) => string;
|
||||
}
|
||||
|
||||
function validateTelegramUserId(value: string): string | undefined {
|
||||
@@ -111,14 +84,15 @@ export const PLATFORMS: PlatformDef[] = [
|
||||
validate: validateTelegramUserId,
|
||||
},
|
||||
],
|
||||
buildArgs: ({ userId }) => ["--allowed-user-id", userId ?? ""],
|
||||
buildHookCommand: ({ userId }) =>
|
||||
`jq -r ".payload.actor.participantKey" | grep -q "telegram:id:${userId}" && echo '{"action":"allow"}' || echo '{"action":"deny"}'`,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "slack",
|
||||
name: "Slack",
|
||||
type: "hybrid",
|
||||
hint: "Public URL for webhook mode; leave blank for socket mode.",
|
||||
type: "webhook",
|
||||
hint: "Requires a Slack app and public URL.",
|
||||
fields: [
|
||||
{
|
||||
flag: "--bot-token",
|
||||
@@ -131,32 +105,21 @@ export const PLATFORMS: PlatformDef[] = [
|
||||
"Install to workspace and copy the Bot Token",
|
||||
],
|
||||
},
|
||||
{
|
||||
flag: "--base-url",
|
||||
label: "Public base URL",
|
||||
placeholder: "leave blank for socket mode",
|
||||
help: [
|
||||
"Enter a publicly accessible URL for webhook mode",
|
||||
"Leave blank to use Slack socket mode instead",
|
||||
],
|
||||
},
|
||||
{
|
||||
flag: "--signing-secret",
|
||||
label: "Signing secret",
|
||||
required: true,
|
||||
help: ["Found in your app's Basic Information page"],
|
||||
includeWhen: { flag: "--base-url", notEquals: "" },
|
||||
},
|
||||
{
|
||||
flag: "--app-token",
|
||||
label: "App-level token",
|
||||
placeholder: "xapp-...",
|
||||
flag: "--base-url",
|
||||
label: "Public base URL",
|
||||
placeholder: "https://example.com",
|
||||
required: true,
|
||||
help: [
|
||||
"Enable Socket Mode in the Slack app",
|
||||
"Generate an app-level token with the connections:write scope",
|
||||
"Your publicly accessible URL for webhook callbacks",
|
||||
"Use ngrok or similar for local development",
|
||||
],
|
||||
includeWhen: { flag: "--base-url", equals: "" },
|
||||
},
|
||||
],
|
||||
security: {
|
||||
@@ -185,10 +148,8 @@ export const PLATFORMS: PlatformDef[] = [
|
||||
validate: validateSlackUserId,
|
||||
},
|
||||
],
|
||||
buildArgs: ({ teamId, userId }) => [
|
||||
"--hook-command",
|
||||
`jq -r ".payload.actor.participantKey" | grep -qx "slack:team:${teamId}:user:${userId}" && echo '{"action":"allow"}' || echo '{"action":"deny"}'`,
|
||||
],
|
||||
buildHookCommand: ({ teamId, userId }) =>
|
||||
`jq -r ".payload.actor.participantKey" | grep -q "slack:team:${teamId}:user:${userId}" && echo '{"action":"allow"}' || echo '{"action":"deny"}'`,
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -2,10 +2,7 @@ import { spawn } from "node:child_process";
|
||||
import process from "node:process";
|
||||
import { listConnectorCatalog } from "../../../cli/src/connectors/catalog";
|
||||
import { listActiveConnectors } from "../../../cli/src/connectors/status";
|
||||
import {
|
||||
PLATFORMS,
|
||||
shouldIncludeField,
|
||||
} from "../../../cli/src/wizards/connect/platforms";
|
||||
import { PLATFORMS } from "../../../cli/src/wizards/connect/platforms";
|
||||
import type {
|
||||
WebviewConnectorChannel,
|
||||
WebviewConnectorChannelsResponse,
|
||||
@@ -30,9 +27,6 @@ export function connectorChannelsPayload(): WebviewConnectorChannelsResponse {
|
||||
placeholder: field.placeholder,
|
||||
required: field.required,
|
||||
help: field.help,
|
||||
initialValue: field.initialValue,
|
||||
options: field.options,
|
||||
includeWhen: field.includeWhen,
|
||||
})),
|
||||
security: platform.security
|
||||
? {
|
||||
@@ -110,21 +104,9 @@ function buildConnectorStartArgs(args?: Record<string, unknown>): string[] {
|
||||
throw new Error(`connector channel is not available: ${channel}`);
|
||||
}
|
||||
const values = asRecord(args?.values) ?? {};
|
||||
const fieldValues: Record<string, string> = {};
|
||||
for (const field of platform.fields) {
|
||||
const rawValue = values[field.flag];
|
||||
if (typeof rawValue === "string") {
|
||||
fieldValues[field.flag] = rawValue.trim();
|
||||
} else if (field.initialValue) {
|
||||
fieldValues[field.flag] = field.initialValue;
|
||||
}
|
||||
}
|
||||
const cliArgs = [channel];
|
||||
for (const field of platform.fields) {
|
||||
if (!shouldIncludeField(field, fieldValues)) {
|
||||
continue;
|
||||
}
|
||||
const value = fieldValues[field.flag];
|
||||
const value = asString(values[field.flag]);
|
||||
if (!value) {
|
||||
if (field.required) throw new Error(`${field.label} is required`);
|
||||
continue;
|
||||
@@ -142,7 +124,10 @@ function buildConnectorStartArgs(args?: Record<string, unknown>): string[] {
|
||||
if (validationError) throw new Error(validationError);
|
||||
hookValues[field.key] = value;
|
||||
}
|
||||
cliArgs.push(...platform.security.buildArgs(hookValues));
|
||||
cliArgs.push(
|
||||
"--hook-command",
|
||||
platform.security.buildHookCommand(hookValues),
|
||||
);
|
||||
}
|
||||
return cliArgs;
|
||||
}
|
||||
|
||||
@@ -134,13 +134,6 @@ export type WebviewConnectorField = {
|
||||
placeholder?: string;
|
||||
required?: boolean;
|
||||
help?: string[];
|
||||
initialValue?: string;
|
||||
options?: Array<{ value: string; label: string; hint?: string }>;
|
||||
includeWhen?: {
|
||||
flag: string;
|
||||
equals?: string;
|
||||
notEquals?: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type WebviewConnectorSecurityField = {
|
||||
@@ -154,7 +147,7 @@ export type WebviewConnectorSecurityField = {
|
||||
export type WebviewConnectorChannel = {
|
||||
id: string;
|
||||
name: string;
|
||||
type: "polling" | "webhook" | "hybrid";
|
||||
type: "polling" | "webhook";
|
||||
hint: string;
|
||||
fields: WebviewConnectorField[];
|
||||
security?: {
|
||||
@@ -175,7 +168,6 @@ export type WebviewActiveConnector = {
|
||||
phoneNumberId?: string;
|
||||
port?: number;
|
||||
baseUrl?: string;
|
||||
connectionMode?: string;
|
||||
};
|
||||
|
||||
export type WebviewConnectorChannelsResponse = {
|
||||
|
||||
@@ -42,13 +42,6 @@ type ConnectorField = {
|
||||
placeholder?: string;
|
||||
required?: boolean;
|
||||
help?: string[];
|
||||
initialValue?: string;
|
||||
options?: Array<{ value: string; label: string; hint?: string }>;
|
||||
includeWhen?: {
|
||||
flag: string;
|
||||
equals?: string;
|
||||
notEquals?: string;
|
||||
};
|
||||
};
|
||||
|
||||
type ConnectorSecurityField = {
|
||||
@@ -62,7 +55,7 @@ type ConnectorSecurityField = {
|
||||
type ConnectorChannel = {
|
||||
id: string;
|
||||
name: string;
|
||||
type: "polling" | "webhook" | "hybrid";
|
||||
type: "polling" | "webhook";
|
||||
hint: string;
|
||||
fields: ConnectorField[];
|
||||
security?: {
|
||||
@@ -83,7 +76,6 @@ type ActiveConnector = {
|
||||
phoneNumberId?: string;
|
||||
port?: number;
|
||||
baseUrl?: string;
|
||||
connectionMode?: string;
|
||||
};
|
||||
|
||||
type ConnectorChannelsResponse = {
|
||||
@@ -150,41 +142,10 @@ function isMultilineField(field: ConnectorField): boolean {
|
||||
return label.includes("json") || field.flag.includes("credentials");
|
||||
}
|
||||
|
||||
function shouldIncludeField(
|
||||
field: ConnectorField,
|
||||
values: Record<string, string>,
|
||||
): boolean {
|
||||
const condition = field.includeWhen;
|
||||
if (!condition) {
|
||||
return true;
|
||||
}
|
||||
const value = values[condition.flag] ?? "";
|
||||
if (condition.equals !== undefined && value !== condition.equals) {
|
||||
return false;
|
||||
}
|
||||
if (condition.notEquals !== undefined && value === condition.notEquals) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function initialValuesForChannel(
|
||||
channel?: ConnectorChannel,
|
||||
): Record<string, string> {
|
||||
const values: Record<string, string> = {};
|
||||
for (const field of channel?.fields ?? []) {
|
||||
if (field.initialValue) {
|
||||
values[field.flag] = field.initialValue;
|
||||
}
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
function createFormState(channels: ConnectorChannel[]): ConnectorFormState {
|
||||
const channel = channels[0];
|
||||
return {
|
||||
channelId: channel?.id ?? "",
|
||||
values: initialValuesForChannel(channel),
|
||||
channelId: channels[0]?.id ?? "",
|
||||
values: {},
|
||||
securityEnabled: false,
|
||||
securityValues: {},
|
||||
};
|
||||
@@ -214,15 +175,6 @@ export function ChannelsContent() {
|
||||
() => channels.find((channel) => channel.id === formState.channelId),
|
||||
[channels, formState.channelId],
|
||||
);
|
||||
const visibleFields = useMemo(() => {
|
||||
const values = {
|
||||
...initialValuesForChannel(selectedChannel),
|
||||
...formState.values,
|
||||
};
|
||||
return (selectedChannel?.fields ?? []).filter((field) =>
|
||||
shouldIncludeField(field, values),
|
||||
);
|
||||
}, [selectedChannel, formState.values]);
|
||||
|
||||
const applyResponse = useCallback((response: ConnectorChannelsResponse) => {
|
||||
setChannels(response.available);
|
||||
@@ -281,9 +233,6 @@ export function ChannelsContent() {
|
||||
return;
|
||||
}
|
||||
for (const field of selectedChannel.fields) {
|
||||
if (!visibleFields.includes(field)) {
|
||||
continue;
|
||||
}
|
||||
if (field.required && !formState.values[field.flag]?.trim()) {
|
||||
setFormError(`${field.label} is required`);
|
||||
return;
|
||||
@@ -427,11 +376,6 @@ export function ChannelsContent() {
|
||||
<span className="rounded-md border bg-background px-1.5 py-0.5">
|
||||
{formatDateTime(connector.startedAt)}
|
||||
</span>
|
||||
{connector.connectionMode ? (
|
||||
<span className="rounded-md border bg-background px-1.5 py-0.5">
|
||||
{connector.connectionMode}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
@@ -469,9 +413,7 @@ export function ChannelsContent() {
|
||||
}
|
||||
setFormState({
|
||||
channelId: value,
|
||||
values: initialValuesForChannel(
|
||||
channels.find((channel) => channel.id === value),
|
||||
),
|
||||
values: {},
|
||||
securityEnabled: false,
|
||||
securityValues: {},
|
||||
});
|
||||
@@ -491,7 +433,7 @@ export function ChannelsContent() {
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{visibleFields.map((field) => (
|
||||
{selectedChannel?.fields.map((field) => (
|
||||
<div className="grid gap-2" key={field.flag}>
|
||||
<Label>
|
||||
{field.label}
|
||||
@@ -499,29 +441,7 @@ export function ChannelsContent() {
|
||||
<span className="text-destructive"> *</span>
|
||||
) : null}
|
||||
</Label>
|
||||
{field.options ? (
|
||||
<Select
|
||||
onValueChange={(value) => {
|
||||
if (value) {
|
||||
updateFieldValue(field.flag, value);
|
||||
}
|
||||
}}
|
||||
value={
|
||||
formState.values[field.flag] ?? field.initialValue ?? ""
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={field.placeholder} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{field.options.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : isMultilineField(field) ? (
|
||||
{isMultilineField(field) ? (
|
||||
<Textarea
|
||||
onChange={(event) =>
|
||||
updateFieldValue(field.flag, event.target.value)
|
||||
|
||||
@@ -1,201 +0,0 @@
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright 2026 Cline Bot Inc.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
+5
-19
@@ -1,11 +1,6 @@
|
||||
{
|
||||
"root": true,
|
||||
"root": false,
|
||||
"$schema": "./node_modules/@biomejs/biome/configuration_schema.json",
|
||||
"vcs": {
|
||||
"enabled": true,
|
||||
"clientKind": "git",
|
||||
"defaultBranch": "main"
|
||||
},
|
||||
"assist": {
|
||||
"enabled": true,
|
||||
"actions": {
|
||||
@@ -129,7 +124,6 @@
|
||||
"!!**/playwright",
|
||||
"!!**/.vscode-test",
|
||||
"!!**/test-results",
|
||||
"!!**/coverage",
|
||||
"!!**/node_modules",
|
||||
"!!**/webview-ui/build",
|
||||
"!!**/generated",
|
||||
@@ -137,9 +131,7 @@
|
||||
"!!**/tests/specs"
|
||||
]
|
||||
},
|
||||
"plugins": [
|
||||
"src/dev/grit/process-env.grit"
|
||||
],
|
||||
"plugins": ["src/dev/grit/process-env.grit"],
|
||||
"overrides": [
|
||||
{
|
||||
"includes": [
|
||||
@@ -154,15 +146,11 @@
|
||||
"!!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.*",
|
||||
@@ -195,9 +183,7 @@
|
||||
"!!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"]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
+21
-32
@@ -1,34 +1,23 @@
|
||||
{
|
||||
"$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
|
||||
}
|
||||
}
|
||||
"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
|
||||
}
|
||||
|
||||
Generated
+112
@@ -8,6 +8,9 @@
|
||||
"name": "claude-dev",
|
||||
"version": "3.87.0",
|
||||
"license": "Apache-2.0",
|
||||
"workspaces": [
|
||||
"."
|
||||
],
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "^0.37.0",
|
||||
"@bufbuild/protobuf": "^2.2.5",
|
||||
@@ -378,6 +381,9 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "SEE LICENSE IN LICENSE.md",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -391,6 +397,9 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "SEE LICENSE IN LICENSE.md",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -404,6 +413,9 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "SEE LICENSE IN LICENSE.md",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -417,6 +429,9 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "SEE LICENSE IN LICENSE.md",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1212,6 +1227,9 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1229,6 +1247,9 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1246,6 +1267,9 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1263,6 +1287,9 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -5831,6 +5858,9 @@
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -5844,6 +5874,9 @@
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -5857,6 +5890,9 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -5870,6 +5906,9 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -5883,6 +5922,9 @@
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -5896,6 +5938,9 @@
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -5909,6 +5954,9 @@
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -5922,6 +5970,9 @@
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -5935,6 +5986,9 @@
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -5948,6 +6002,9 @@
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -5961,6 +6018,9 @@
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -5974,6 +6034,9 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -5987,6 +6050,9 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -7129,6 +7195,9 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0 AND MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -7146,6 +7215,9 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "Apache-2.0 AND MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -7163,6 +7235,9 @@
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0 AND MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -7180,6 +7255,9 @@
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0 AND MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -7197,6 +7275,9 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0 AND MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -7214,6 +7295,9 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "Apache-2.0 AND MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -7449,6 +7533,9 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -7465,6 +7552,9 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -7481,6 +7571,9 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -7497,6 +7590,9 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -9682,6 +9778,10 @@
|
||||
"node": ">=12.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/claude-dev": {
|
||||
"resolved": "",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/clone": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/clone/-/clone-2.1.2.tgz",
|
||||
@@ -13582,6 +13682,9 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -13602,6 +13705,9 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -13622,6 +13728,9 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -13642,6 +13751,9 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
||||
@@ -4,6 +4,9 @@
|
||||
"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.87.0",
|
||||
"icon": "assets/icons/icon.png",
|
||||
"workspaces": [
|
||||
"."
|
||||
],
|
||||
"engines": {
|
||||
"vscode": "^1.84.0"
|
||||
},
|
||||
@@ -363,13 +366,9 @@
|
||||
"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",
|
||||
"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",
|
||||
"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",
|
||||
"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",
|
||||
@@ -405,10 +404,10 @@
|
||||
"lint-staged": {
|
||||
"src/shared/storage/state-keys.ts": [
|
||||
"node scripts/generate-state-proto.mjs",
|
||||
"git add apps/vscode/proto/cline/state.proto"
|
||||
"git add proto/cline/state.proto"
|
||||
],
|
||||
"*": [
|
||||
"biome check --write --staged --no-errors-on-unmatched --files-ignore-unknown=true --semicolons=as-needed"
|
||||
"biome check --write --staged --no-errors-on-unmatched --files-ignore-unknown=true"
|
||||
]
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -32,8 +32,6 @@ 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
|
||||
@@ -116,16 +114,6 @@ 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;
|
||||
|
||||
@@ -4,11 +4,12 @@ import * as path from "path"
|
||||
import { Environment, type EnvironmentConfig } from "./shared/config-types"
|
||||
import { Logger } from "./shared/services/Logger"
|
||||
|
||||
export { Environment } /**
|
||||
export { Environment, type EnvironmentConfig }
|
||||
|
||||
/**
|
||||
* 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
|
||||
@@ -35,7 +36,7 @@ class ClineEndpoint {
|
||||
private onPremiseConfig: EndpointsFileSchema | null = null
|
||||
private environment: Environment = Environment.production
|
||||
// Track if config came from bundled file (enterprise distribution)
|
||||
private isBundled = false
|
||||
private isBundled: boolean = false
|
||||
|
||||
private constructor() {
|
||||
// Set environment at module load. Use override if provided.
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,606 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
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,5 +1,9 @@
|
||||
import { ModelInfo } from "@shared/api"
|
||||
import { type ApiHandler as SdkApiHandler, type ApiStreamChunk as SdkApiStreamChunk } from "@cline/llms"
|
||||
import { ApiConfiguration, ModelInfo } from "@shared/api"
|
||||
import { Mode } from "@shared/storage/types"
|
||||
import { ClineStorageMessage } from "@/shared/messages/content"
|
||||
import { ClineTool } from "@/shared/tools"
|
||||
import { ApiStream, ApiStreamUsageChunk } from "./transform/stream"
|
||||
|
||||
// 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
|
||||
@@ -9,6 +13,22 @@ import { Mode } from "@shared/storage/types"
|
||||
// at module-eval time (which can break extension activation). Keep this file
|
||||
// types-only.
|
||||
|
||||
// Re-export the SDK inference contracts so callers can depend on the SDK types
|
||||
// through the existing @core/api entry point. These are the canonical handler
|
||||
// and stream types going forward; the local interfaces below remain for the
|
||||
// classic provider classes until they are removed.
|
||||
export type { SdkApiHandler, SdkApiStreamChunk }
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
export interface ApiHandlerModel {
|
||||
id: string
|
||||
info: ModelInfo
|
||||
@@ -21,3 +41,7 @@ export interface ApiProviderInfo {
|
||||
mode: Mode
|
||||
customPrompt?: string // "compact"
|
||||
}
|
||||
|
||||
export interface SingleCompletionHandler {
|
||||
completePrompt(prompt: string): Promise<string>
|
||||
}
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
export type ApiStream = AsyncGenerator<ApiStreamChunk> & { id?: string }
|
||||
export type ApiStreamChunk = ApiStreamTextChunk | ApiStreamThinkingChunk | ApiStreamUsageChunk | ApiStreamToolCallsChunk
|
||||
|
||||
export interface ApiStreamTextChunk {
|
||||
type: "text"
|
||||
/**
|
||||
* Text content generated by the model
|
||||
*/
|
||||
text: string
|
||||
/**
|
||||
* The response ID associated with this chunk
|
||||
*/
|
||||
id?: string
|
||||
/**
|
||||
* The thought signature associated with this chunk used by Gemini
|
||||
*/
|
||||
signature?: string
|
||||
}
|
||||
|
||||
export interface ApiStreamUsageChunk {
|
||||
type: "usage"
|
||||
inputTokens: number
|
||||
outputTokens: number
|
||||
cacheWriteTokens?: number
|
||||
cacheReadTokens?: number
|
||||
thoughtsTokenCount?: number // openrouter
|
||||
totalCost?: number // openrouter
|
||||
/**
|
||||
* The response ID associated with this response
|
||||
*/
|
||||
id?: string
|
||||
}
|
||||
|
||||
export interface ApiStreamToolCallsChunk {
|
||||
type: "tool_calls"
|
||||
/**
|
||||
* The tool call information
|
||||
*/
|
||||
tool_call: ApiStreamToolCall
|
||||
/**
|
||||
* The response ID associated with this chunk
|
||||
*/
|
||||
id?: string
|
||||
/**
|
||||
* The thought signature associated with this chunk used by Gemini
|
||||
*/
|
||||
signature?: string
|
||||
}
|
||||
|
||||
export interface ApiStreamToolCall {
|
||||
/**
|
||||
* The call ID associated with this tool call
|
||||
*/
|
||||
call_id?: string
|
||||
// Information about the tool being called
|
||||
function: {
|
||||
/**
|
||||
* The tool call ID
|
||||
*/
|
||||
id?: string
|
||||
/**
|
||||
* Name of the tool
|
||||
*/
|
||||
name?: string
|
||||
/**
|
||||
* The arguments passed to the tool execution
|
||||
*/
|
||||
arguments?: any
|
||||
}
|
||||
}
|
||||
|
||||
export interface ApiStreamThinkingChunk {
|
||||
type: "reasoning"
|
||||
/**
|
||||
* The reasoning text generated by the model.
|
||||
* Redacted reasoning block will have this field set to "[REDACTED]" or an empty string.
|
||||
*/
|
||||
reasoning: string
|
||||
/**
|
||||
* openrouter has various properties that we can pass back unmodified in api requests to preserve reasoning traces
|
||||
* This is also where we store the summary details for OpenAI.
|
||||
*/
|
||||
details?: unknown
|
||||
/**
|
||||
* It's used when sending the thinking block back to the API.
|
||||
* API expects this in completed form, not as array of deltas.
|
||||
* Also used by Gemini for thought signature associated with this chunk
|
||||
*/
|
||||
signature?: string
|
||||
/**
|
||||
* redacted data
|
||||
*/
|
||||
redacted_data?: string
|
||||
/**
|
||||
* The response ID associated with this chunk
|
||||
*/
|
||||
id?: string
|
||||
}
|
||||
@@ -0,0 +1,386 @@
|
||||
import { expect } from "chai"
|
||||
import { describe, it } from "mocha"
|
||||
import { constructNewFileContent as cnfc } from "./diff"
|
||||
|
||||
async function cnfc2(diffContent: string, originalContent: string, isFinal: boolean): Promise<string> {
|
||||
const result = await cnfc(diffContent, originalContent, isFinal, "v2")
|
||||
return result.newContent
|
||||
}
|
||||
|
||||
describe("constructNewFileContent", () => {
|
||||
const testCases = [
|
||||
{
|
||||
name: "empty file",
|
||||
original: "",
|
||||
diff: `------- SEARCH
|
||||
=======
|
||||
new content
|
||||
+++++++ REPLACE`,
|
||||
expected: "new content\n",
|
||||
isFinal: true,
|
||||
},
|
||||
{
|
||||
name: "malformed search - mixed symbols",
|
||||
original: "line1\nline2\nline3",
|
||||
diff: `<<-- SEARCH
|
||||
line2
|
||||
=======
|
||||
replaced
|
||||
+++++++ REPLACE`,
|
||||
shouldThrow: true,
|
||||
},
|
||||
{
|
||||
name: "malformed search - insufficient dashes",
|
||||
original: "line1\nline2\nline3",
|
||||
diff: `-- SEARCH
|
||||
line2
|
||||
=======
|
||||
replaced
|
||||
+++++++ REPLACE`,
|
||||
shouldThrow: true,
|
||||
},
|
||||
{
|
||||
name: "malformed search - missing space",
|
||||
original: "line1\nline2\nline3",
|
||||
diff: `-------SEARCH
|
||||
line2
|
||||
=======
|
||||
replaced
|
||||
+++++++ REPLACE`,
|
||||
shouldThrow: true,
|
||||
},
|
||||
{
|
||||
name: "exact match replacement",
|
||||
original: "line1\nline2\nline3",
|
||||
diff: `------- SEARCH
|
||||
line2
|
||||
=======
|
||||
replaced
|
||||
+++++++ REPLACE`,
|
||||
expected: "line1\nreplaced\nline3",
|
||||
isFinal: true,
|
||||
},
|
||||
{
|
||||
name: "line-trimmed match replacement",
|
||||
original: "line1\n line2 \nline3",
|
||||
diff: `------- SEARCH
|
||||
line2
|
||||
=======
|
||||
replaced
|
||||
+++++++ REPLACE`,
|
||||
expected: "line1\nreplaced\nline3",
|
||||
isFinal: true,
|
||||
},
|
||||
{
|
||||
name: "block anchor match replacement",
|
||||
original: "line1\nstart\nmiddle\nend\nline5",
|
||||
diff: `------- SEARCH
|
||||
start
|
||||
middle
|
||||
end
|
||||
=======
|
||||
replaced
|
||||
+++++++ REPLACE`,
|
||||
expected: "line1\nreplaced\nline5",
|
||||
isFinal: true,
|
||||
},
|
||||
{
|
||||
name: "incremental processing",
|
||||
original: "line1\nline2\nline3",
|
||||
diff: [
|
||||
`------- SEARCH
|
||||
line2
|
||||
=======`,
|
||||
"replaced\n",
|
||||
"+++++++ REPLACE",
|
||||
].join("\n"),
|
||||
expected: "line1\nreplaced\n\nline3",
|
||||
isFinal: true,
|
||||
},
|
||||
{
|
||||
name: "final chunk with remaining content",
|
||||
original: "line1\nline2\nline3",
|
||||
diff: `------- SEARCH
|
||||
line2
|
||||
=======
|
||||
replaced
|
||||
+++++++ REPLACE`,
|
||||
expected: "line1\nreplaced\nline3",
|
||||
isFinal: true,
|
||||
},
|
||||
{
|
||||
name: "multiple ordered replacements",
|
||||
original: "First\nSecond\nThird\nFourth",
|
||||
diff: `------- SEARCH
|
||||
First
|
||||
=======
|
||||
1st
|
||||
+++++++ REPLACE
|
||||
|
||||
------- SEARCH
|
||||
Third
|
||||
=======
|
||||
3rd
|
||||
+++++++ REPLACE`,
|
||||
expected: "1st\nSecond\n3rd\nFourth",
|
||||
isFinal: true,
|
||||
},
|
||||
{
|
||||
name: "replace then delete",
|
||||
original: "line1\nline2\nline3\nline4",
|
||||
diff: `------- SEARCH
|
||||
line2
|
||||
=======
|
||||
replaced
|
||||
+++++++ REPLACE
|
||||
|
||||
------- SEARCH
|
||||
line4
|
||||
=======
|
||||
+++++++ REPLACE`,
|
||||
expected: "line1\nreplaced\nline3\n",
|
||||
isFinal: true,
|
||||
},
|
||||
{
|
||||
name: "delete then replace",
|
||||
original: "line1\nline2\nline3\nline4",
|
||||
diff: `------- SEARCH
|
||||
line1
|
||||
=======
|
||||
+++++++ REPLACE
|
||||
|
||||
------- SEARCH
|
||||
line3
|
||||
=======
|
||||
replaced
|
||||
+++++++ REPLACE`,
|
||||
expected: "line2\nreplaced\nline4",
|
||||
isFinal: true,
|
||||
},
|
||||
{
|
||||
name: "malformed diff - missing separator",
|
||||
original: "line1\nline2\nline3",
|
||||
diff: `------- SEARCH
|
||||
line2
|
||||
+++++++ REPLACE
|
||||
replaced`,
|
||||
shouldThrow: true,
|
||||
},
|
||||
{
|
||||
name: "malformed diff - trailing space on separator",
|
||||
original: "line1\nline2\nline3",
|
||||
diff: `------- SEARCH
|
||||
line2
|
||||
=======
|
||||
replaced
|
||||
+++++++ REPLACE`,
|
||||
shouldThrow: true,
|
||||
},
|
||||
{
|
||||
name: "malformed diff - double replace markers",
|
||||
original: "line1\nline2\nline3",
|
||||
diff: `------- SEARCH
|
||||
line2
|
||||
+++++++ REPLACE
|
||||
first replacement
|
||||
+++++++ REPLACE`,
|
||||
shouldThrow: true,
|
||||
},
|
||||
{
|
||||
name: "malformed diff - malformed separator with dashes",
|
||||
original: "line1\nline2\nline3",
|
||||
diff: `------- SEARCH
|
||||
line2
|
||||
------- =======
|
||||
replaced
|
||||
+++++++ REPLACE`,
|
||||
shouldThrow: true,
|
||||
},
|
||||
]
|
||||
//.filter(({name}) => name === "multiple ordered replacements")
|
||||
//.filter(({name}) => name === "delete then replace")
|
||||
testCases.forEach(({ name, original, diff, expected, isFinal, shouldThrow }) => {
|
||||
it(`should handle ${name} case correctly`, async () => {
|
||||
if (shouldThrow) {
|
||||
try {
|
||||
await cnfc(diff, original, isFinal ?? true)
|
||||
expect.fail("Expected an error to be thrown")
|
||||
} catch (err) {
|
||||
expect(err).to.be.an("error")
|
||||
}
|
||||
|
||||
try {
|
||||
await cnfc2(diff, original, isFinal ?? true)
|
||||
expect.fail("Expected an error to be thrown")
|
||||
} catch (err) {
|
||||
expect(err).to.be.an("error")
|
||||
}
|
||||
} else {
|
||||
const result1 = await cnfc(diff, original, isFinal ?? true)
|
||||
const result2 = await cnfc2(diff, original, isFinal ?? true)
|
||||
const _equal = result1.newContent === result2
|
||||
const _equal2 = result1.newContent === expected
|
||||
// Verify both implementations produce same result
|
||||
expect(result1.newContent).to.equal(result2)
|
||||
|
||||
// Verify result matches expected
|
||||
expect(result1.newContent).to.equal(expected)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it("should throw error when no match found", async () => {
|
||||
const original = "line1\nline2\nline3"
|
||||
const diff = `------- SEARCH
|
||||
non-existent
|
||||
=======
|
||||
replaced
|
||||
+++++++ REPLACE`
|
||||
|
||||
try {
|
||||
await cnfc(diff, original, true)
|
||||
expect.fail("Expected an error to be thrown")
|
||||
} catch (err) {
|
||||
expect(err).to.be.an("error")
|
||||
}
|
||||
|
||||
try {
|
||||
await cnfc2(diff, original, true)
|
||||
expect.fail("Expected an error to be thrown")
|
||||
} catch (err) {
|
||||
expect(err).to.be.an("error")
|
||||
}
|
||||
})
|
||||
|
||||
it("should handle missing final REPLACE marker when isFinal is true", async () => {
|
||||
const original = "line1\nline2\nline3"
|
||||
const diff = `------- SEARCH
|
||||
line2
|
||||
=======
|
||||
replaced`
|
||||
// Note: missing +++++++ REPLACE marker
|
||||
|
||||
const result1 = await cnfc(diff, original, true) // isFinal = true
|
||||
|
||||
// Should still work and replace line2 with "replaced"
|
||||
const expected = "line1\nreplaced\nline3"
|
||||
|
||||
expect(result1.newContent).to.equal(expected)
|
||||
})
|
||||
|
||||
it("should handle missing final REPLACE marker with multiple lines of replacement", async () => {
|
||||
const original = "function test() {\n\tconst a = 1;\n\treturn a;\n}"
|
||||
const diff = `------- SEARCH
|
||||
const a = 1;
|
||||
return a;
|
||||
=======
|
||||
const a = 42;
|
||||
console.log('updated');
|
||||
return a;`
|
||||
// Note: missing +++++++ REPLACE marker
|
||||
|
||||
const result1 = await cnfc(diff, original, true) // isFinal = true
|
||||
const expected = "function test() {\n\tconst a = 42;\n\tconsole.log('updated');\n\treturn a;\n}"
|
||||
|
||||
expect(result1.newContent).to.equal(expected)
|
||||
})
|
||||
|
||||
// it("should NOT process incomplete replacement when isFinal is false", async () => {
|
||||
// const original = "line1\nline2\nline3"
|
||||
// const diff = `------- SEARCH
|
||||
// line2
|
||||
// =======
|
||||
// replaced`
|
||||
// // Note: missing +++++++ REPLACE marker AND isFinal = false
|
||||
|
||||
// const result1 = await cnfc(diff, original, false) // isFinal = false
|
||||
|
||||
// // Should not make any changes since the block is incomplete
|
||||
// const expected = "line1\nline2\nline3"
|
||||
|
||||
// expect(result1).to.equal(expected)
|
||||
// })
|
||||
})
|
||||
|
||||
// Test cases for out-of-order search/replace blocks
|
||||
|
||||
describe("Diff Format Out of Order Cases", () => {
|
||||
it("should handle out-of-order replacements with different positions", async () => {
|
||||
const isFinal = true
|
||||
const original = "first\nsecond\nthird\nfourth\n"
|
||||
const diff = `------- SEARCH
|
||||
fourth
|
||||
=======
|
||||
new fourth
|
||||
+++++++ REPLACE
|
||||
------- SEARCH
|
||||
second
|
||||
=======
|
||||
new second
|
||||
+++++++ REPLACE`
|
||||
const result1 = await cnfc(diff, original, isFinal)
|
||||
const expectedResult = "first\nnew second\nthird\nnew fourth\n"
|
||||
expect(result1.newContent).to.equal(expectedResult)
|
||||
})
|
||||
|
||||
it("should handle multiple out-of-order replacements", async () => {
|
||||
const isFinal = true
|
||||
const original = "one\ntwo\nthree\nfour\nfive\n"
|
||||
const diff = `------- SEARCH
|
||||
four
|
||||
=======
|
||||
fourth
|
||||
+++++++ REPLACE
|
||||
------- SEARCH
|
||||
two
|
||||
=======
|
||||
second
|
||||
+++++++ REPLACE
|
||||
------- SEARCH
|
||||
five
|
||||
=======
|
||||
fifth
|
||||
+++++++ REPLACE`
|
||||
const result1 = await cnfc(diff, original, isFinal)
|
||||
const expectedResult = "one\nsecond\nthree\nfourth\nfifth\n"
|
||||
expect(result1.newContent).to.equal(expectedResult)
|
||||
})
|
||||
|
||||
it("should handle out-of-order replacements with indentation", async () => {
|
||||
const isFinal = true
|
||||
const original = "function test() {\n\tconst a = 1;\n\tconst b = 2;\n\tconst c = 3;\n\n}"
|
||||
const diff = `------- SEARCH
|
||||
const c = 3;
|
||||
=======
|
||||
const c = 30;
|
||||
+++++++ REPLACE
|
||||
------- SEARCH
|
||||
const a = 1;
|
||||
=======
|
||||
const a = 10;
|
||||
+++++++ REPLACE`
|
||||
const result1 = await cnfc(diff, original, isFinal)
|
||||
const expectedResult = "function test() {\n\tconst a = 10;\n\tconst b = 2;\n\tconst c = 30;\n\n}"
|
||||
expect(result1.newContent).to.equal(expectedResult)
|
||||
})
|
||||
|
||||
it("should handle out-of-order replacements with empty lines", async () => {
|
||||
const isFinal = true
|
||||
const original = "header\n\nbody\n\nfooter\n"
|
||||
const diff = `------- SEARCH
|
||||
footer
|
||||
=======
|
||||
new footer
|
||||
+++++++ REPLACE
|
||||
------- SEARCH
|
||||
|
||||
body
|
||||
|
||||
=======
|
||||
new body content
|
||||
+++++++ REPLACE`
|
||||
const result1 = await cnfc(diff, original, isFinal)
|
||||
const expectedResult = "header\nnew body content\nnew footer\n"
|
||||
expect(result1.newContent).to.equal(expectedResult)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,855 @@
|
||||
const SEARCH_BLOCK_START = "------- SEARCH"
|
||||
const SEARCH_BLOCK_END = "======="
|
||||
const REPLACE_BLOCK_END = "+++++++ REPLACE"
|
||||
|
||||
/**
|
||||
* Converts a character index in a string to a 1-based line number.
|
||||
* @param content - The full content string
|
||||
* @param charIndex - The character index in the content
|
||||
* @returns The 1-based line number where charIndex falls
|
||||
*/
|
||||
export function getLineNumberFromCharIndex(content: string, charIndex: number): number {
|
||||
if (charIndex <= 0) return 1
|
||||
return content.substring(0, charIndex).split("\n").length
|
||||
}
|
||||
|
||||
const SEARCH_BLOCK_CHAR = "-"
|
||||
const REPLACE_BLOCK_CHAR = "+"
|
||||
const LEGACY_SEARCH_BLOCK_CHAR = "<"
|
||||
const LEGACY_REPLACE_BLOCK_CHAR = ">"
|
||||
|
||||
// Replace the exact string constants with flexible regex patterns
|
||||
const SEARCH_BLOCK_START_REGEX = /^[-]{3,} SEARCH>?$/
|
||||
const LEGACY_SEARCH_BLOCK_START_REGEX = /^[<]{3,} SEARCH>?$/
|
||||
|
||||
const SEARCH_BLOCK_END_REGEX = /^[=]{3,}$/
|
||||
|
||||
const REPLACE_BLOCK_END_REGEX = /^[+]{3,} REPLACE>?$/
|
||||
const LEGACY_REPLACE_BLOCK_END_REGEX = /^[>]{3,} REPLACE>?$/
|
||||
|
||||
// Helper functions to check if a line matches the flexible patterns
|
||||
function isSearchBlockStart(line: string): boolean {
|
||||
return SEARCH_BLOCK_START_REGEX.test(line) || LEGACY_SEARCH_BLOCK_START_REGEX.test(line)
|
||||
}
|
||||
|
||||
function isSearchBlockEnd(line: string): boolean {
|
||||
return SEARCH_BLOCK_END_REGEX.test(line)
|
||||
}
|
||||
|
||||
function isReplaceBlockEnd(line: string): boolean {
|
||||
return REPLACE_BLOCK_END_REGEX.test(line) || LEGACY_REPLACE_BLOCK_END_REGEX.test(line)
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempts a line-trimmed fallback match for the given search content in the original content.
|
||||
* It tries to match `searchContent` lines against a block of lines in `originalContent` starting
|
||||
* from `lastProcessedIndex`. Lines are matched by trimming leading/trailing whitespace and ensuring
|
||||
* they are identical afterwards.
|
||||
*
|
||||
* Returns [matchIndexStart, matchIndexEnd] if found, or false if not found.
|
||||
*/
|
||||
function lineTrimmedFallbackMatch(originalContent: string, searchContent: string, startIndex: number): [number, number] | false {
|
||||
// Split both contents into lines
|
||||
const originalLines = originalContent.split("\n")
|
||||
const searchLines = searchContent.split("\n")
|
||||
|
||||
// Trim trailing empty line if exists (from the trailing \n in searchContent)
|
||||
if (searchLines[searchLines.length - 1] === "") {
|
||||
searchLines.pop()
|
||||
}
|
||||
|
||||
// Find the line number where startIndex falls
|
||||
let startLineNum = 0
|
||||
let currentIndex = 0
|
||||
while (currentIndex < startIndex && startLineNum < originalLines.length) {
|
||||
currentIndex += originalLines[startLineNum].length + 1 // +1 for \n
|
||||
startLineNum++
|
||||
}
|
||||
|
||||
// For each possible starting position in original content
|
||||
for (let i = startLineNum; i <= originalLines.length - searchLines.length; i++) {
|
||||
let matches = true
|
||||
|
||||
// Try to match all search lines from this position
|
||||
for (let j = 0; j < searchLines.length; j++) {
|
||||
const originalTrimmed = originalLines[i + j].trim()
|
||||
const searchTrimmed = searchLines[j].trim()
|
||||
|
||||
if (originalTrimmed !== searchTrimmed) {
|
||||
matches = false
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// If we found a match, calculate the exact character positions
|
||||
if (matches) {
|
||||
// Find start character index
|
||||
let matchStartIndex = 0
|
||||
for (let k = 0; k < i; k++) {
|
||||
matchStartIndex += originalLines[k].length + 1 // +1 for \n
|
||||
}
|
||||
|
||||
// Find end character index
|
||||
let matchEndIndex = matchStartIndex
|
||||
for (let k = 0; k < searchLines.length; k++) {
|
||||
matchEndIndex += originalLines[i + k].length + 1 // +1 for \n
|
||||
}
|
||||
|
||||
return [matchStartIndex, matchEndIndex]
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempts to match blocks of code by using the first and last lines as anchors.
|
||||
* This is a third-tier fallback strategy that helps match blocks where we can identify
|
||||
* the correct location by matching the beginning and end, even if the exact content
|
||||
* differs slightly.
|
||||
*
|
||||
* The matching strategy:
|
||||
* 1. Only attempts to match blocks of 3 or more lines to avoid false positives
|
||||
* 2. Extracts from the search content:
|
||||
* - First line as the "start anchor"
|
||||
* - Last line as the "end anchor"
|
||||
* 3. For each position in the original content:
|
||||
* - Checks if the next line matches the start anchor
|
||||
* - If it does, jumps ahead by the search block size
|
||||
* - Checks if that line matches the end anchor
|
||||
* - All comparisons are done after trimming whitespace
|
||||
*
|
||||
* This approach is particularly useful for matching blocks of code where:
|
||||
* - The exact content might have minor differences
|
||||
* - The beginning and end of the block are distinctive enough to serve as anchors
|
||||
* - The overall structure (number of lines) remains the same
|
||||
*
|
||||
* @param originalContent - The full content of the original file
|
||||
* @param searchContent - The content we're trying to find in the original file
|
||||
* @param startIndex - The character index in originalContent where to start searching
|
||||
* @returns A tuple of [startIndex, endIndex] if a match is found, false otherwise
|
||||
*/
|
||||
function blockAnchorFallbackMatch(originalContent: string, searchContent: string, startIndex: number): [number, number] | false {
|
||||
const originalLines = originalContent.split("\n")
|
||||
const searchLines = searchContent.split("\n")
|
||||
|
||||
// Only use this approach for blocks of 3+ lines
|
||||
if (searchLines.length < 3) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Trim trailing empty line if exists
|
||||
if (searchLines[searchLines.length - 1] === "") {
|
||||
searchLines.pop()
|
||||
}
|
||||
|
||||
const firstLineSearch = searchLines[0].trim()
|
||||
const lastLineSearch = searchLines[searchLines.length - 1].trim()
|
||||
const searchBlockSize = searchLines.length
|
||||
|
||||
// Find the line number where startIndex falls
|
||||
let startLineNum = 0
|
||||
let currentIndex = 0
|
||||
while (currentIndex < startIndex && startLineNum < originalLines.length) {
|
||||
currentIndex += originalLines[startLineNum].length + 1
|
||||
startLineNum++
|
||||
}
|
||||
|
||||
// Look for matching start and end anchors
|
||||
for (let i = startLineNum; i <= originalLines.length - searchBlockSize; i++) {
|
||||
// Check if first line matches
|
||||
if (originalLines[i].trim() !== firstLineSearch) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if last line matches at the expected position
|
||||
if (originalLines[i + searchBlockSize - 1].trim() !== lastLineSearch) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Calculate exact character positions
|
||||
let matchStartIndex = 0
|
||||
for (let k = 0; k < i; k++) {
|
||||
matchStartIndex += originalLines[k].length + 1
|
||||
}
|
||||
|
||||
let matchEndIndex = matchStartIndex
|
||||
for (let k = 0; k < searchBlockSize; k++) {
|
||||
matchEndIndex += originalLines[i + k].length + 1
|
||||
}
|
||||
|
||||
return [matchStartIndex, matchEndIndex]
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* This function reconstructs the file content by applying a streamed diff (in a
|
||||
* specialized SEARCH/REPLACE block format) to the original file content. It is designed
|
||||
* to handle both incremental updates and the final resulting file after all chunks have
|
||||
* been processed.
|
||||
*
|
||||
* The diff format is a custom structure that uses three markers to define changes:
|
||||
*
|
||||
* ------- SEARCH
|
||||
* [Exact content to find in the original file]
|
||||
* =======
|
||||
* [Content to replace with]
|
||||
* +++++++ REPLACE
|
||||
*
|
||||
* Behavior and Assumptions:
|
||||
* 1. The file is processed chunk-by-chunk. Each chunk of `diffContent` may contain
|
||||
* partial or complete SEARCH/REPLACE blocks. By calling this function with each
|
||||
* incremental chunk (with `isFinal` indicating the last chunk), the final reconstructed
|
||||
* file content is produced.
|
||||
*
|
||||
* 2. Matching Strategy (in order of attempt):
|
||||
* a. Exact Match: First attempts to find the exact SEARCH block text in the original file
|
||||
* b. Line-Trimmed Match: Falls back to line-by-line comparison ignoring leading/trailing whitespace
|
||||
* c. Block Anchor Match: For blocks of 3+ lines, tries to match using first/last lines as anchors
|
||||
* If all matching strategies fail, an error is thrown.
|
||||
*
|
||||
* 3. Empty SEARCH Section:
|
||||
* - If SEARCH is empty and the original file is empty, this indicates creating a new file
|
||||
* (pure insertion).
|
||||
* - If SEARCH is empty and the original file is not empty, this indicates a complete
|
||||
* file replacement (the entire original content is considered matched and replaced).
|
||||
*
|
||||
* 4. Applying Changes:
|
||||
* - Before encountering the "=======" marker, lines are accumulated as search content.
|
||||
* - After "=======" and before ">>>>>>> REPLACE", lines are accumulated as replacement content.
|
||||
* - Once the block is complete (">>>>>>> REPLACE"), the matched section in the original
|
||||
* file is replaced with the accumulated replacement lines, and the position in the original
|
||||
* file is advanced.
|
||||
*
|
||||
* 5. Incremental Output:
|
||||
* - As soon as the match location is found and we are in the REPLACE section, each new
|
||||
* replacement line is appended to the result so that partial updates can be viewed
|
||||
* incrementally.
|
||||
*
|
||||
* 6. Partial Markers:
|
||||
* - If the final line of the chunk looks like it might be part of a marker but is not one
|
||||
* of the known markers, it is removed. This prevents incomplete or partial markers
|
||||
* from corrupting the output.
|
||||
*
|
||||
* 7. Finalization:
|
||||
* - Once all chunks have been processed (when `isFinal` is true), any remaining original
|
||||
* content after the last replaced section is appended to the result.
|
||||
* - Trailing newlines are not forcibly added. The code tries to output exactly what is specified.
|
||||
*
|
||||
* Errors:
|
||||
* - If the search block cannot be matched using any of the available matching strategies,
|
||||
* an error is thrown.
|
||||
*/
|
||||
export async function constructNewFileContent(
|
||||
diffContent: string,
|
||||
originalContent: string,
|
||||
isFinal: boolean,
|
||||
version: "v1" | "v2" = "v1",
|
||||
): Promise<{ newContent: string; matchIndices: number[] }> {
|
||||
const constructor = constructNewFileContentVersionMapping[version]
|
||||
if (!constructor) {
|
||||
throw new Error(`Invalid version '${version}' for file content constructor`)
|
||||
}
|
||||
return constructor(diffContent, originalContent, isFinal)
|
||||
}
|
||||
|
||||
const constructNewFileContentVersionMapping: Record<
|
||||
string,
|
||||
(diffContent: string, originalContent: string, isFinal: boolean) => Promise<{ newContent: string; matchIndices: number[] }>
|
||||
> = {
|
||||
v1: constructNewFileContentV1,
|
||||
v2: constructNewFileContentV2,
|
||||
} as const
|
||||
|
||||
async function constructNewFileContentV1(
|
||||
diffContent: string,
|
||||
originalContent: string,
|
||||
isFinal: boolean,
|
||||
): Promise<{ newContent: string; matchIndices: number[] }> {
|
||||
let result = ""
|
||||
let lastProcessedIndex = 0
|
||||
|
||||
let currentSearchContent = ""
|
||||
let currentReplaceContent = ""
|
||||
let inSearch = false
|
||||
let inReplace = false
|
||||
|
||||
let searchMatchIndex = -1
|
||||
let searchEndIndex = -1
|
||||
|
||||
// Track all replacements to handle out-of-order edits
|
||||
const replacements: Array<{ start: number; end: number; content: string }> = []
|
||||
let pendingOutOfOrderReplacement = false
|
||||
|
||||
const lines = diffContent.split("\n")
|
||||
|
||||
// If the last line looks like a partial marker but isn't recognized,
|
||||
// remove it because it might be incomplete.
|
||||
const lastLine = lines[lines.length - 1]
|
||||
if (
|
||||
lines.length > 0 &&
|
||||
(lastLine.startsWith(SEARCH_BLOCK_CHAR) ||
|
||||
lastLine.startsWith(LEGACY_SEARCH_BLOCK_CHAR) ||
|
||||
lastLine.startsWith("=") ||
|
||||
lastLine.startsWith(REPLACE_BLOCK_CHAR) ||
|
||||
lastLine.startsWith(LEGACY_REPLACE_BLOCK_CHAR)) &&
|
||||
!isSearchBlockStart(lastLine) &&
|
||||
!isSearchBlockEnd(lastLine) &&
|
||||
!isReplaceBlockEnd(lastLine)
|
||||
) {
|
||||
lines.pop()
|
||||
}
|
||||
|
||||
for (const line of lines) {
|
||||
if (isSearchBlockStart(line)) {
|
||||
inSearch = true
|
||||
currentSearchContent = ""
|
||||
currentReplaceContent = ""
|
||||
continue
|
||||
}
|
||||
|
||||
if (isSearchBlockEnd(line)) {
|
||||
inSearch = false
|
||||
inReplace = true
|
||||
|
||||
// Remove trailing linebreak for adding the === marker
|
||||
// if (currentSearchContent.endsWith("\r\n")) {
|
||||
// currentSearchContent = currentSearchContent.slice(0, -2)
|
||||
// } else if (currentSearchContent.endsWith("\n")) {
|
||||
// currentSearchContent = currentSearchContent.slice(0, -1)
|
||||
// }
|
||||
|
||||
if (!currentSearchContent) {
|
||||
// Empty search block
|
||||
if (originalContent.length === 0) {
|
||||
// New file scenario: nothing to match, just start inserting
|
||||
searchMatchIndex = 0
|
||||
searchEndIndex = 0
|
||||
} else {
|
||||
// ERROR: Empty search block with non-empty file indicates malformed SEARCH marker
|
||||
throw new Error(
|
||||
"Empty SEARCH block detected with non-empty file. This usually indicates a malformed SEARCH marker.\n" +
|
||||
"Please ensure your SEARCH marker follows the correct format:\n" +
|
||||
"- Use '------- SEARCH' (7+ dashes + space + SEARCH)\n",
|
||||
)
|
||||
}
|
||||
} else {
|
||||
// Add check for inefficient full-file search
|
||||
// if (currentSearchContent.trim() === originalContent.trim()) {
|
||||
// throw new Error(
|
||||
// "The SEARCH block contains the entire file content. Please either:\n" +
|
||||
// "1. Use an empty SEARCH block to replace the entire file, or\n" +
|
||||
// "2. Make focused changes to specific parts of the file that need modification.",
|
||||
// )
|
||||
// }
|
||||
|
||||
// Exact search match scenario
|
||||
const exactIndex = originalContent.indexOf(currentSearchContent, lastProcessedIndex)
|
||||
if (exactIndex !== -1) {
|
||||
searchMatchIndex = exactIndex
|
||||
searchEndIndex = exactIndex + currentSearchContent.length
|
||||
} else {
|
||||
// Attempt fallback line-trimmed matching
|
||||
const lineMatch = lineTrimmedFallbackMatch(originalContent, currentSearchContent, lastProcessedIndex)
|
||||
if (lineMatch) {
|
||||
;[searchMatchIndex, searchEndIndex] = lineMatch
|
||||
} else {
|
||||
// Try block anchor fallback for larger blocks
|
||||
const blockMatch = blockAnchorFallbackMatch(originalContent, currentSearchContent, lastProcessedIndex)
|
||||
if (blockMatch) {
|
||||
;[searchMatchIndex, searchEndIndex] = blockMatch
|
||||
} else {
|
||||
// Last resort: search the entire file from the beginning
|
||||
const fullFileIndex = originalContent.indexOf(currentSearchContent, 0)
|
||||
if (fullFileIndex !== -1) {
|
||||
// Found in the file - could be out of order
|
||||
searchMatchIndex = fullFileIndex
|
||||
searchEndIndex = fullFileIndex + currentSearchContent.length
|
||||
if (searchMatchIndex < lastProcessedIndex) {
|
||||
pendingOutOfOrderReplacement = true
|
||||
}
|
||||
} else {
|
||||
throw new Error(
|
||||
`The SEARCH block:\n${currentSearchContent.trimEnd()}\n...does not match anything in the file.`,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check if this is an out-of-order replacement
|
||||
if (searchMatchIndex < lastProcessedIndex) {
|
||||
pendingOutOfOrderReplacement = true
|
||||
}
|
||||
|
||||
// For in-order replacements, output everything up to the match location
|
||||
if (!pendingOutOfOrderReplacement) {
|
||||
result += originalContent.slice(lastProcessedIndex, searchMatchIndex)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if (isReplaceBlockEnd(line)) {
|
||||
// Finished one replace block
|
||||
|
||||
if (searchMatchIndex === -1) {
|
||||
throw new Error(`The SEARCH block:\n${currentSearchContent.trimEnd()}\n...is malformatted.`)
|
||||
}
|
||||
|
||||
// Store this replacement
|
||||
replacements.push({
|
||||
start: searchMatchIndex,
|
||||
end: searchEndIndex,
|
||||
content: currentReplaceContent,
|
||||
})
|
||||
|
||||
// If this was an in-order replacement, advance lastProcessedIndex
|
||||
if (!pendingOutOfOrderReplacement) {
|
||||
lastProcessedIndex = searchEndIndex
|
||||
}
|
||||
|
||||
// Reset for next block
|
||||
inSearch = false
|
||||
inReplace = false
|
||||
currentSearchContent = ""
|
||||
currentReplaceContent = ""
|
||||
searchMatchIndex = -1
|
||||
searchEndIndex = -1
|
||||
pendingOutOfOrderReplacement = false
|
||||
continue
|
||||
}
|
||||
|
||||
// Accumulate content for search or replace
|
||||
// (currentReplaceContent is not being used for anything right now since we directly append to result.)
|
||||
// (We artificially add a linebreak since we split on \n at the beginning. In order to not include a trailing linebreak in the final search/result blocks we need to remove it before using them. This allows for partial line matches to be correctly identified.)
|
||||
// NOTE: search/replace blocks must be arranged in the order they appear in the file due to how we build the content using lastProcessedIndex. We also cannot strip the trailing newline since for non-partial lines it would remove the linebreak from the original content. (If we remove end linebreak from search, then we'd also have to remove it from replace but we can't know if it's a partial line or not since the model may be using the line break to indicate the end of the block rather than as part of the search content.) We require the model to output full lines in order for our fallbacks to work as well.
|
||||
if (inSearch) {
|
||||
currentSearchContent += line + "\n"
|
||||
} else if (inReplace) {
|
||||
currentReplaceContent += line + "\n"
|
||||
// Only output replacement lines immediately for in-order replacements
|
||||
if (searchMatchIndex !== -1 && !pendingOutOfOrderReplacement) {
|
||||
result += line + "\n"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If this is the final chunk, we need to apply all replacements and build the final result
|
||||
if (isFinal) {
|
||||
// Handle the case where we're still in replace mode when processing ends
|
||||
// and this is the final chunk - treat it as if we encountered the REPLACE marker
|
||||
if (inReplace && searchMatchIndex !== -1) {
|
||||
// Store this replacement
|
||||
replacements.push({
|
||||
start: searchMatchIndex,
|
||||
end: searchEndIndex,
|
||||
content: currentReplaceContent,
|
||||
})
|
||||
|
||||
// If this was an in-order replacement, advance lastProcessedIndex
|
||||
if (!pendingOutOfOrderReplacement) {
|
||||
lastProcessedIndex = searchEndIndex
|
||||
}
|
||||
|
||||
// Reset state
|
||||
inSearch = false
|
||||
inReplace = false
|
||||
currentSearchContent = ""
|
||||
currentReplaceContent = ""
|
||||
searchMatchIndex = -1
|
||||
searchEndIndex = -1
|
||||
pendingOutOfOrderReplacement = false
|
||||
}
|
||||
// end of handling missing replace marker
|
||||
|
||||
// Sort replacements by start position
|
||||
replacements.sort((a, b) => a.start - b.start)
|
||||
|
||||
// Rebuild the entire result by applying all replacements
|
||||
result = ""
|
||||
let currentPos = 0
|
||||
|
||||
for (const replacement of replacements) {
|
||||
// Add original content up to this replacement
|
||||
result += originalContent.slice(currentPos, replacement.start)
|
||||
// Add the replacement content
|
||||
result += replacement.content
|
||||
// Move position to after the replaced section
|
||||
currentPos = replacement.end
|
||||
}
|
||||
|
||||
// Add any remaining original content
|
||||
result += originalContent.slice(currentPos)
|
||||
}
|
||||
|
||||
// Return all match indices from the replacements
|
||||
// This is used to determine the line numbers for each SEARCH/REPLACE block in the UI
|
||||
return { newContent: result, matchIndices: replacements.map((r) => r.start) }
|
||||
}
|
||||
|
||||
enum ProcessingState {
|
||||
Idle = 0,
|
||||
StateSearch = 1 << 0,
|
||||
StateReplace = 1 << 1,
|
||||
}
|
||||
|
||||
class NewFileContentConstructor {
|
||||
private originalContent: string
|
||||
private isFinal: boolean
|
||||
private state: number
|
||||
private pendingNonStandardLines: string[]
|
||||
private result: string
|
||||
private lastProcessedIndex: number
|
||||
private currentSearchContent: string
|
||||
private searchMatchIndex: number
|
||||
private searchEndIndex: number
|
||||
|
||||
constructor(originalContent: string, isFinal: boolean) {
|
||||
this.originalContent = originalContent
|
||||
this.isFinal = isFinal
|
||||
this.pendingNonStandardLines = []
|
||||
this.result = ""
|
||||
this.lastProcessedIndex = 0
|
||||
this.state = ProcessingState.Idle
|
||||
this.currentSearchContent = ""
|
||||
this.searchMatchIndex = -1
|
||||
this.searchEndIndex = -1
|
||||
}
|
||||
|
||||
private resetForNextBlock() {
|
||||
// Reset for next block
|
||||
this.state = ProcessingState.Idle
|
||||
this.currentSearchContent = ""
|
||||
this.searchMatchIndex = -1
|
||||
this.searchEndIndex = -1
|
||||
}
|
||||
|
||||
private findLastMatchingLineIndex(regx: RegExp, lineLimit: number) {
|
||||
for (let i = lineLimit; i > 0; ) {
|
||||
i--
|
||||
if (this.pendingNonStandardLines[i].match(regx)) {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
private updateProcessingState(newState: ProcessingState) {
|
||||
const isValidTransition =
|
||||
(this.state === ProcessingState.Idle && newState === ProcessingState.StateSearch) ||
|
||||
(this.state === ProcessingState.StateSearch && newState === ProcessingState.StateReplace)
|
||||
|
||||
if (!isValidTransition) {
|
||||
throw new Error(
|
||||
`Invalid state transition.\n` +
|
||||
"Valid transitions are:\n" +
|
||||
"- Idle → StateSearch\n" +
|
||||
"- StateSearch → StateReplace",
|
||||
)
|
||||
}
|
||||
|
||||
this.state |= newState
|
||||
}
|
||||
|
||||
private isStateActive(state: ProcessingState): boolean {
|
||||
return (this.state & state) === state
|
||||
}
|
||||
|
||||
private activateReplaceState() {
|
||||
this.updateProcessingState(ProcessingState.StateReplace)
|
||||
}
|
||||
|
||||
private activateSearchState() {
|
||||
this.updateProcessingState(ProcessingState.StateSearch)
|
||||
this.currentSearchContent = ""
|
||||
}
|
||||
|
||||
private isSearchingActive(): boolean {
|
||||
return this.isStateActive(ProcessingState.StateSearch)
|
||||
}
|
||||
|
||||
private isReplacingActive(): boolean {
|
||||
return this.isStateActive(ProcessingState.StateReplace)
|
||||
}
|
||||
|
||||
private hasPendingNonStandardLines(pendingNonStandardLineLimit: number): boolean {
|
||||
return this.pendingNonStandardLines.length - pendingNonStandardLineLimit < this.pendingNonStandardLines.length
|
||||
}
|
||||
|
||||
public processLine(line: string) {
|
||||
this.internalProcessLine(line, true, this.pendingNonStandardLines.length)
|
||||
}
|
||||
|
||||
public getResult(): { newContent: string; matchIndices: number[] } {
|
||||
// If this is the final chunk, append any remaining original content
|
||||
if (this.isFinal && this.lastProcessedIndex < this.originalContent.length) {
|
||||
this.result += this.originalContent.slice(this.lastProcessedIndex)
|
||||
}
|
||||
if (this.isFinal && this.state !== ProcessingState.Idle) {
|
||||
throw new Error("File processing incomplete - SEARCH/REPLACE operations still active during finalization")
|
||||
}
|
||||
// Note: V2 implementation doesn't currently track match indices
|
||||
// For now, return empty array. If V2 becomes the default and we need line numbers,
|
||||
// we should add state to track all match indices.
|
||||
return { newContent: this.result, matchIndices: [] }
|
||||
}
|
||||
|
||||
private internalProcessLine(
|
||||
line: string,
|
||||
canWritependingNonStandardLines: boolean,
|
||||
pendingNonStandardLineLimit: number,
|
||||
): number {
|
||||
let removeLineCount = 0
|
||||
if (isSearchBlockStart(line)) {
|
||||
removeLineCount = this.trimPendingNonStandardTrailingEmptyLines(pendingNonStandardLineLimit)
|
||||
if (removeLineCount > 0) {
|
||||
pendingNonStandardLineLimit = pendingNonStandardLineLimit - removeLineCount
|
||||
}
|
||||
if (this.hasPendingNonStandardLines(pendingNonStandardLineLimit)) {
|
||||
this.tryFixSearchReplaceBlock(pendingNonStandardLineLimit)
|
||||
canWritependingNonStandardLines && (this.pendingNonStandardLines.length = 0)
|
||||
}
|
||||
this.activateSearchState()
|
||||
} else if (isSearchBlockEnd(line)) {
|
||||
// 校验非标内容
|
||||
if (!this.isSearchingActive()) {
|
||||
this.tryFixSearchBlock(pendingNonStandardLineLimit)
|
||||
canWritependingNonStandardLines && (this.pendingNonStandardLines.length = 0)
|
||||
}
|
||||
this.activateReplaceState()
|
||||
this.beforeReplace()
|
||||
} else if (isReplaceBlockEnd(line)) {
|
||||
if (!this.isReplacingActive()) {
|
||||
this.tryFixReplaceBlock(pendingNonStandardLineLimit)
|
||||
canWritependingNonStandardLines && (this.pendingNonStandardLines.length = 0)
|
||||
}
|
||||
this.lastProcessedIndex = this.searchEndIndex
|
||||
this.resetForNextBlock()
|
||||
} else {
|
||||
// Accumulate content for search or replace
|
||||
// (currentReplaceContent is not being used for anything right now since we directly append to result.)
|
||||
// (We artificially add a linebreak since we split on \n at the beginning. In order to not include a trailing linebreak in the final search/result blocks we need to remove it before using them. This allows for partial line matches to be correctly identified.)
|
||||
// NOTE: search/replace blocks must be arranged in the order they appear in the file due to how we build the content using lastProcessedIndex. We also cannot strip the trailing newline since for non-partial lines it would remove the linebreak from the original content. (If we remove end linebreak from search, then we'd also have to remove it from replace but we can't know if it's a partial line or not since the model may be using the line break to indicate the end of the block rather than as part of the search content.) We require the model to output full lines in order for our fallbacks to work as well.
|
||||
if (this.isReplacingActive()) {
|
||||
// Output replacement lines immediately if we know the insertion point
|
||||
if (this.searchMatchIndex !== -1) {
|
||||
this.result += line + "\n"
|
||||
}
|
||||
} else if (this.isSearchingActive()) {
|
||||
this.currentSearchContent += line + "\n"
|
||||
} else {
|
||||
const appendToPendingNonStandardLines = canWritependingNonStandardLines
|
||||
if (appendToPendingNonStandardLines) {
|
||||
// 处理非标内容
|
||||
this.pendingNonStandardLines.push(line)
|
||||
}
|
||||
}
|
||||
}
|
||||
return removeLineCount
|
||||
}
|
||||
|
||||
private beforeReplace() {
|
||||
// Remove trailing linebreak for adding the === marker
|
||||
// if (currentSearchContent.endsWith("\r\n")) {
|
||||
// currentSearchContent = currentSearchContent.slice(0, -2)
|
||||
// } else if (currentSearchContent.endsWith("\n")) {
|
||||
// currentSearchContent = currentSearchContent.slice(0, -1)
|
||||
// }
|
||||
|
||||
if (!this.currentSearchContent) {
|
||||
// Empty search block
|
||||
if (this.originalContent.length === 0) {
|
||||
// New file scenario: nothing to match, just start inserting
|
||||
this.searchMatchIndex = 0
|
||||
this.searchEndIndex = 0
|
||||
} else {
|
||||
// Complete file replacement scenario: treat the entire file as matched
|
||||
this.searchMatchIndex = 0
|
||||
this.searchEndIndex = this.originalContent.length
|
||||
}
|
||||
} else {
|
||||
// Add check for inefficient full-file search
|
||||
// if (currentSearchContent.trim() === originalContent.trim()) {
|
||||
// throw new Error(
|
||||
// "The SEARCH block contains the entire file content. Please either:\n" +
|
||||
// "1. Use an empty SEARCH block to replace the entire file, or\n" +
|
||||
// "2. Make focused changes to specific parts of the file that need modification.",
|
||||
// )
|
||||
// }
|
||||
// Exact search match scenario
|
||||
const exactIndex = this.originalContent.indexOf(this.currentSearchContent, this.lastProcessedIndex)
|
||||
if (exactIndex !== -1) {
|
||||
this.searchMatchIndex = exactIndex
|
||||
this.searchEndIndex = exactIndex + this.currentSearchContent.length
|
||||
} else {
|
||||
// Attempt fallback line-trimmed matching
|
||||
const lineMatch = lineTrimmedFallbackMatch(
|
||||
this.originalContent,
|
||||
this.currentSearchContent,
|
||||
this.lastProcessedIndex,
|
||||
)
|
||||
if (lineMatch) {
|
||||
;[this.searchMatchIndex, this.searchEndIndex] = lineMatch
|
||||
} else {
|
||||
// Try block anchor fallback for larger blocks
|
||||
const blockMatch = blockAnchorFallbackMatch(
|
||||
this.originalContent,
|
||||
this.currentSearchContent,
|
||||
this.lastProcessedIndex,
|
||||
)
|
||||
if (blockMatch) {
|
||||
;[this.searchMatchIndex, this.searchEndIndex] = blockMatch
|
||||
} else {
|
||||
throw new Error(
|
||||
`The SEARCH block:\n${this.currentSearchContent.trimEnd()}\n...does not match anything in the file.`,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (this.searchMatchIndex < this.lastProcessedIndex) {
|
||||
throw new Error(
|
||||
`The SEARCH block:\n${this.currentSearchContent.trimEnd()}\n...matched an incorrect content in the file.`,
|
||||
)
|
||||
}
|
||||
// Output everything up to the match location
|
||||
this.result += this.originalContent.slice(this.lastProcessedIndex, this.searchMatchIndex)
|
||||
}
|
||||
|
||||
private tryFixSearchBlock(lineLimit: number): number {
|
||||
let removeLineCount = 0
|
||||
if (lineLimit < 0) {
|
||||
lineLimit = this.pendingNonStandardLines.length
|
||||
}
|
||||
if (!lineLimit) {
|
||||
throw new Error("Invalid SEARCH/REPLACE block structure - no lines available to process")
|
||||
}
|
||||
const searchTagRegexp = /^([-]{3,}|[<]{3,}) SEARCH$/
|
||||
const searchTagIndex = this.findLastMatchingLineIndex(searchTagRegexp, lineLimit)
|
||||
if (searchTagIndex !== -1) {
|
||||
const fixLines = this.pendingNonStandardLines.slice(searchTagIndex, lineLimit)
|
||||
fixLines[0] = SEARCH_BLOCK_START
|
||||
for (const line of fixLines) {
|
||||
removeLineCount += this.internalProcessLine(line, false, searchTagIndex)
|
||||
}
|
||||
} else {
|
||||
throw new Error(
|
||||
`Invalid REPLACE marker detected - could not find matching SEARCH block starting from line ${searchTagIndex + 1}`,
|
||||
)
|
||||
}
|
||||
return removeLineCount
|
||||
}
|
||||
|
||||
private tryFixReplaceBlock(lineLimit: number): number {
|
||||
let removeLineCount = 0
|
||||
if (lineLimit < 0) {
|
||||
lineLimit = this.pendingNonStandardLines.length
|
||||
}
|
||||
if (!lineLimit) {
|
||||
throw new Error()
|
||||
}
|
||||
const replaceBeginTagRegexp = /^[=]{3,}$/
|
||||
const replaceBeginTagIndex = this.findLastMatchingLineIndex(replaceBeginTagRegexp, lineLimit)
|
||||
if (replaceBeginTagIndex !== -1) {
|
||||
// // 校验非标内容
|
||||
// if (!this.isSearchingActive()) {
|
||||
// removeLineCount += this.tryFixSearchBlock(replaceBeginTagIndex)
|
||||
// }
|
||||
const fixLines = this.pendingNonStandardLines.slice(
|
||||
replaceBeginTagIndex - removeLineCount,
|
||||
lineLimit - removeLineCount,
|
||||
)
|
||||
fixLines[0] = SEARCH_BLOCK_END
|
||||
for (const line of fixLines) {
|
||||
removeLineCount += this.internalProcessLine(line, false, replaceBeginTagIndex - removeLineCount)
|
||||
}
|
||||
} else {
|
||||
throw new Error(`Malformed REPLACE block - missing valid separator after line ${replaceBeginTagIndex + 1}`)
|
||||
}
|
||||
return removeLineCount
|
||||
}
|
||||
|
||||
private tryFixSearchReplaceBlock(lineLimit: number): number {
|
||||
let removeLineCount = 0
|
||||
if (lineLimit < 0) {
|
||||
lineLimit = this.pendingNonStandardLines.length
|
||||
}
|
||||
if (!lineLimit) {
|
||||
throw new Error()
|
||||
}
|
||||
|
||||
const replaceEndTagRegexp = /^([+]{3,}|[>]{3,}) REPLACE$/
|
||||
const replaceEndTagIndex = this.findLastMatchingLineIndex(replaceEndTagRegexp, lineLimit)
|
||||
const likeReplaceEndTag = replaceEndTagIndex === lineLimit - 1
|
||||
if (likeReplaceEndTag) {
|
||||
// // 校验非标内容
|
||||
// if (!this.isReplacingActive()) {
|
||||
// removeLineCount += this.tryFixReplaceBlock(replaceEndTagIndex)
|
||||
// }
|
||||
const fixLines = this.pendingNonStandardLines.slice(replaceEndTagIndex - removeLineCount, lineLimit - removeLineCount)
|
||||
fixLines[fixLines.length - 1] = REPLACE_BLOCK_END
|
||||
for (const line of fixLines) {
|
||||
removeLineCount += this.internalProcessLine(line, false, replaceEndTagIndex - removeLineCount)
|
||||
}
|
||||
} else {
|
||||
throw new Error("Malformed SEARCH/REPLACE block structure: Missing valid closing REPLACE marker")
|
||||
}
|
||||
return removeLineCount
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes trailing empty lines from the pendingNonStandardLines array
|
||||
* @param lineLimit - The index to start checking from (exclusive).
|
||||
* Removes empty lines from lineLimit-1 backwards.
|
||||
* @returns The number of empty lines removed
|
||||
*/
|
||||
private trimPendingNonStandardTrailingEmptyLines(lineLimit: number): number {
|
||||
let removedCount = 0
|
||||
let i = Math.min(lineLimit, this.pendingNonStandardLines.length) - 1
|
||||
|
||||
while (i >= 0 && this.pendingNonStandardLines[i].trim() === "") {
|
||||
this.pendingNonStandardLines.pop()
|
||||
removedCount++
|
||||
i--
|
||||
}
|
||||
|
||||
return removedCount
|
||||
}
|
||||
}
|
||||
|
||||
export async function constructNewFileContentV2(
|
||||
diffContent: string,
|
||||
originalContent: string,
|
||||
isFinal: boolean,
|
||||
): Promise<{ newContent: string; matchIndices: number[] }> {
|
||||
const newFileContentConstructor = new NewFileContentConstructor(originalContent, isFinal)
|
||||
|
||||
const lines = diffContent.split("\n")
|
||||
|
||||
// If the last line looks like a partial marker but isn't recognized,
|
||||
// remove it because it might be incomplete.
|
||||
const lastLine = lines[lines.length - 1]
|
||||
if (
|
||||
lines.length > 0 &&
|
||||
(lastLine.startsWith(SEARCH_BLOCK_CHAR) ||
|
||||
lastLine.startsWith(LEGACY_SEARCH_BLOCK_CHAR) ||
|
||||
lastLine.startsWith("=") ||
|
||||
lastLine.startsWith(REPLACE_BLOCK_CHAR) ||
|
||||
lastLine.startsWith(LEGACY_REPLACE_BLOCK_CHAR)) &&
|
||||
lastLine !== SEARCH_BLOCK_START &&
|
||||
lastLine !== SEARCH_BLOCK_END &&
|
||||
lastLine !== REPLACE_BLOCK_END
|
||||
) {
|
||||
lines.pop()
|
||||
}
|
||||
|
||||
for (const line of lines) {
|
||||
newFileContentConstructor.processLine(line)
|
||||
}
|
||||
|
||||
const result = newFileContentConstructor.getResult()
|
||||
return result
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
import { expect } from "chai"
|
||||
import { describe, it } from "mocha"
|
||||
import { constructNewFileContent as cnfc } from "./diff"
|
||||
|
||||
async function cnfc2(diffContent: string, originalContent: string, isFinal: boolean): Promise<string> {
|
||||
const result = await cnfc(diffContent, originalContent, isFinal, "v2")
|
||||
return result.newContent
|
||||
}
|
||||
|
||||
describe("Diff Format Edge Cases", () => {
|
||||
it("should handle SEARCH prefix symbols - less than 7", async () => {
|
||||
const isFinal = true
|
||||
const original = "before\ncontent\nafter"
|
||||
const diff = `----- SEARCH
|
||||
content
|
||||
=======
|
||||
new content
|
||||
+++++++ REPLACE`
|
||||
const result1 = await cnfc(diff, original, isFinal)
|
||||
const result2 = await cnfc2(diff, original, isFinal)
|
||||
const expectedResult = "before\nnew content\nafter"
|
||||
expect(result1.newContent).to.equal(expectedResult)
|
||||
expect(result2).to.equal(expectedResult)
|
||||
})
|
||||
|
||||
it("should handle SEARCH prefix symbols - more than 7", async () => {
|
||||
const isFinal = true
|
||||
const original = "before\ncontent\nafter"
|
||||
const diff = `----------- SEARCH
|
||||
content
|
||||
=======
|
||||
new content
|
||||
+++++++ REPLACE`
|
||||
const result1 = await cnfc(diff, original, isFinal)
|
||||
const result2 = await cnfc2(diff, original, isFinal)
|
||||
const expectedResult = "before\nnew content\nafter"
|
||||
expect(result1.newContent).to.equal(expectedResult)
|
||||
expect(result2).to.equal(expectedResult)
|
||||
})
|
||||
|
||||
it("should handle SEARCH - less than 7 and REPLACE = less than 7", async () => {
|
||||
const isFinal = true
|
||||
const original = "before\ncontent\nafter"
|
||||
const diff = `----- SEARCH
|
||||
content
|
||||
=====
|
||||
new content
|
||||
+++++++ REPLACE`
|
||||
const result1 = await cnfc(diff, original, isFinal)
|
||||
const result2 = await cnfc2(diff, original, isFinal)
|
||||
const expectedResult = "before\nnew content\nafter"
|
||||
expect(result1.newContent).to.equal(expectedResult)
|
||||
expect(result2).to.equal(expectedResult)
|
||||
})
|
||||
|
||||
it("should handle SEARCH - less than 7 and REPLACE = more than 7", async () => {
|
||||
const isFinal = true
|
||||
const original = "before\ncontent\nafter"
|
||||
const diff = `----- SEARCH
|
||||
content
|
||||
========
|
||||
new content
|
||||
+++++++ REPLACE`
|
||||
const result1 = await cnfc(diff, original, isFinal)
|
||||
const result2 = await cnfc2(diff, original, isFinal)
|
||||
expect(result1.newContent).to.equal("before\nnew content\nafter")
|
||||
expect(result2).to.equal("before\nnew content\nafter")
|
||||
})
|
||||
|
||||
it("should handle SEARCH - more than 7 and REPLACE = more than 7", async () => {
|
||||
const isFinal = true
|
||||
const original = "before\ncontent\nafter"
|
||||
const diff = `----------- SEARCH
|
||||
content
|
||||
==========
|
||||
new content
|
||||
+++++++ REPLACE`
|
||||
const result1 = await cnfc(diff, original, isFinal)
|
||||
const result2 = await cnfc2(diff, original, isFinal)
|
||||
const expectedResult = "before\nnew content\nafter"
|
||||
expect(result1.newContent).to.equal(expectedResult)
|
||||
expect(result2).to.equal(expectedResult)
|
||||
})
|
||||
|
||||
it("should handle SEARCH - more than 7 and REPLACE = less than 7", async () => {
|
||||
const isFinal = true
|
||||
const original = "before\ncontent\nafter"
|
||||
const diff = `----------- SEARCH
|
||||
content
|
||||
=====
|
||||
new content
|
||||
+++++++ REPLACE`
|
||||
const result1 = await cnfc(diff, original, isFinal)
|
||||
const result2 = await cnfc2(diff, original, isFinal)
|
||||
const expectedResult = "before\nnew content\nafter"
|
||||
expect(result1.newContent).to.equal(expectedResult)
|
||||
expect(result2).to.equal(expectedResult)
|
||||
})
|
||||
|
||||
it("should handle consecutive SEARCH-REPLACE with second block SEARCH - less than 7", async () => {
|
||||
const isFinal = true
|
||||
const original = "before\nfirst content\nafter\nsecond content\nend"
|
||||
const diff = `------- SEARCH
|
||||
first content
|
||||
=======
|
||||
first new content
|
||||
+++++++ REPLACE
|
||||
----- SEARCH
|
||||
second content
|
||||
=======
|
||||
second new content
|
||||
+++++++ REPLACE`
|
||||
const result1 = await cnfc(diff, original, isFinal)
|
||||
const result2 = await cnfc2(diff, original, isFinal)
|
||||
const expectedResult = "before\nfirst new content\nafter\nsecond new content\nend"
|
||||
expect(result1.newContent).to.equal(expectedResult)
|
||||
expect(result2).to.equal(expectedResult)
|
||||
})
|
||||
|
||||
it("should handle consecutive SEARCH-REPLACE with second block SEARCH - less than 7 and REPLACE = less than 7", async () => {
|
||||
const isFinal = true
|
||||
const original = "before\nfirst content\nafter\nsecond content\nend"
|
||||
const diff = `------- SEARCH
|
||||
first content
|
||||
=======
|
||||
first new content
|
||||
+++++++ REPLACE
|
||||
----- SEARCH
|
||||
second content
|
||||
=====
|
||||
second new content
|
||||
+++++++ REPLACE`
|
||||
const result1 = await cnfc(diff, original, isFinal)
|
||||
const result2 = await cnfc2(diff, original, isFinal)
|
||||
const expectedResult = "before\nfirst new content\nafter\nsecond new content\nend"
|
||||
expect(result1.newContent).to.equal(expectedResult)
|
||||
expect(result2).to.equal(expectedResult)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,361 @@
|
||||
// import { constructNewFileContent as cnfc } from "./diff"
|
||||
// import { describe, it } from "mocha"
|
||||
// import { expect } from "chai"
|
||||
|
||||
// async function cnfc2(diffContent: string, originalContent: string, isFinal: boolean): Promise<string> {
|
||||
// return cnfc(diffContent, originalContent, isFinal, "v2")
|
||||
// }
|
||||
|
||||
// describe("Diff Format Edge Cases", () => {
|
||||
// it("should handle missing search block", async () => {
|
||||
// const original = "line1\nline2"
|
||||
// const diff = `=======
|
||||
// new content
|
||||
// +++++++ REPLACE`
|
||||
// const result1 = await cnfc(diff, original, true)
|
||||
// expect(result1).to.equal("new content\n")
|
||||
// try {
|
||||
// await cnfc2(diff, original, true)
|
||||
// expect.fail("Expected an error to be thrown")
|
||||
// } catch (err) {
|
||||
// expect(err).to.be.an("error")
|
||||
// }
|
||||
// })
|
||||
|
||||
// it("should handle consecutive search blocks", async () => {
|
||||
// const original = "text"
|
||||
// const diff = `------- SEARCH
|
||||
// =======
|
||||
// replaced
|
||||
// +++++++ REPLACE
|
||||
// ------- SEARCH
|
||||
// =======
|
||||
// another
|
||||
// +++++++ REPLACE`
|
||||
// const result1 = await cnfc(diff, original, true)
|
||||
// expect(result1).to.equal("replaced\nanother\n")
|
||||
// try {
|
||||
// await cnfc2(diff, original, true)
|
||||
// expect.fail("Expected an error to be thrown")
|
||||
// } catch (err) {
|
||||
// expect(err).to.be.an("error")
|
||||
// }
|
||||
// })
|
||||
|
||||
// it("should handle reverse markers order", async () => {
|
||||
// const original = "content"
|
||||
// const diff = `+++++++ SEARCH
|
||||
// =======
|
||||
// invalid
|
||||
// ------- REPLACE`
|
||||
// const result1 = await cnfc(diff, original, true)
|
||||
// expect(result1).to.equal("invalid\ncontent")
|
||||
// try {
|
||||
// await cnfc2(diff, original, true)
|
||||
// expect.fail("Expected an error to be thrown")
|
||||
// } catch (err) {
|
||||
// expect(err).to.be.an("error")
|
||||
// }
|
||||
// })
|
||||
|
||||
// it("should handle incomplete block structure", async () => {
|
||||
// const original = "valid text"
|
||||
// const diff = `------- SEARCH
|
||||
// text
|
||||
// +++++++ REPLACE`
|
||||
// const result1 = await cnfc(diff, original, true)
|
||||
// expect(result1).to.equal("t")
|
||||
// try {
|
||||
// await cnfc2(diff, original, true)
|
||||
// expect.fail("Expected an error to be thrown")
|
||||
// } catch (err) {
|
||||
// expect(err).to.be.an("error")
|
||||
// }
|
||||
// })
|
||||
|
||||
// it("should handle empty search block", async () => {
|
||||
// const original = "any content"
|
||||
// const diff = `------- SEARCH
|
||||
// =======
|
||||
// inserted
|
||||
// +++++++ REPLACE`
|
||||
// const result1 = await cnfc(diff, original, true)
|
||||
// const result2 = await cnfc2(diff, original, true)
|
||||
// expect(result1).to.equal("inserted\n")
|
||||
// expect(result1).to.equal(result2)
|
||||
// })
|
||||
|
||||
// it("should handle mixed line endings", async () => {
|
||||
// const original = "line1\r\nline2"
|
||||
// const diff = `------- SEARCH
|
||||
// line1\r
|
||||
// =======
|
||||
// line1
|
||||
// +++++++ REPLACE`
|
||||
// const result1 = await cnfc(diff, original, true)
|
||||
// const result2 = await cnfc2(diff, original, true)
|
||||
// expect(result1).to.equal("line1\nline2")
|
||||
// expect(result1).to.equal(result2)
|
||||
// })
|
||||
|
||||
// it("should handle special characters in search", async () => {
|
||||
// const original = "text with $^.*\nend"
|
||||
// const diff = `------- SEARCH
|
||||
// $^.*
|
||||
// =======
|
||||
// replaced
|
||||
// +++++++ REPLACE`
|
||||
// const result1 = await cnfc(diff, original, true)
|
||||
// const result2 = await cnfc2(diff, original, true)
|
||||
// expect(result1).to.equal("text with replaced\nend")
|
||||
// expect(result1).to.equal(result2)
|
||||
// })
|
||||
|
||||
// it("should handle special regex chars and nested search markers", async () => {
|
||||
// const original = `text with $^.*\n--- SEARCH\nend`
|
||||
// const diff = `------- SEARCH
|
||||
// $^.*
|
||||
// =======
|
||||
// replaced
|
||||
// +++++++ REPLACE
|
||||
|
||||
// ------- SEARCH
|
||||
// --- SEARCH
|
||||
// =======
|
||||
// before
|
||||
// +++++++ REPLACE`
|
||||
// const result1 = await cnfc(diff, original, true)
|
||||
// const result2 = await cnfc2(diff, original, true)
|
||||
// expect(result1).to.equal("text with replaced\nbefore\nend")
|
||||
// expect(result1).to.equal(result2)
|
||||
// })
|
||||
|
||||
// it("cnfc2 should handle invalid search marker format", async () => {
|
||||
// const original = `text with $^.*\n--- SEARCH\nend`
|
||||
// const diff = `--- SEARCH
|
||||
// $^.*
|
||||
// =======
|
||||
// replaced
|
||||
// +++++++ REPLACE
|
||||
|
||||
// ------- SEARCH
|
||||
// --- SEARCH
|
||||
// =======
|
||||
// before
|
||||
// +++++++ REPLACE`
|
||||
// try {
|
||||
// await cnfc(diff, original, true)
|
||||
// expect.fail("Expected an error to be thrown")
|
||||
// } catch (err) {
|
||||
// expect(err).to.be.an("error")
|
||||
// }
|
||||
// const result2 = await cnfc2(diff, original, true)
|
||||
// expect(result2).to.equal("text with replaced\nbefore\nend")
|
||||
// })
|
||||
|
||||
// it("cnfc2 should throw error for incomplete search marker", async () => {
|
||||
// const original = `text with $^.*\n--- SEARCH\nend`
|
||||
// const diff = `--- SEARCH
|
||||
// $^.*
|
||||
// =======
|
||||
// replaced
|
||||
// +++++++ REPLACE
|
||||
|
||||
// ------ SEARCH
|
||||
// --- SEARCH
|
||||
// =======
|
||||
// before
|
||||
// +++++++ REPLACE`
|
||||
// const result1 = await cnfc(diff, original, true)
|
||||
// expect(result1).to.equal("replaced\nbefore\n")
|
||||
// try {
|
||||
// await cnfc2(diff, original, true)
|
||||
// expect.fail("Expected an error to be thrown")
|
||||
// } catch (err) {
|
||||
// expect(err).to.be.an("error")
|
||||
// }
|
||||
// })
|
||||
|
||||
// it("cnfc2 should handle custom nested search markers", async () => {
|
||||
// const original = `text with $^.*\n--- SEARCH2\nend`
|
||||
// const diff = `--- SEARCH
|
||||
// $^.*
|
||||
// =======
|
||||
// replaced
|
||||
// +++++++ REPLACE
|
||||
|
||||
// ------ SEARCH
|
||||
// --- SEARCH2
|
||||
// =======
|
||||
// before
|
||||
// +++++++ REPLACE`
|
||||
// const result1 = await cnfc(diff, original, true)
|
||||
// const result2 = await cnfc2(diff, original, true)
|
||||
// expect(result1).to.equal("replaced\nbefore\n")
|
||||
// expect(result2).to.equal("text with replaced\nbefore\nend")
|
||||
// })
|
||||
|
||||
// it("cnfc2 should handle text containing nested search markers", async () => {
|
||||
// const original = `text with $^.*\ntext with --- SEARCH2\nend`
|
||||
// const diff = `--- SEARCH
|
||||
// $^.*
|
||||
// =======
|
||||
// replaced
|
||||
// +++++++ REPLACE
|
||||
|
||||
// ------ SEARCH
|
||||
// text with --- SEARCH2
|
||||
// =======
|
||||
// before
|
||||
// +++++++ REPLACE`
|
||||
// const result1 = await cnfc(diff, original, true)
|
||||
// const result2 = await cnfc2(diff, original, true)
|
||||
// expect(result1).to.equal("replaced\nbefore\n")
|
||||
// expect(result2).to.equal("text with replaced\nbefore\nend")
|
||||
// })
|
||||
|
||||
// it("cnfc2 should handle missing replacement marker in lenient mode", async () => {
|
||||
// const original = `text with $^.*\ntext with --- SEARCH2\nend`
|
||||
// const diff = `--- SEARCH
|
||||
// $^.*
|
||||
// =======
|
||||
// replaced
|
||||
// +++++++ REPLACE
|
||||
|
||||
// ------ SEARCH
|
||||
// text with --- SEARCH2
|
||||
// =======
|
||||
// before`
|
||||
// const result1 = await cnfc(diff, original, false)
|
||||
// const result2 = await cnfc2(diff, original, false)
|
||||
// expect(result1).to.equal("replaced\nbefore\n")
|
||||
// expect(result2).to.equal("text with replaced\nbefore\n")
|
||||
// })
|
||||
|
||||
// it("cnfc2 should throw error for missing replacement marker in strict mode", async () => {
|
||||
// const original = `text with $^.*\ntext with --- SEARCH2\nend`
|
||||
// const diff = `--- SEARCH
|
||||
// $^.*
|
||||
// =======
|
||||
// replaced
|
||||
// +++++++ REPLACE
|
||||
|
||||
// ------ SEARCH
|
||||
// text with --- SEARCH2
|
||||
// =======
|
||||
// before`
|
||||
// const result1 = await cnfc(diff, original, true)
|
||||
// expect(result1).to.equal("replaced\nbefore\n")
|
||||
// try {
|
||||
// await cnfc2(diff, original, true)
|
||||
// expect.fail("Expected an error to be thrown")
|
||||
// } catch (err) {
|
||||
// expect(err).to.be.an("error")
|
||||
// }
|
||||
// })
|
||||
|
||||
// it("cnfc2 should handle long text with multiple search-replace blocks", async () => {
|
||||
// const original = `This is a long text with multiple sections.
|
||||
// Section 1: Lorem ipsum dolor sit amet
|
||||
// Section 2: consectetur adipiscing elit
|
||||
// Section 3: sed do eiusmod tempor
|
||||
// Section 4: incididunt ut labore
|
||||
// Section 5: et dolore magna aliqua`
|
||||
|
||||
// const diff = `--- SEARCH
|
||||
// Section 1: Lorem ipsum dolor sit amet
|
||||
// =======
|
||||
// Section 1: Replaced text
|
||||
// +++++++ REPLACE
|
||||
|
||||
// ------- SEARCH
|
||||
// Section 3: sed do eiusmod tempor
|
||||
// =======
|
||||
// Section 3: Modified content
|
||||
// +++++++ REPLACE
|
||||
|
||||
// ------- SEARCH
|
||||
// Section 5: et dolore magna aliqua
|
||||
// =======
|
||||
// Section 5: Final replacement
|
||||
// +++++++ REPLACE`
|
||||
|
||||
// const expected = `This is a long text with multiple sections.
|
||||
// Section 1: Replaced text
|
||||
// Section 2: consectetur adipiscing elit
|
||||
// Section 3: Modified content
|
||||
// Section 4: incididunt ut labore
|
||||
// Section 5: Final replacement
|
||||
// `
|
||||
|
||||
// const result = await cnfc2(diff, original, true)
|
||||
// expect(result).to.equal(expected)
|
||||
// })
|
||||
|
||||
// // Test diff containing special regex characters and nested search markers
|
||||
// const diff = `--- SEARCH
|
||||
// $^.*
|
||||
// =======
|
||||
// replaced
|
||||
// +++++++ REPLACE
|
||||
|
||||
// ------ SEARCH
|
||||
// --- SEARCH
|
||||
// =======
|
||||
// before
|
||||
// +++++++ REPLACE`
|
||||
// // expected1 shows the incremental results when processing the diff line by line
|
||||
// // Each element represents the result after processing that line number
|
||||
// const expected1 = [
|
||||
// "",
|
||||
// "",
|
||||
// "",
|
||||
// "replaced\n",
|
||||
// "replaced\n",
|
||||
// "replaced\n",
|
||||
// "replaced\n",
|
||||
// "replaced\n",
|
||||
// "replaced\n",
|
||||
// "replaced\nbefore\n",
|
||||
// ]
|
||||
// // expected2 shows the results when processing with original content
|
||||
// // Each element represents the result after processing that line number
|
||||
// const expected2 = [
|
||||
// "",
|
||||
// "",
|
||||
// "text with ",
|
||||
// "text with replaced\n",
|
||||
// "text with replaced\n",
|
||||
// "text with replaced\n",
|
||||
// "text with replaced\n",
|
||||
// "text with replaced\n",
|
||||
// new Error(),
|
||||
// new Error(),
|
||||
// ]
|
||||
// const diffLines = diff.split("\n")
|
||||
// for (let i = 1; i < diffLines.length; i++) {
|
||||
// it(`cnfc2 should handle partial diff configuration (line ${i})`, async () => {
|
||||
// const original = `text with $^.*\n--- SEARCH\nend`
|
||||
// const result1 = await cnfc(diffLines.slice(0, i).join("\n"), original, i === diffLines.length - 1)
|
||||
// expect(result1).to.equal(expected1[i - 1])
|
||||
// })
|
||||
// }
|
||||
|
||||
// for (let i = 1; i < diffLines.length; i++) {
|
||||
// it(`cnfc2 should handle partial diff configuration (line ${i})`, async () => {
|
||||
// const original = `text with $^.*\n--- SEARCH\nend`
|
||||
// let expected = expected2[i - 1]
|
||||
// if (expected instanceof Error) {
|
||||
// try {
|
||||
// await cnfc2(diffLines.slice(0, i).join("\n"), original, true)
|
||||
// expect.fail("Expected an error to be thrown")
|
||||
// } catch (err) {
|
||||
// expect(err).to.be.an("error")
|
||||
// }
|
||||
// } else {
|
||||
// const result2 = await cnfc2(diffLines.slice(0, i).join("\n"), original, i === diffLines.length - 1)
|
||||
// expect(result2).to.equal(expected)
|
||||
// }
|
||||
// })
|
||||
// }
|
||||
// })
|
||||
@@ -0,0 +1,109 @@
|
||||
import { ClineDefaultTool } from "@shared/tools"
|
||||
|
||||
export type AssistantMessageContent = TextStreamContent | ToolUse | ReasoningStreamContent
|
||||
|
||||
export interface TextStreamContent {
|
||||
type: "text"
|
||||
content: string
|
||||
partial: boolean
|
||||
}
|
||||
|
||||
export const toolParamNames = [
|
||||
"command",
|
||||
"requires_approval",
|
||||
"path",
|
||||
"absolutePath",
|
||||
"content",
|
||||
"diff",
|
||||
"regex",
|
||||
"file_pattern",
|
||||
"recursive",
|
||||
"action",
|
||||
"url",
|
||||
"coordinate",
|
||||
"text",
|
||||
"query",
|
||||
"allowed_domains",
|
||||
"blocked_domains",
|
||||
"prompt",
|
||||
"server_name",
|
||||
"tool_name",
|
||||
"arguments",
|
||||
"uri",
|
||||
"question",
|
||||
"options",
|
||||
"response",
|
||||
"result",
|
||||
"context",
|
||||
"title",
|
||||
"what_happened",
|
||||
"steps_to_reproduce",
|
||||
"api_request_output",
|
||||
"additional_context",
|
||||
"needs_more_exploration",
|
||||
"task_progress",
|
||||
"timeout",
|
||||
"input",
|
||||
"from_ref",
|
||||
"to_ref",
|
||||
"skill_name",
|
||||
"prompt_1",
|
||||
"prompt_2",
|
||||
"prompt_3",
|
||||
"prompt_4",
|
||||
"prompt_5",
|
||||
"start_line",
|
||||
"end_line",
|
||||
] as const
|
||||
|
||||
export type ToolParamName = (typeof toolParamNames)[number]
|
||||
|
||||
export interface ToolUse {
|
||||
type: "tool_use"
|
||||
name: ClineDefaultTool // id of the tool being used
|
||||
// params is a partial record, allowing only some or none of the possible parameters to be used
|
||||
params: Partial<Record<ToolParamName, string>>
|
||||
partial: boolean
|
||||
/**
|
||||
* Whether this tool use was initiated by a native tool call
|
||||
*/
|
||||
isNativeToolCall?: boolean
|
||||
/**
|
||||
* The call / response ID this tool use is associated with.
|
||||
*/
|
||||
call_id?: string // optional call ID for tracking tool use calls
|
||||
/**
|
||||
* Thought signature associated with this tool use, used by Gemini
|
||||
*/
|
||||
signature?: string
|
||||
}
|
||||
|
||||
export interface ReasoningStreamContent {
|
||||
type: "reasoning"
|
||||
/**
|
||||
* The reasoning text generated by the model.
|
||||
* Redacted reasoning block will have this field set to "[REDACTED]" or an empty string.
|
||||
*/
|
||||
reasoning: string
|
||||
/**
|
||||
* openrouter has various properties that we can pass back unmodified in api requests to preserve reasoning traces
|
||||
*/
|
||||
details?: any
|
||||
/**
|
||||
* It's used when sending the thinking block back to the API.
|
||||
* API expects this in completed form, not as array of deltas.
|
||||
*/
|
||||
signature?: string
|
||||
/**
|
||||
* whether this reasoning block has been redacted
|
||||
*/
|
||||
redacted?: boolean
|
||||
/**
|
||||
* redacted data
|
||||
*/
|
||||
data?: string
|
||||
/**
|
||||
* Indicates whether this is a partial reasoning block
|
||||
*/
|
||||
partial: boolean
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,511 @@
|
||||
import { Anthropic } from "@anthropic-ai/sdk"
|
||||
import { ClineMessage } from "@shared/ExtensionMessage"
|
||||
import { expect } from "chai"
|
||||
import { ContextManager } from "../ContextManager"
|
||||
|
||||
// Minimal mock for ApiHandler — only getModel() fields are used by shouldCompactContextWindow
|
||||
function createMockApi(contextWindow: number, providerId?: string) {
|
||||
return {
|
||||
getModel: () => ({ id: "test-model", info: { contextWindow }, providerId }),
|
||||
} as any
|
||||
}
|
||||
|
||||
function createApiReqMessage(tokens: {
|
||||
tokensIn?: number
|
||||
tokensOut?: number
|
||||
cacheWrites?: number
|
||||
cacheReads?: number
|
||||
}): ClineMessage {
|
||||
return {
|
||||
ts: Date.now(),
|
||||
type: "say",
|
||||
say: "api_req_started",
|
||||
text: JSON.stringify(tokens),
|
||||
}
|
||||
}
|
||||
|
||||
describe("ContextManager", () => {
|
||||
function createMessages(count: number): Anthropic.Messages.MessageParam[] {
|
||||
const messages: Anthropic.Messages.MessageParam[] = []
|
||||
|
||||
messages.push({
|
||||
role: "user",
|
||||
content: "Initial task message",
|
||||
})
|
||||
|
||||
let role: "user" | "assistant" = "assistant"
|
||||
for (let i = 1; i < count; i++) {
|
||||
messages.push({
|
||||
role,
|
||||
content: `Message ${i}`,
|
||||
})
|
||||
role = role === "user" ? "assistant" : "user"
|
||||
}
|
||||
|
||||
return messages
|
||||
}
|
||||
|
||||
describe("getNextTruncationRange", () => {
|
||||
let contextManager: ContextManager
|
||||
|
||||
beforeEach(() => {
|
||||
contextManager = new ContextManager()
|
||||
})
|
||||
|
||||
it("first truncation with half keep", () => {
|
||||
const messages = createMessages(11)
|
||||
const result = contextManager.getNextTruncationRange(messages, undefined, "half")
|
||||
|
||||
expect(result).to.deep.equal([2, 5])
|
||||
})
|
||||
|
||||
it("first truncation with quarter keep", () => {
|
||||
const messages = createMessages(11)
|
||||
const result = contextManager.getNextTruncationRange(messages, undefined, "quarter")
|
||||
|
||||
expect(result).to.deep.equal([2, 7])
|
||||
})
|
||||
|
||||
it("sequential truncation with half keep", () => {
|
||||
const messages = createMessages(21)
|
||||
const firstRange = contextManager.getNextTruncationRange(messages, undefined, "half")
|
||||
expect(firstRange).to.deep.equal([2, 9])
|
||||
|
||||
// Pass the previous range for sequential truncation
|
||||
const secondRange = contextManager.getNextTruncationRange(messages, firstRange, "half")
|
||||
expect(secondRange).to.deep.equal([2, 13])
|
||||
})
|
||||
|
||||
it("sequential truncation with quarter keep", () => {
|
||||
const messages = createMessages(41)
|
||||
const firstRange = contextManager.getNextTruncationRange(messages, undefined, "quarter")
|
||||
|
||||
const secondRange = contextManager.getNextTruncationRange(messages, firstRange, "quarter")
|
||||
|
||||
expect(secondRange[0]).to.equal(2)
|
||||
expect(secondRange[1]).to.be.greaterThan(firstRange[1])
|
||||
})
|
||||
|
||||
it("ensures the last message in range is a user message", () => {
|
||||
const messages = createMessages(14)
|
||||
const result = contextManager.getNextTruncationRange(messages, undefined, "half")
|
||||
|
||||
// Check if the message at the end of range is an assistant message
|
||||
const lastRemovedMessage = messages[result[1]]
|
||||
expect(lastRemovedMessage.role).to.equal("assistant")
|
||||
|
||||
// Check if the next message after the range is a user message
|
||||
const nextMessage = messages[result[1] + 1]
|
||||
expect(nextMessage.role).to.equal("user")
|
||||
})
|
||||
|
||||
it("handles small message arrays", () => {
|
||||
const messages = createMessages(3)
|
||||
const result = contextManager.getNextTruncationRange(messages, undefined, "half")
|
||||
|
||||
expect(result).to.deep.equal([2, 1])
|
||||
})
|
||||
|
||||
it("preserves the message structure when truncating", () => {
|
||||
const messages = createMessages(20)
|
||||
const result = contextManager.getNextTruncationRange(messages, undefined, "half")
|
||||
|
||||
// Get messages after removing the range
|
||||
const effectiveMessages = [...messages.slice(0, result[0]), ...messages.slice(result[1] + 1)]
|
||||
|
||||
// Check first message and alternating pattern
|
||||
expect(effectiveMessages[0].role).to.equal("user")
|
||||
for (let i = 1; i < effectiveMessages.length; i++) {
|
||||
const expectedRole = i % 2 === 1 ? "assistant" : "user"
|
||||
expect(effectiveMessages[i].role).to.equal(expectedRole)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe("applyContextOptimizations", () => {
|
||||
let contextManager: ContextManager
|
||||
|
||||
beforeEach(() => {
|
||||
contextManager = new ContextManager()
|
||||
})
|
||||
|
||||
it("detects duplicate file reads across write_to_file, replace_in_file, and file mentions (normal tool calling)", () => {
|
||||
const messages: Anthropic.Messages.MessageParam[] = [
|
||||
{ role: "user", content: "Initial task" },
|
||||
{ role: "assistant", content: "Response" },
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "[write_to_file for 'test.txt'] Result:\nThe content was successfully saved to test.txt.\n\nHere is the full, updated content of the file that was saved:\n\n<final_file_content path=\"test.txt\">\ntest\n\n</final_file_content>",
|
||||
},
|
||||
{
|
||||
type: "text",
|
||||
text: "<environment_details>\n# Visual Studio Code Visible Files\ntest.txt\n\n# Current Mode\nACT MODE\n</environment_details>",
|
||||
},
|
||||
],
|
||||
},
|
||||
{ role: "assistant", content: "Response" },
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "[replace_in_file for 'test.txt'] Result:\nThe content was successfully saved to test.txt.\n\nHere is the full, updated content of the file that was saved:\n\n<final_file_content path=\"test.txt\">\ntest 2\n\n</final_file_content>",
|
||||
},
|
||||
{
|
||||
type: "text",
|
||||
text: "<environment_details>\n# Visual Studio Code Visible Files\ntest.txt\n\n# Current Mode\nACT MODE\n</environment_details>",
|
||||
},
|
||||
],
|
||||
},
|
||||
{ role: "assistant", content: "Response" },
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "[TASK RESUMPTION] This task was interrupted just now. The conversation may have been incomplete.",
|
||||
},
|
||||
{
|
||||
type: "text",
|
||||
text: "New message to respond to:\n<user_message>\n'test.txt' (see below for file content) tell me whats in this file\n</user_message>\n\n<file_content path=\"test.txt\">\ntest 2\n\n</file_content>",
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const timestamp = Date.now()
|
||||
const [didUpdate, indices] = contextManager.applyContextOptimizations(messages, 2, timestamp)
|
||||
|
||||
expect(didUpdate).to.equal(true)
|
||||
expect(indices.size).to.equal(2)
|
||||
expect(indices.has(2)).to.equal(true)
|
||||
expect(indices.has(4)).to.equal(true)
|
||||
expect(indices.has(6)).to.equal(false)
|
||||
})
|
||||
|
||||
it("returns false when no duplicate file reads exist", () => {
|
||||
const messages: Anthropic.Messages.MessageParam[] = [
|
||||
{ role: "user", content: "Initial task" },
|
||||
{ role: "assistant", content: "Response" },
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "[write_to_file for 'test.txt'] Result:\n<final_file_content path=\"test.txt\">\ntest\n\n</final_file_content>",
|
||||
},
|
||||
],
|
||||
},
|
||||
{ role: "assistant", content: "Response" },
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "[write_to_file for 'other.txt'] Result:\n<final_file_content path=\"other.txt\">\nother content\n\n</final_file_content>",
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const [didUpdate, indices] = contextManager.applyContextOptimizations(messages, 2, Date.now())
|
||||
|
||||
expect(didUpdate).to.equal(false)
|
||||
expect(indices.size).to.equal(0)
|
||||
})
|
||||
|
||||
it("returns false for empty messages beyond startFromIndex", () => {
|
||||
const messages: Anthropic.Messages.MessageParam[] = [
|
||||
{ role: "user", content: "Initial task" },
|
||||
{ role: "assistant", content: "Response" },
|
||||
]
|
||||
|
||||
const [didUpdate, indices] = contextManager.applyContextOptimizations(messages, 2, Date.now())
|
||||
|
||||
expect(didUpdate).to.equal(false)
|
||||
expect(indices.size).to.equal(0)
|
||||
})
|
||||
|
||||
it("detects duplicate file reads with native tool calling format (tool_result blocks)", () => {
|
||||
const messages: Anthropic.Messages.MessageParam[] = [
|
||||
{ role: "user", content: "Initial task" },
|
||||
{ role: "assistant", content: [{ type: "tool_use", id: "toolu_001", name: "plan_mode_respond", input: {} }] },
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
type: "tool_result",
|
||||
tool_use_id: "toolu_001",
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "[plan_mode_respond] Result:\n<user_message>\n'test2.txt' (see below for file content)\n</user_message>\n\n<file_content path=\"/Users/toshi/Desktop/cline_testing_repo/test2.txt\">\ntest\n\n</file_content>",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{ role: "assistant", content: [{ type: "tool_use", id: "toolu_002", name: "write_to_file", input: {} }] },
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
type: "tool_result",
|
||||
tool_use_id: "toolu_002",
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "[write_to_file for '/Users/toshi/Desktop/cline_testing_repo/test2.txt'] Result:\nThe content was successfully saved.\n\n<final_file_content path=\"/Users/toshi/Desktop/cline_testing_repo/test2.txt\">\ntest\n\n</final_file_content>",
|
||||
},
|
||||
],
|
||||
},
|
||||
{ type: "text", text: "<environment_details>\n# Current Mode\nACT MODE\n</environment_details>" },
|
||||
],
|
||||
},
|
||||
{ role: "assistant", content: [{ type: "tool_use", id: "toolu_003", name: "text", input: {} }] },
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "[TASK RESUMPTION] This task was interrupted just now. The conversation may have been incomplete.",
|
||||
},
|
||||
{ type: "text", text: "New message to respond to with plan_mode_respond tool" },
|
||||
],
|
||||
},
|
||||
{ role: "assistant", content: [{ type: "tool_use", id: "toolu_004", name: "replace_in_file", input: {} }] },
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
type: "tool_result",
|
||||
tool_use_id: "toolu_004",
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "[replace_in_file for '/Users/toshi/Desktop/cline_testing_repo/test2.txt'] Result:\nThe content was successfully saved.\n\n<final_file_content path=\"/Users/toshi/Desktop/cline_testing_repo/test2.txt\">\ntest2\n\n</final_file_content>",
|
||||
},
|
||||
],
|
||||
},
|
||||
{ type: "text", text: "<environment_details>\n# Current Mode\nACT MODE\n</environment_details>" },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const timestamp = Date.now()
|
||||
const [didUpdate, indices] = contextManager.applyContextOptimizations(messages, 2, timestamp)
|
||||
|
||||
expect(didUpdate).to.equal(true)
|
||||
expect(indices.size).to.equal(2)
|
||||
expect(indices.has(2)).to.equal(true)
|
||||
expect(indices.has(4)).to.equal(true)
|
||||
expect(indices.has(8)).to.equal(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("getTruncatedMessages", () => {
|
||||
let contextManager: ContextManager
|
||||
|
||||
beforeEach(() => {
|
||||
contextManager = new ContextManager()
|
||||
})
|
||||
|
||||
it("returns original messages when no range is provided", () => {
|
||||
const messages = createMessages(3)
|
||||
|
||||
const result = contextManager.getTruncatedMessages(messages, undefined)
|
||||
expect(result).to.deep.equal(messages)
|
||||
})
|
||||
|
||||
it("correctly removes messages in the specified range", () => {
|
||||
const messages = createMessages(5)
|
||||
|
||||
const range: [number, number] = [1, 3]
|
||||
const result = contextManager.getTruncatedMessages(messages, range)
|
||||
|
||||
expect(result).to.have.lengthOf(3)
|
||||
expect(result[0]).to.deep.equal(messages[0])
|
||||
expect(result[1]).to.deep.equal(messages[1])
|
||||
expect(result[2]).to.deep.equal(messages[4])
|
||||
})
|
||||
|
||||
it("works with a range that starts at the first message after task", () => {
|
||||
const messages = createMessages(4)
|
||||
|
||||
const range: [number, number] = [1, 2]
|
||||
const result = contextManager.getTruncatedMessages(messages, range)
|
||||
|
||||
expect(result).to.have.lengthOf(3)
|
||||
expect(result[0]).to.deep.equal(messages[0])
|
||||
expect(result[1]).to.deep.equal(messages[1])
|
||||
expect(result[2]).to.deep.equal(messages[3])
|
||||
})
|
||||
|
||||
it("correctly handles removing a range while preserving alternation pattern", () => {
|
||||
const messages = createMessages(5)
|
||||
|
||||
const range: [number, number] = [2, 3]
|
||||
const result = contextManager.getTruncatedMessages(messages, range)
|
||||
|
||||
expect(result).to.have.lengthOf(3)
|
||||
expect(result[0]).to.deep.equal(messages[0])
|
||||
expect(result[1]).to.deep.equal(messages[1])
|
||||
expect(result[2]).to.deep.equal(messages[4])
|
||||
|
||||
expect(result[0].role).to.equal("user")
|
||||
expect(result[1].role).to.equal("assistant")
|
||||
expect(result[2].role).to.equal("user")
|
||||
})
|
||||
|
||||
it("removes orphaned tool_results after truncation", () => {
|
||||
// Create messages with tool_use and tool_result blocks
|
||||
const messages: Anthropic.Messages.MessageParam[] = [
|
||||
{ role: "user", content: "Initial task" },
|
||||
{ role: "assistant", content: "Response 1" },
|
||||
// Assistant message with tool_use that will be truncated
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ type: "text", text: "Using a tool" },
|
||||
{ type: "tool_use", id: "tool_123", name: "read_file", input: { path: "test.ts" } },
|
||||
],
|
||||
},
|
||||
// User message with tool_result - should have tool_result removed after truncation
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{ type: "tool_result", tool_use_id: "tool_123", content: "file content here" },
|
||||
{ type: "text", text: "Additional user text" },
|
||||
],
|
||||
},
|
||||
{ role: "assistant", content: "Response 2" },
|
||||
]
|
||||
|
||||
// Truncate to remove the assistant message with tool_use
|
||||
const range: [number, number] = [2, 2]
|
||||
const result = contextManager.getTruncatedMessages(messages, range)
|
||||
|
||||
// Should have 4 messages (original 5 minus 1 truncated)
|
||||
expect(result).to.have.lengthOf(4)
|
||||
|
||||
// The user message at index 2 should have tool_result removed but text preserved
|
||||
const userMessageAfterTruncation = result[2]
|
||||
expect(userMessageAfterTruncation.role).to.equal("user")
|
||||
expect(Array.isArray(userMessageAfterTruncation.content)).to.be.true
|
||||
|
||||
const content = userMessageAfterTruncation.content as Anthropic.Messages.ContentBlockParam[]
|
||||
// Should only have the text block, not the tool_result
|
||||
expect(content).to.have.lengthOf(1)
|
||||
expect(content[0].type).to.equal("text")
|
||||
expect((content[0] as Anthropic.Messages.TextBlockParam).text).to.equal("Additional user text")
|
||||
})
|
||||
})
|
||||
|
||||
describe("shouldCompactContextWindow", () => {
|
||||
let contextManager: ContextManager
|
||||
|
||||
beforeEach(() => {
|
||||
contextManager = new ContextManager()
|
||||
})
|
||||
|
||||
it("does not compact at 33K tokens with default 0.75 threshold on 200K context", () => {
|
||||
const api = createMockApi(200_000)
|
||||
const clineMessages: ClineMessage[] = [createApiReqMessage({ tokensIn: 30_000, tokensOut: 3_000 })]
|
||||
|
||||
const result = contextManager.shouldCompactContextWindow(clineMessages, api, 0, 0.75)
|
||||
expect(result).to.equal(false)
|
||||
})
|
||||
|
||||
it("compacts when tokens exceed 0.75 threshold on 200K context", () => {
|
||||
const api = createMockApi(200_000)
|
||||
const clineMessages: ClineMessage[] = [createApiReqMessage({ tokensIn: 140_000, tokensOut: 15_000 })]
|
||||
|
||||
const result = contextManager.shouldCompactContextWindow(clineMessages, api, 0, 0.75)
|
||||
expect(result).to.equal(true)
|
||||
})
|
||||
|
||||
it("compacts at only 10K tokens when threshold is accidentally set to 0.05", () => {
|
||||
const contextWindow = 200_000
|
||||
const accidentalThreshold = 0.05
|
||||
// floor(200000 * 0.05) = 10000 — this is the bug case from PR #9348.
|
||||
// Accidental clicks on the progress bar set threshold to ~5%, triggering
|
||||
// compaction at 10K tokens instead of the intended 150K (0.75 * 200K).
|
||||
const compactionTriggersAt = Math.floor(contextWindow * accidentalThreshold) // 10,000
|
||||
const totalTokens = compactionTriggersAt + 500 // 10,500 — just above the trigger
|
||||
|
||||
const api = createMockApi(contextWindow)
|
||||
const tokensIn = totalTokens - 1_500
|
||||
const tokensOut = 1_500
|
||||
const clineMessages: ClineMessage[] = [createApiReqMessage({ tokensIn, tokensOut })]
|
||||
|
||||
const result = contextManager.shouldCompactContextWindow(clineMessages, api, 0, accidentalThreshold)
|
||||
expect(result).to.equal(true)
|
||||
})
|
||||
|
||||
it("falls back to maxAllowedSize when threshold is undefined", () => {
|
||||
const api = createMockApi(200_000)
|
||||
// 155K tokens — above 0.75 threshold (150K) but below maxAllowedSize (160K)
|
||||
const clineMessages: ClineMessage[] = [createApiReqMessage({ tokensIn: 150_000, tokensOut: 5_000 })]
|
||||
|
||||
const result = contextManager.shouldCompactContextWindow(clineMessages, api, 0, undefined)
|
||||
// undefined → uses maxAllowedSize (160K), so 155K < 160K → false
|
||||
expect(result).to.equal(false)
|
||||
})
|
||||
|
||||
it("falls back to maxAllowedSize when threshold is 0", () => {
|
||||
const api = createMockApi(200_000)
|
||||
const clineMessages: ClineMessage[] = [createApiReqMessage({ tokensIn: 150_000, tokensOut: 5_000 })]
|
||||
|
||||
// 0 is falsy, so ternary falls back to maxAllowedSize (160K)
|
||||
const result = contextManager.shouldCompactContextWindow(clineMessages, api, 0, 0)
|
||||
expect(result).to.equal(false)
|
||||
})
|
||||
|
||||
it("includes cacheWrites and cacheReads in total token count", () => {
|
||||
const api = createMockApi(200_000)
|
||||
// Low direct tokens but high cache reads push total over threshold
|
||||
const clineMessages: ClineMessage[] = [
|
||||
createApiReqMessage({ tokensIn: 5_000, tokensOut: 500, cacheWrites: 0, cacheReads: 150_000 }),
|
||||
]
|
||||
|
||||
const result = contextManager.shouldCompactContextWindow(clineMessages, api, 0, 0.75)
|
||||
expect(result).to.equal(true)
|
||||
})
|
||||
|
||||
it("returns false when previousApiReqIndex is negative", () => {
|
||||
const api = createMockApi(200_000)
|
||||
const clineMessages: ClineMessage[] = [createApiReqMessage({ tokensIn: 200_000 })]
|
||||
|
||||
const result = contextManager.shouldCompactContextWindow(clineMessages, api, -1, 0.75)
|
||||
expect(result).to.equal(false)
|
||||
})
|
||||
|
||||
it("threshold is capped at maxAllowedSize even when percentage is very high", () => {
|
||||
const api = createMockApi(200_000)
|
||||
// threshold of 1.0 → floor(200000 * 1.0) = 200000, but min(200000, 160000) = 160000
|
||||
const clineMessages: ClineMessage[] = [createApiReqMessage({ tokensIn: 165_000 })]
|
||||
|
||||
const result = contextManager.shouldCompactContextWindow(clineMessages, api, 0, 1.0)
|
||||
expect(result).to.equal(true)
|
||||
})
|
||||
|
||||
it("compacts OpenAI Codex OAuth 400K models before their 272K input cap", () => {
|
||||
const api = createMockApi(400_000, "openai-codex")
|
||||
const clineMessages: ClineMessage[] = [createApiReqMessage({ tokensIn: 250_000 })]
|
||||
|
||||
const result = contextManager.shouldCompactContextWindow(clineMessages, api, 0, 0.75)
|
||||
expect(result).to.equal(true)
|
||||
})
|
||||
|
||||
it("keeps the normal threshold for non-Codex 400K models", () => {
|
||||
const api = createMockApi(400_000, "openai-native")
|
||||
const clineMessages: ClineMessage[] = [createApiReqMessage({ tokensIn: 250_000 })]
|
||||
|
||||
const result = contextManager.shouldCompactContextWindow(clineMessages, api, 0, 0.75)
|
||||
expect(result).to.equal(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
import { expect } from "chai"
|
||||
import { checkContextWindowExceededError } from "../context-error-handling"
|
||||
|
||||
describe("checkContextWindowExceededError", () => {
|
||||
it("detects OpenRouter context errors using structured status", () => {
|
||||
const error = Object.assign(
|
||||
new Error("This endpoint's maximum context length is 204800 tokens. However, you requested about 244027 tokens."),
|
||||
{
|
||||
status: 400,
|
||||
},
|
||||
)
|
||||
|
||||
expect(checkContextWindowExceededError(error)).to.equal(true)
|
||||
})
|
||||
|
||||
it("detects OpenRouter JSON-encoded status + context length errors", () => {
|
||||
const error = new Error(
|
||||
'OpenRouter Mid-Stream Error: {"status":400,"message":"This endpoint\'s maximum context length is 200000 tokens"}',
|
||||
)
|
||||
|
||||
expect(checkContextWindowExceededError(error)).to.equal(true)
|
||||
})
|
||||
|
||||
it("does not classify unrelated 400 errors as context window failures", () => {
|
||||
const error = new Error("OpenRouter API Error 400: Invalid API key")
|
||||
|
||||
expect(checkContextWindowExceededError(error)).to.equal(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,175 @@
|
||||
import LengthFinishReasonError, { APIError } from "openai"
|
||||
|
||||
export function checkContextWindowExceededError(error: unknown): boolean {
|
||||
return (
|
||||
checkIsOpenAIContextWindowError(error) ||
|
||||
checkIsOpenRouterContextWindowError(error) ||
|
||||
checkIsAnthropicContextWindowError(error) ||
|
||||
checkIsCerebrasContextWindowError(error) ||
|
||||
checkIsBedrockContextWindowError(error) ||
|
||||
checkIsVercelContextWindowError(error)
|
||||
)
|
||||
}
|
||||
|
||||
function checkIsOpenRouterContextWindowError(error: any): boolean {
|
||||
try {
|
||||
// OpenRouter errors can reach us in two shapes:
|
||||
// 1) Direct chunk.error path wrapped as Error with status/code attached.
|
||||
// 2) Mid-stream finish_reason="error" path where JSON is stringified into message.
|
||||
// So we check structured status first, then JSON-encoded status/code in message text.
|
||||
const status = error?.status ?? error?.code ?? error?.error?.status ?? error?.response?.status
|
||||
const message: string = String(error?.message || error?.error?.message || "")
|
||||
|
||||
// Handle JSON-encoded errors where status/code is embedded in the message string.
|
||||
const statusFromMessage = message.match(/"code":\s*(\d+)/)?.[1] ?? message.match(/"status":\s*(\d+)/)?.[1]
|
||||
const finalStatus = statusFromMessage || status
|
||||
|
||||
// Known OpenAI/OpenRouter-style signal (code 400 and message includes "context length")
|
||||
const CONTEXT_ERROR_PATTERNS = [
|
||||
/\bcontext\s*(?:length|window)\b/i,
|
||||
/\bmaximum\s*context\b/i,
|
||||
/\b(?:input\s*)?tokens?\s*exceed/i,
|
||||
/\btoo\s*many\s*tokens?\b/i,
|
||||
] as const
|
||||
|
||||
return String(finalStatus) === "400" && CONTEXT_ERROR_PATTERNS.some((pattern) => pattern.test(message))
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Docs: https://platform.openai.com/docs/guides/error-codes/api-errors
|
||||
function checkIsOpenAIContextWindowError(error: unknown): boolean {
|
||||
try {
|
||||
if (error instanceof LengthFinishReasonError) {
|
||||
return true
|
||||
}
|
||||
|
||||
const KNOWN_CONTEXT_ERROR_SUBSTRINGS = ["token", "context length"] as const
|
||||
|
||||
return (
|
||||
Boolean(error) &&
|
||||
error instanceof APIError &&
|
||||
error.code?.toString() === "400" &&
|
||||
KNOWN_CONTEXT_ERROR_SUBSTRINGS.some((substring) => error.message.includes(substring))
|
||||
)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function checkIsAnthropicContextWindowError(response: any): boolean {
|
||||
try {
|
||||
return response?.error?.error?.type === "invalid_request_error"
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function checkIsCerebrasContextWindowError(response: any): boolean {
|
||||
try {
|
||||
const status = response?.status ?? response?.code ?? response?.error?.status ?? response?.response?.status
|
||||
const message: string = String(response?.message || response?.error?.message || "")
|
||||
|
||||
return String(status) === "400" && message.includes("Please reduce the length of the messages or completion")
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function checkIsBedrockContextWindowError(error: any): boolean {
|
||||
try {
|
||||
// Bedrock returns ValidationException for context window errors
|
||||
const errorType = error?.name ?? error?.error?.type ?? error?.__type
|
||||
const errorCode = error?.code ?? error?.error?.code ?? error?.$metadata?.httpStatusCode
|
||||
|
||||
// Handle nested error structures (e.g., through Vercel AI SDK)
|
||||
const nestedError = error?.error?.param
|
||||
const nestedErrorCode = nestedError?.statusCode ?? error?.details?.code
|
||||
const nestedMessage = nestedError?.message ?? nestedError?.error
|
||||
|
||||
const message: string = String(error?.message || error?.error?.message || nestedMessage || "")
|
||||
|
||||
// Check for ValidationException with HTTP 400
|
||||
const isValidationException =
|
||||
errorType === "ValidationException" ||
|
||||
errorType === "AI_APICallError" ||
|
||||
String(errorCode) === "400" ||
|
||||
String(nestedErrorCode) === "400" ||
|
||||
error?.code === "stream_initialization_failed"
|
||||
|
||||
if (!isValidationException) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Known Bedrock context window error patterns
|
||||
const BEDROCK_CONTEXT_PATTERNS = [
|
||||
/maximum tokens.*exceeds.*model limit/i,
|
||||
/input length and max_tokens exceed context limit/i,
|
||||
/context length.*exceeds/i,
|
||||
/total number of tokens.*exceeds.*limit/i,
|
||||
/requested.*tokens.*exceeds.*limit/i,
|
||||
/reduce.*length.*messages.*completion/i,
|
||||
/input is too long/i,
|
||||
] as const
|
||||
|
||||
return BEDROCK_CONTEXT_PATTERNS.some((pattern) => pattern.test(message))
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export function checkIsVercelContextWindowError(error: any): boolean {
|
||||
try {
|
||||
const status = error?.status ?? error?.error?.param?.statusCode ?? error?.statusCode
|
||||
|
||||
// Check for explicit context_length_exceeded code (OpenAI streaming errors)
|
||||
const errorCode = error?.error?.error?.code
|
||||
if (errorCode === "context_length_exceeded") {
|
||||
return true
|
||||
}
|
||||
|
||||
const messages: string[] = [
|
||||
error?.message,
|
||||
error?.error?.message,
|
||||
error?.error?.param?.message,
|
||||
error?.error?.param?.error,
|
||||
error?.error?.error?.message,
|
||||
error?.error?.value?.error_message, // Alibaba Qwen validation errors
|
||||
].filter((msg) => msg != null)
|
||||
|
||||
if (messages.length === 0) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Must be a 400 error OR have 400 embedded in error_message (Alibaba Qwen case)
|
||||
const hasValidStatus = String(status) === "400"
|
||||
const errorMessage = error?.error?.value?.error_message
|
||||
const has400InMessage =
|
||||
errorMessage &&
|
||||
typeof errorMessage === "string" &&
|
||||
(errorMessage.includes('"code":400') || errorMessage.includes('"code": 400'))
|
||||
|
||||
if (!hasValidStatus && !has400InMessage) {
|
||||
return false
|
||||
}
|
||||
|
||||
const CONTEXT_ERROR_PATTERNS = [
|
||||
/input is too long/i,
|
||||
/input token count exceeds.*maximum.*tokens? allowed/i,
|
||||
/input exceeds.*context window/i,
|
||||
/requested input length.*exceeds.*maximum input length/i,
|
||||
/prompt is too long.*tokens?\s*>\s*\d+\s*maximum/i,
|
||||
/\bcontext\s*(?:length|window)\b.*exceed/i,
|
||||
/\bmaximum\s*context\b/i,
|
||||
/\b(?:input\s*)?tokens?\s*exceed/i,
|
||||
/too\s*many\s*tokens/i,
|
||||
] as const
|
||||
|
||||
return messages
|
||||
.map((msg) => String(msg).toLowerCase())
|
||||
.some((message) => CONTEXT_ERROR_PATTERNS.some((pattern) => pattern.test(message)))
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { ApiHandler } from "@core/api"
|
||||
|
||||
/**
|
||||
* Gets context window information for the given API handler
|
||||
*
|
||||
* @param api The API handler to get context window information for
|
||||
* @returns An object containing the raw context window size and the effective max allowed size
|
||||
*/
|
||||
export function getContextWindowInfo(api: ApiHandler) {
|
||||
const model = api.getModel()
|
||||
const contextWindow = model.info.contextWindow || 128_000
|
||||
const isOpenAiCodexOAuth = model.providerId === "openai-codex"
|
||||
const defaultMaxAllowedSize = Math.max(contextWindow - 40_000, contextWindow * 0.8)
|
||||
|
||||
let maxAllowedSize: number
|
||||
switch (contextWindow) {
|
||||
case 64_000: // deepseek models
|
||||
maxAllowedSize = contextWindow - 27_000
|
||||
break
|
||||
case 128_000: // most models
|
||||
maxAllowedSize = contextWindow - 30_000
|
||||
break
|
||||
case 200_000: // claude models
|
||||
maxAllowedSize = contextWindow - 40_000
|
||||
break
|
||||
case 400_000:
|
||||
// OpenAI Codex OAuth has a 272K input cap inside the 400K total context window.
|
||||
maxAllowedSize = isOpenAiCodexOAuth ? 272_000 - 40_000 : defaultMaxAllowedSize
|
||||
break
|
||||
default:
|
||||
maxAllowedSize = defaultMaxAllowedSize // for deepseek, 80% of 64k meant only ~10k buffer which was too small and resulted in users getting context window errors.
|
||||
}
|
||||
|
||||
return { contextWindow, maxAllowedSize }
|
||||
}
|
||||
@@ -8,14 +8,14 @@ export interface FileMetadataEntry {
|
||||
user_edit_date?: number | null
|
||||
}
|
||||
|
||||
interface ModelMetadataEntry {
|
||||
export interface ModelMetadataEntry {
|
||||
ts: number
|
||||
model_id: string
|
||||
model_provider_id: string
|
||||
mode: string
|
||||
}
|
||||
|
||||
interface EnvironmentMetadataEntry {
|
||||
export interface EnvironmentMetadataEntry {
|
||||
ts: number
|
||||
os_name: string
|
||||
os_version: string
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { collectEnvironmentMetadata, getTaskMetadata, saveTaskMetadata } from "@core/storage/disk"
|
||||
import type { EnvironmentMetadataEntry } from "./ContextTrackerTypes"
|
||||
|
||||
export class EnvironmentContextTracker {
|
||||
readonly taskId: string
|
||||
|
||||
constructor(taskId: string) {
|
||||
this.taskId = taskId
|
||||
}
|
||||
|
||||
async recordEnvironment() {
|
||||
const metadata = await getTaskMetadata(this.taskId)
|
||||
|
||||
if (!metadata.environment_history) {
|
||||
metadata.environment_history = []
|
||||
}
|
||||
|
||||
const currentEnv = await collectEnvironmentMetadata()
|
||||
const currentEnvWithTs: EnvironmentMetadataEntry = {
|
||||
ts: Date.now(),
|
||||
...currentEnv,
|
||||
}
|
||||
|
||||
const lastEntry = metadata.environment_history[metadata.environment_history.length - 1]
|
||||
if (lastEntry && this.isSameEnvironment(lastEntry, currentEnvWithTs)) {
|
||||
return // No change, don't add duplicate
|
||||
}
|
||||
|
||||
metadata.environment_history.push(currentEnvWithTs)
|
||||
await saveTaskMetadata(this.taskId, metadata)
|
||||
}
|
||||
|
||||
private isSameEnvironment(a: EnvironmentMetadataEntry, b: EnvironmentMetadataEntry): boolean {
|
||||
return (
|
||||
a.os_name === b.os_name &&
|
||||
a.os_version === b.os_version &&
|
||||
a.os_arch === b.os_arch &&
|
||||
a.host_name === b.host_name &&
|
||||
a.host_version === b.host_version &&
|
||||
a.cline_version === b.cline_version
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
import * as diskModule from "@core/storage/disk"
|
||||
import { expect } from "chai"
|
||||
import { afterEach, beforeEach, describe, it } from "mocha"
|
||||
import * as sinon from "sinon"
|
||||
import type { TaskMetadata } from "./ContextTrackerTypes"
|
||||
import { ModelContextTracker } from "./ModelContextTracker"
|
||||
|
||||
describe("ModelContextTracker", () => {
|
||||
const taskId = "test-task-id"
|
||||
let sandbox: sinon.SinonSandbox
|
||||
let tracker: ModelContextTracker
|
||||
let mockTaskMetadata: TaskMetadata
|
||||
let getTaskMetadataStub: sinon.SinonStub
|
||||
let saveTaskMetadataStub: sinon.SinonStub
|
||||
|
||||
beforeEach(() => {
|
||||
sandbox = sinon.createSandbox()
|
||||
|
||||
// Mock disk module functions
|
||||
mockTaskMetadata = { files_in_context: [], model_usage: [], environment_history: [] }
|
||||
getTaskMetadataStub = sandbox.stub(diskModule, "getTaskMetadata").resolves(mockTaskMetadata)
|
||||
saveTaskMetadataStub = sandbox.stub(diskModule, "saveTaskMetadata").resolves()
|
||||
|
||||
// Create tracker instance
|
||||
tracker = new ModelContextTracker(taskId)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
sandbox.restore()
|
||||
})
|
||||
|
||||
it("should record model usage with correct data", async () => {
|
||||
// Test data
|
||||
const apiProviderId = "anthropic"
|
||||
const modelId = "claude-3-opus"
|
||||
const mode = "act"
|
||||
|
||||
// Use a fake timer to have a predictable timestamp
|
||||
const fakeNow = 1617293940000 // Some fixed timestamp
|
||||
const clock = sandbox.useFakeTimers(fakeNow)
|
||||
|
||||
try {
|
||||
// Call the method being tested
|
||||
await tracker.recordModelUsage(apiProviderId, modelId, mode)
|
||||
|
||||
// Verify getTaskMetadata was called with correct parameters
|
||||
expect(getTaskMetadataStub.calledOnce).to.be.true
|
||||
expect(getTaskMetadataStub.firstCall.args[0]).to.equal(taskId)
|
||||
|
||||
// Verify saveTaskMetadata was called with the correct data
|
||||
expect(saveTaskMetadataStub.calledOnce).to.be.true
|
||||
|
||||
// Extract the saved metadata from the call arguments
|
||||
const savedMetadata = saveTaskMetadataStub.firstCall.args[1]
|
||||
|
||||
// Verify model_usage array has one entry
|
||||
expect(savedMetadata.model_usage.length).to.equal(1)
|
||||
|
||||
// Verify the entry has the correct properties
|
||||
const modelUsageEntry = savedMetadata.model_usage[0]
|
||||
expect(modelUsageEntry.ts).to.equal(fakeNow)
|
||||
expect(modelUsageEntry.model_id).to.equal(modelId)
|
||||
expect(modelUsageEntry.model_provider_id).to.equal(apiProviderId)
|
||||
expect(modelUsageEntry.mode).to.equal(mode)
|
||||
} finally {
|
||||
// Restore the clock
|
||||
clock.restore()
|
||||
}
|
||||
})
|
||||
|
||||
it("should append model usage to existing entries", async () => {
|
||||
// Add an existing model usage entry
|
||||
const existingTimestamp = 1617200000000
|
||||
mockTaskMetadata.model_usage = [
|
||||
{
|
||||
ts: existingTimestamp,
|
||||
model_id: "existing-model",
|
||||
model_provider_id: "existing-provider",
|
||||
mode: "plan",
|
||||
},
|
||||
]
|
||||
|
||||
// Test data for new entry
|
||||
const apiProviderId = "anthropic"
|
||||
const modelId = "claude-3-sonnet"
|
||||
const mode = "act"
|
||||
|
||||
// Use a fake timer
|
||||
const newTimestamp = 1617300000000
|
||||
const clock = sandbox.useFakeTimers(newTimestamp)
|
||||
|
||||
try {
|
||||
// Call the method being tested
|
||||
await tracker.recordModelUsage(apiProviderId, modelId, mode)
|
||||
|
||||
// Verify saveTaskMetadata was called
|
||||
expect(saveTaskMetadataStub.calledOnce).to.be.true
|
||||
|
||||
// Extract the saved metadata
|
||||
const savedMetadata = saveTaskMetadataStub.firstCall.args[1]
|
||||
|
||||
// Verify model_usage array now has two entries
|
||||
expect(savedMetadata.model_usage.length).to.equal(2)
|
||||
|
||||
// Verify the existing entry is preserved
|
||||
expect(savedMetadata.model_usage[0]).to.deep.equal({
|
||||
ts: existingTimestamp,
|
||||
model_id: "existing-model",
|
||||
model_provider_id: "existing-provider",
|
||||
mode: "plan",
|
||||
})
|
||||
|
||||
// Verify the new entry has correct data
|
||||
expect(savedMetadata.model_usage[1]).to.deep.equal({
|
||||
ts: newTimestamp,
|
||||
model_id: modelId,
|
||||
model_provider_id: apiProviderId,
|
||||
mode: mode,
|
||||
})
|
||||
} finally {
|
||||
clock.restore()
|
||||
}
|
||||
})
|
||||
|
||||
it("should handle multiple model usages in sequence", async () => {
|
||||
// Test data for sequential calls
|
||||
const usages = [
|
||||
{ provider: "anthropic", model: "claude-3-opus", mode: "plan" },
|
||||
{ provider: "openai", model: "gpt-4", mode: "act" },
|
||||
{ provider: "anthropic", model: "claude-3-haiku", mode: "plan" },
|
||||
]
|
||||
|
||||
// Use a fake timer that advances with each call
|
||||
const startTime = 1617300000000
|
||||
const clock = sandbox.useFakeTimers(startTime)
|
||||
|
||||
try {
|
||||
// Record multiple model usages
|
||||
for (let i = 0; i < usages.length; i++) {
|
||||
const { provider, model, mode } = usages[i]
|
||||
|
||||
// Advance time by 1 second for each call
|
||||
clock.tick(1000)
|
||||
const expectedTime = startTime + (i + 1) * 1000
|
||||
|
||||
// Reset history between calls to check individual call behavior
|
||||
getTaskMetadataStub.resetHistory()
|
||||
saveTaskMetadataStub.resetHistory()
|
||||
|
||||
// Reset mock metadata for each iteration to avoid accumulation
|
||||
mockTaskMetadata.model_usage = []
|
||||
|
||||
// Call the method
|
||||
await tracker.recordModelUsage(provider, model, mode)
|
||||
|
||||
// Verify interaction with disk module
|
||||
expect(getTaskMetadataStub.calledOnce).to.be.true
|
||||
expect(saveTaskMetadataStub.calledOnce).to.be.true
|
||||
|
||||
// Get the saved metadata
|
||||
const savedMetadata = saveTaskMetadataStub.firstCall.args[1]
|
||||
|
||||
// Since we reset the array for each call, we should always have 1 entry
|
||||
expect(savedMetadata.model_usage.length).to.equal(1)
|
||||
|
||||
// Check the entry
|
||||
const entry = savedMetadata.model_usage[0]
|
||||
expect(entry.ts).to.equal(expectedTime)
|
||||
expect(entry.model_id).to.equal(model)
|
||||
expect(entry.model_provider_id).to.equal(provider)
|
||||
expect(entry.mode).to.equal(mode)
|
||||
}
|
||||
} finally {
|
||||
clock.restore()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,37 @@
|
||||
import { getTaskMetadata, saveTaskMetadata } from "@core/storage/disk"
|
||||
|
||||
export class ModelContextTracker {
|
||||
readonly taskId: string
|
||||
|
||||
constructor(taskId: string) {
|
||||
this.taskId = taskId
|
||||
}
|
||||
|
||||
async recordModelUsage(apiProviderId: string, modelId: string, mode: string) {
|
||||
const metadata = await getTaskMetadata(this.taskId)
|
||||
|
||||
if (!metadata.model_usage) {
|
||||
metadata.model_usage = []
|
||||
}
|
||||
|
||||
// check to see if the last entry is the same as the new one
|
||||
const lastEntry = metadata.model_usage[metadata.model_usage.length - 1]
|
||||
if (
|
||||
lastEntry &&
|
||||
lastEntry.model_id === modelId &&
|
||||
lastEntry.model_provider_id === apiProviderId &&
|
||||
lastEntry.mode === mode
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
metadata.model_usage.push({
|
||||
ts: Date.now(),
|
||||
model_id: modelId,
|
||||
model_provider_id: apiProviderId,
|
||||
mode: mode,
|
||||
})
|
||||
|
||||
await saveTaskMetadata(this.taskId, metadata)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
import { HostProvider } from "@/hosts/host-provider"
|
||||
import { extractPathLikeStrings, RuleEvaluationContext, toWorkspaceRelativePosixPath } from "./rule-conditionals"
|
||||
|
||||
type WorkspaceRoot = { path: string }
|
||||
type WorkspaceManagerLike = { getRoots(): WorkspaceRoot[] }
|
||||
|
||||
type ClineMessageLike = {
|
||||
type: string
|
||||
ask?: string
|
||||
say?: string
|
||||
text?: string
|
||||
}
|
||||
|
||||
type MessageStateHandlerLike = {
|
||||
getClineMessages(): ClineMessageLike[]
|
||||
}
|
||||
|
||||
export type RuleContextBuilderDeps = {
|
||||
cwd: string
|
||||
messageStateHandler: MessageStateHandlerLike
|
||||
workspaceManager?: WorkspaceManagerLike
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the evaluation context used for conditional Cline Rules (e.g. YAML frontmatter `paths:`).
|
||||
*
|
||||
* Kept in the user-instructions domain so Task remains orchestration-focused.
|
||||
*
|
||||
* Path context is gathered from multiple sources in clineMessages:
|
||||
* - User messages (task, user_feedback)
|
||||
* - Visible/open tabs
|
||||
* - Tool results (say="tool") - completed operations
|
||||
* - Tool requests (ask="tool") - pending operations (captures intent before execution)
|
||||
*/
|
||||
export class RuleContextBuilder {
|
||||
/**
|
||||
* Maximum number of path candidates to consider for rule activation.
|
||||
* This cap prevents performance degradation in long-running tasks with many file operations.
|
||||
*/
|
||||
static readonly MAX_RULE_PATH_CANDIDATES = 100
|
||||
|
||||
static async buildEvaluationContext(deps: RuleContextBuilderDeps): Promise<RuleEvaluationContext> {
|
||||
return {
|
||||
paths: await RuleContextBuilder.getRulePathContext(deps),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse apply_patch input to extract target file paths from patch headers.
|
||||
* Matches lines like: *** Add File: path/to/file.ts
|
||||
*/
|
||||
private static extractPathsFromApplyPatch(input: string): string[] {
|
||||
if (typeof input !== "string" || !input) return []
|
||||
|
||||
const paths: string[] = []
|
||||
const fileHeaderRegex = /^\*\*\* (?:Add|Update|Delete) File: (.+?)(?:\n|$)/gm
|
||||
let m: RegExpExecArray | null
|
||||
while ((m = fileHeaderRegex.exec(input))) {
|
||||
const filePath = (m[1] || "").trim()
|
||||
if (filePath) {
|
||||
paths.push(filePath)
|
||||
}
|
||||
}
|
||||
return paths
|
||||
}
|
||||
|
||||
private static async getRulePathContext(deps: RuleContextBuilderDeps): Promise<string[]> {
|
||||
const candidates: string[] = []
|
||||
const clineMessages = deps.messageStateHandler.getClineMessages()
|
||||
|
||||
// (1) Current-turn user message evidence:
|
||||
// Use the most recent user-authored text (initial task or subsequent feedback).
|
||||
// NOTE: We intentionally prefer the latest user_feedback over the original task to
|
||||
// support first-turn activation on later turns.
|
||||
const lastUserMsg = [...clineMessages]
|
||||
.reverse()
|
||||
.find((m) => m.type === "say" && (m.say === "user_feedback" || m.say === "task") && typeof m.text === "string")
|
||||
if (lastUserMsg?.text) {
|
||||
candidates.push(...extractPathLikeStrings(lastUserMsg.text))
|
||||
}
|
||||
|
||||
// (2) Visible + open tabs
|
||||
const roots = deps.workspaceManager?.getRoots().map((r) => r.path) ?? [deps.cwd]
|
||||
const rawVisiblePaths = (await HostProvider.window.getVisibleTabs({}))?.paths ?? []
|
||||
const rawOpenTabPaths = (await HostProvider.window.getOpenTabs({}))?.paths ?? []
|
||||
for (const abs of [...rawVisiblePaths, ...rawOpenTabPaths]) {
|
||||
for (const root of roots) {
|
||||
const rel = toWorkspaceRelativePosixPath(abs, root)
|
||||
if (rel) {
|
||||
candidates.push(rel)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// (3) Files edited by Cline during this task (completed operations):
|
||||
// Parse say="tool" messages for tool results indicating file operations.
|
||||
for (const msg of clineMessages) {
|
||||
if (msg.type !== "say" || msg.say !== "tool" || !msg.text) continue
|
||||
try {
|
||||
const tool = JSON.parse(msg.text) as { tool?: string; path?: string }
|
||||
if (
|
||||
(tool.tool === "editedExistingFile" || tool.tool === "newFileCreated" || tool.tool === "fileDeleted") &&
|
||||
tool.path
|
||||
) {
|
||||
candidates.push(tool.path)
|
||||
}
|
||||
} catch {
|
||||
// ignore parse errors
|
||||
}
|
||||
}
|
||||
|
||||
// (4) Tool requests (pending operations):
|
||||
// Parse ask="tool" messages to capture the assistant's intent BEFORE tool execution.
|
||||
// This enables rule activation even when:
|
||||
// - The tool hasn't completed yet
|
||||
// - The tool fails (intent was still expressed)
|
||||
// - Files don't exist yet (new file creation)
|
||||
for (const msg of clineMessages) {
|
||||
if (msg.type !== "ask" || msg.ask !== "tool" || !msg.text) continue
|
||||
try {
|
||||
const tool = JSON.parse(msg.text) as {
|
||||
tool?: string
|
||||
path?: string
|
||||
content?: string // apply_patch stores patch content here
|
||||
}
|
||||
|
||||
// Extract path from standard file tools
|
||||
if (tool.path) {
|
||||
candidates.push(tool.path)
|
||||
}
|
||||
|
||||
// Handle apply_patch specially: parse patch headers for file paths
|
||||
if (tool.tool === "applyPatch" && tool.content) {
|
||||
candidates.push(...RuleContextBuilder.extractPathsFromApplyPatch(tool.content))
|
||||
}
|
||||
} catch {
|
||||
// ignore parse errors
|
||||
}
|
||||
}
|
||||
|
||||
// Normalize/dedupe/cap
|
||||
const seen = new Set<string>()
|
||||
const normalized: string[] = []
|
||||
for (const c of candidates) {
|
||||
const posix = c.replace(/\\/g, "/").replace(/^\//, "")
|
||||
if (!posix || posix === "/") continue
|
||||
if (seen.has(posix)) continue
|
||||
seen.add(posix)
|
||||
normalized.push(posix)
|
||||
if (normalized.length >= RuleContextBuilder.MAX_RULE_PATH_CANDIDATES) break
|
||||
}
|
||||
return normalized.sort()
|
||||
}
|
||||
}
|
||||
+284
@@ -0,0 +1,284 @@
|
||||
import { expect } from "chai"
|
||||
import sinon from "sinon"
|
||||
import { RuleContextBuilder, RuleContextBuilderDeps } from "../RuleContextBuilder"
|
||||
|
||||
// Mock HostProvider to avoid actual VSCode API calls
|
||||
const mockHostProvider = {
|
||||
window: {
|
||||
getVisibleTabs: sinon.stub().resolves({ paths: [] }),
|
||||
getOpenTabs: sinon.stub().resolves({ paths: [] }),
|
||||
},
|
||||
}
|
||||
|
||||
describe("RuleContextBuilder", () => {
|
||||
let hostProviderStub: sinon.SinonStub
|
||||
|
||||
beforeEach(() => {
|
||||
// Stub HostProvider to use mock
|
||||
hostProviderStub = sinon.stub(require("@/hosts/host-provider"), "HostProvider").value(mockHostProvider)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
sinon.restore()
|
||||
})
|
||||
|
||||
describe("getRulePathContext from ask='tool' messages", () => {
|
||||
it("extracts path from ask='tool' message with write_to_file", async () => {
|
||||
const deps: RuleContextBuilderDeps = {
|
||||
cwd: "/workspace",
|
||||
messageStateHandler: {
|
||||
getClineMessages: () => [
|
||||
{
|
||||
type: "ask",
|
||||
ask: "tool",
|
||||
text: JSON.stringify({
|
||||
tool: "write_to_file",
|
||||
path: "src/components/Button.tsx",
|
||||
content: "// new file",
|
||||
}),
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
const context = await RuleContextBuilder.buildEvaluationContext(deps)
|
||||
expect(context.paths).to.include("src/components/Button.tsx")
|
||||
})
|
||||
|
||||
it("extracts paths from multiple sequential tool requests", async () => {
|
||||
const deps: RuleContextBuilderDeps = {
|
||||
cwd: "/workspace",
|
||||
messageStateHandler: {
|
||||
getClineMessages: () => [
|
||||
{
|
||||
type: "ask",
|
||||
ask: "tool",
|
||||
text: JSON.stringify({
|
||||
tool: "write_to_file",
|
||||
path: "src/utils/helper.ts",
|
||||
}),
|
||||
},
|
||||
{
|
||||
type: "ask",
|
||||
ask: "tool",
|
||||
text: JSON.stringify({
|
||||
tool: "replace_in_file",
|
||||
path: "src/index.ts",
|
||||
}),
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
const context = await RuleContextBuilder.buildEvaluationContext(deps)
|
||||
expect(context.paths).to.include("src/utils/helper.ts")
|
||||
expect(context.paths).to.include("src/index.ts")
|
||||
})
|
||||
|
||||
it("handles malformed JSON gracefully", async () => {
|
||||
const deps: RuleContextBuilderDeps = {
|
||||
cwd: "/workspace",
|
||||
messageStateHandler: {
|
||||
getClineMessages: () => [
|
||||
{
|
||||
type: "ask",
|
||||
ask: "tool",
|
||||
text: "not valid json {{{",
|
||||
},
|
||||
{
|
||||
type: "ask",
|
||||
ask: "tool",
|
||||
text: JSON.stringify({
|
||||
tool: "write_to_file",
|
||||
path: "valid/path.ts",
|
||||
}),
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
// Should not throw and should extract the valid path
|
||||
const context = await RuleContextBuilder.buildEvaluationContext(deps)
|
||||
expect(context.paths).to.include("valid/path.ts")
|
||||
})
|
||||
|
||||
it("extracts paths from apply_patch tool request", async () => {
|
||||
const patchContent = `*** Add File: src/new-feature.ts
|
||||
+const x = 1
|
||||
|
||||
*** Update File: src/existing.ts
|
||||
---
|
||||
+++
|
||||
@@ 1,1 @@
|
||||
-const y = 2
|
||||
+const y = 3`
|
||||
|
||||
const deps: RuleContextBuilderDeps = {
|
||||
cwd: "/workspace",
|
||||
messageStateHandler: {
|
||||
getClineMessages: () => [
|
||||
{
|
||||
type: "ask",
|
||||
ask: "tool",
|
||||
text: JSON.stringify({
|
||||
tool: "applyPatch",
|
||||
content: patchContent,
|
||||
}),
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
const context = await RuleContextBuilder.buildEvaluationContext(deps)
|
||||
expect(context.paths).to.include("src/new-feature.ts")
|
||||
expect(context.paths).to.include("src/existing.ts")
|
||||
})
|
||||
|
||||
it("deduplicates paths from multiple sources", async () => {
|
||||
const deps: RuleContextBuilderDeps = {
|
||||
cwd: "/workspace",
|
||||
messageStateHandler: {
|
||||
getClineMessages: () => [
|
||||
{
|
||||
type: "say",
|
||||
say: "task",
|
||||
text: "Update src/index.ts",
|
||||
},
|
||||
{
|
||||
type: "ask",
|
||||
ask: "tool",
|
||||
text: JSON.stringify({
|
||||
tool: "write_to_file",
|
||||
path: "src/index.ts",
|
||||
}),
|
||||
},
|
||||
{
|
||||
type: "say",
|
||||
say: "tool",
|
||||
text: JSON.stringify({
|
||||
tool: "editedExistingFile",
|
||||
path: "src/index.ts",
|
||||
}),
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
const context = await RuleContextBuilder.buildEvaluationContext(deps)
|
||||
// Should only appear once despite being in 3 messages
|
||||
const indexCount = (context.paths ?? []).filter((p) => p === "src/index.ts").length
|
||||
expect(indexCount).to.equal(1)
|
||||
})
|
||||
|
||||
it("normalizes Windows-style paths to POSIX", async () => {
|
||||
const deps: RuleContextBuilderDeps = {
|
||||
cwd: "/workspace",
|
||||
messageStateHandler: {
|
||||
getClineMessages: () => [
|
||||
{
|
||||
type: "ask",
|
||||
ask: "tool",
|
||||
text: JSON.stringify({
|
||||
tool: "write_to_file",
|
||||
path: "src\\components\\Button.tsx",
|
||||
}),
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
const context = await RuleContextBuilder.buildEvaluationContext(deps)
|
||||
expect(context.paths).to.include("src/components/Button.tsx")
|
||||
})
|
||||
|
||||
it("respects MAX_RULE_PATH_CANDIDATES limit", async () => {
|
||||
// Create more messages than the limit
|
||||
const messages: Array<{ type: string; ask: string; text: string }> = []
|
||||
for (let i = 0; i < RuleContextBuilder.MAX_RULE_PATH_CANDIDATES + 50; i++) {
|
||||
messages.push({
|
||||
type: "ask",
|
||||
ask: "tool",
|
||||
text: JSON.stringify({
|
||||
tool: "write_to_file",
|
||||
path: `src/file${i}.ts`,
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
const deps: RuleContextBuilderDeps = {
|
||||
cwd: "/workspace",
|
||||
messageStateHandler: {
|
||||
getClineMessages: () => messages,
|
||||
},
|
||||
}
|
||||
|
||||
const context = await RuleContextBuilder.buildEvaluationContext(deps)
|
||||
expect((context.paths ?? []).length).to.be.at.most(RuleContextBuilder.MAX_RULE_PATH_CANDIDATES)
|
||||
})
|
||||
})
|
||||
|
||||
describe("extractPathsFromApplyPatch", () => {
|
||||
it("extracts paths from Add File headers", async () => {
|
||||
const deps: RuleContextBuilderDeps = {
|
||||
cwd: "/workspace",
|
||||
messageStateHandler: {
|
||||
getClineMessages: () => [
|
||||
{
|
||||
type: "ask",
|
||||
ask: "tool",
|
||||
text: JSON.stringify({
|
||||
tool: "applyPatch",
|
||||
content: "*** Add File: src/new.ts\n+content",
|
||||
}),
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
const context = await RuleContextBuilder.buildEvaluationContext(deps)
|
||||
expect(context.paths).to.include("src/new.ts")
|
||||
})
|
||||
|
||||
it("extracts paths from Update File headers", async () => {
|
||||
const deps: RuleContextBuilderDeps = {
|
||||
cwd: "/workspace",
|
||||
messageStateHandler: {
|
||||
getClineMessages: () => [
|
||||
{
|
||||
type: "ask",
|
||||
ask: "tool",
|
||||
text: JSON.stringify({
|
||||
tool: "applyPatch",
|
||||
content: "*** Update File: src/existing.ts\n--- \n+++ \n@@ 1,1 @@\n-old\n+new",
|
||||
}),
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
const context = await RuleContextBuilder.buildEvaluationContext(deps)
|
||||
expect(context.paths).to.include("src/existing.ts")
|
||||
})
|
||||
|
||||
it("extracts paths from Delete File headers", async () => {
|
||||
const deps: RuleContextBuilderDeps = {
|
||||
cwd: "/workspace",
|
||||
messageStateHandler: {
|
||||
getClineMessages: () => [
|
||||
{
|
||||
type: "ask",
|
||||
ask: "tool",
|
||||
text: JSON.stringify({
|
||||
tool: "applyPatch",
|
||||
content: "*** Delete File: src/old.ts",
|
||||
}),
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
const context = await RuleContextBuilder.buildEvaluationContext(deps)
|
||||
expect(context.paths).to.include("src/old.ts")
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,8 +1,150 @@
|
||||
import { synchronizeRuleToggles } from "@core/context/instructions/user-instructions/rule-helpers"
|
||||
import {
|
||||
ActivatedConditionalRule,
|
||||
getRemoteRulesTotalContentWithMetadata,
|
||||
getRuleFilesTotalContentWithMetadata,
|
||||
RULE_SOURCE_PREFIX,
|
||||
RuleLoadResultWithInstructions,
|
||||
synchronizeRuleToggles,
|
||||
} from "@core/context/instructions/user-instructions/rule-helpers"
|
||||
import { formatResponse } from "@core/prompts/responses"
|
||||
import { ensureRulesDirectoryExists, GlobalFileNames } from "@core/storage/disk"
|
||||
import { StateManager } from "@core/storage/StateManager"
|
||||
import { ClineRulesToggles } from "@shared/cline-rules"
|
||||
import { fileExistsAtPath, isDirectory, readDirectory } from "@utils/fs"
|
||||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import { Controller } from "@/core/controller"
|
||||
import { Logger } from "@/shared/services/Logger"
|
||||
import { parseYamlFrontmatter } from "./frontmatter"
|
||||
import { evaluateRuleConditionals, type RuleEvaluationContext } from "./rule-conditionals"
|
||||
|
||||
export const getGlobalClineRules = async (
|
||||
globalClineRulesFilePath: string,
|
||||
toggles: ClineRulesToggles,
|
||||
opts?: { evaluationContext?: RuleEvaluationContext },
|
||||
): Promise<RuleLoadResultWithInstructions> => {
|
||||
let combinedContent = ""
|
||||
const activatedConditionalRules: ActivatedConditionalRule[] = []
|
||||
|
||||
// 1. Get file-based rules
|
||||
if (await fileExistsAtPath(globalClineRulesFilePath)) {
|
||||
if (await isDirectory(globalClineRulesFilePath)) {
|
||||
try {
|
||||
const rulesFilePaths = await readDirectory(globalClineRulesFilePath)
|
||||
// Note: ruleNamePrefix explicitly set to "global" for clarity (matches the default)
|
||||
const rulesFilesTotal = await getRuleFilesTotalContentWithMetadata(
|
||||
rulesFilePaths,
|
||||
globalClineRulesFilePath,
|
||||
toggles,
|
||||
{
|
||||
evaluationContext: opts?.evaluationContext,
|
||||
ruleNamePrefix: "global",
|
||||
},
|
||||
)
|
||||
if (rulesFilesTotal.content) {
|
||||
combinedContent = rulesFilesTotal.content
|
||||
activatedConditionalRules.push(...rulesFilesTotal.activatedConditionalRules)
|
||||
}
|
||||
} catch {
|
||||
Logger.error(`Failed to read .clinerules directory at ${globalClineRulesFilePath}`)
|
||||
}
|
||||
} else {
|
||||
Logger.error(`${globalClineRulesFilePath} is not a directory`)
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Append remote config rules
|
||||
const stateManager = StateManager.get()
|
||||
const remoteConfigSettings = stateManager.getRemoteConfigSettings()
|
||||
const remoteRules = remoteConfigSettings.remoteGlobalRules || []
|
||||
const remoteToggles = stateManager.getGlobalStateKey("remoteRulesToggles") || {}
|
||||
const remoteResult = getRemoteRulesTotalContentWithMetadata(remoteRules, remoteToggles, {
|
||||
evaluationContext: opts?.evaluationContext,
|
||||
})
|
||||
if (remoteResult.content) {
|
||||
if (combinedContent) combinedContent += "\n\n"
|
||||
combinedContent += remoteResult.content
|
||||
activatedConditionalRules.push(...remoteResult.activatedConditionalRules)
|
||||
}
|
||||
|
||||
// 3. Return formatted instructions
|
||||
if (!combinedContent) {
|
||||
return { instructions: undefined, activatedConditionalRules: [] }
|
||||
}
|
||||
|
||||
return {
|
||||
instructions: formatResponse.clineRulesGlobalDirectoryInstructions(globalClineRulesFilePath, combinedContent),
|
||||
activatedConditionalRules,
|
||||
}
|
||||
}
|
||||
|
||||
export const getLocalClineRules = async (
|
||||
cwd: string,
|
||||
toggles: ClineRulesToggles,
|
||||
opts?: { evaluationContext?: RuleEvaluationContext },
|
||||
): Promise<RuleLoadResultWithInstructions> => {
|
||||
const clineRulesFilePath = path.resolve(cwd, GlobalFileNames.clineRules)
|
||||
|
||||
let instructions: string | undefined
|
||||
const activatedConditionalRules: ActivatedConditionalRule[] = []
|
||||
|
||||
if (await fileExistsAtPath(clineRulesFilePath)) {
|
||||
if (await isDirectory(clineRulesFilePath)) {
|
||||
try {
|
||||
const rulesFilePaths = await readDirectory(clineRulesFilePath, [
|
||||
[".clinerules", "workflows"],
|
||||
[".clinerules", "hooks"],
|
||||
[".clinerules", "skills"],
|
||||
])
|
||||
|
||||
const rulesFilesTotal = await getRuleFilesTotalContentWithMetadata(rulesFilePaths, cwd, toggles, {
|
||||
evaluationContext: opts?.evaluationContext,
|
||||
ruleNamePrefix: "workspace",
|
||||
})
|
||||
if (rulesFilesTotal.content) {
|
||||
instructions = formatResponse.clineRulesLocalDirectoryInstructions(cwd, rulesFilesTotal.content)
|
||||
activatedConditionalRules.push(...rulesFilesTotal.activatedConditionalRules)
|
||||
}
|
||||
} catch {
|
||||
Logger.error(`Failed to read .clinerules directory at ${clineRulesFilePath}`)
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
if (clineRulesFilePath in toggles && toggles[clineRulesFilePath] !== false) {
|
||||
const raw = (await fs.readFile(clineRulesFilePath, "utf8")).trim()
|
||||
if (raw) {
|
||||
// Keep single-file .clinerules behavior consistent with directory/remote rules:
|
||||
// - Parse YAML frontmatter (fail-open on parse errors)
|
||||
// - Evaluate conditionals against the request's evaluation context
|
||||
const parsed = parseYamlFrontmatter(raw)
|
||||
if (parsed.hadFrontmatter && parsed.parseError) {
|
||||
// Fail-open: preserve the raw contents so the LLM can still see the author's intent.
|
||||
instructions = formatResponse.clineRulesLocalFileInstructions(cwd, raw)
|
||||
} else {
|
||||
const { passed, matchedConditions } = evaluateRuleConditionals(
|
||||
parsed.data,
|
||||
opts?.evaluationContext ?? {},
|
||||
)
|
||||
if (passed) {
|
||||
instructions = formatResponse.clineRulesLocalFileInstructions(cwd, parsed.body.trim())
|
||||
if (parsed.hadFrontmatter && Object.keys(matchedConditions).length > 0) {
|
||||
activatedConditionalRules.push({
|
||||
name: `${RULE_SOURCE_PREFIX.workspace}:${GlobalFileNames.clineRules}`,
|
||||
matchedConditions,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
Logger.error(`Failed to read .clinerules file at ${clineRulesFilePath}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { instructions, activatedConditionalRules }
|
||||
}
|
||||
|
||||
export async function refreshClineRulesToggles(
|
||||
controller: Controller,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user