mirror of
https://github.com/cline/cline.git
synced 2026-09-19 02:05:44 +08:00
merge
This commit is contained in:
+10
@@ -60,3 +60,13 @@ tests/**/cache
|
||||
# Backup created by scripts/marketplace-readme.mjs while publishing.
|
||||
# Should never be committed: only exists if a publish aborts mid-swap.
|
||||
.README.github.bak
|
||||
|
||||
|
||||
# SDK Session files / User data
|
||||
.cline/data
|
||||
.cline/tmp
|
||||
*.db
|
||||
*.db-shm
|
||||
*.db-wal
|
||||
.cline/**/managed.json
|
||||
.cline/**/bundle.json
|
||||
|
||||
@@ -1,5 +1,19 @@
|
||||
# Changelog
|
||||
|
||||
## [3.84.0]
|
||||
|
||||
### Added
|
||||
|
||||
- Add SAP AI Core support for additional hosted models
|
||||
|
||||
### Fixed
|
||||
|
||||
- Disable the MCP "Restart Server" button when a server is toggled off.
|
||||
|
||||
### Changed
|
||||
|
||||
- Remove the Cline Kanban launch modal and bundled demo media from the VS Code extension startup flow.
|
||||
|
||||
## [3.83.0]
|
||||
|
||||
### Fixed
|
||||
|
||||
Generated
+6
-44
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "claude-dev",
|
||||
"version": "3.83.0",
|
||||
"version": "3.84.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "claude-dev",
|
||||
"version": "3.83.0",
|
||||
"version": "3.84.0",
|
||||
"license": "Apache-2.0",
|
||||
"workspaces": [
|
||||
"."
|
||||
@@ -61,7 +61,7 @@
|
||||
"chrome-launcher": "^1.1.2",
|
||||
"clone-deep": "^4.0.1",
|
||||
"default-shell": "^2.2.0",
|
||||
"diff": "^5.2.0",
|
||||
"diff": "8.0.4",
|
||||
"exceljs": "^4.4.0",
|
||||
"execa": "^9.5.2",
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
@@ -115,7 +115,6 @@
|
||||
"@types/better-sqlite3": "^7.6.13",
|
||||
"@types/chai": "^5.0.1",
|
||||
"@types/clone-deep": "^4.0.4",
|
||||
"@types/diff": "^5.2.1",
|
||||
"@types/get-folder-size": "^3.0.4",
|
||||
"@types/js-yaml": "^4.0.9",
|
||||
"@types/mocha": "^10.0.7",
|
||||
@@ -7206,13 +7205,6 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/diff": {
|
||||
"version": "5.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/diff/-/diff-5.2.3.tgz",
|
||||
"integrity": "sha512-K0Oqlrq3kQMaO2RhfrNQX5trmt+XLyom88zS0u84nnIcLvFnRUMRRHmrGny5GSM+kNO9IZLARsdQHDzkhAgmrQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/estree": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
||||
@@ -10155,9 +10147,9 @@
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/diff": {
|
||||
"version": "5.2.2",
|
||||
"resolved": "https://registry.npmjs.org/diff/-/diff-5.2.2.tgz",
|
||||
"integrity": "sha512-vtcDfH3TOjP8UekytvnHH1o1P4FcUdt4eQ1Y+Abap1tk/OB2MWQvcwS2ClCd1zuIhc3JKOx6p3kod8Vfys3E+A==",
|
||||
"version": "8.0.4",
|
||||
"resolved": "https://registry.npmjs.org/diff/-/diff-8.0.4.tgz",
|
||||
"integrity": "sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==",
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">=0.3.1"
|
||||
@@ -15376,16 +15368,6 @@
|
||||
"balanced-match": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/mocha/node_modules/diff": {
|
||||
"version": "8.0.4",
|
||||
"resolved": "https://registry.npmjs.org/diff/-/diff-8.0.4.tgz",
|
||||
"integrity": "sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">=0.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/mocha/node_modules/find-up": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz",
|
||||
@@ -18769,16 +18751,6 @@
|
||||
"url": "https://opencollective.com/sinon"
|
||||
}
|
||||
},
|
||||
"node_modules/sinon/node_modules/diff": {
|
||||
"version": "8.0.4",
|
||||
"resolved": "https://registry.npmjs.org/diff/-/diff-8.0.4.tgz",
|
||||
"integrity": "sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">=0.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/sinon/node_modules/supports-color": {
|
||||
"version": "7.2.0",
|
||||
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
|
||||
@@ -19878,16 +19850,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/ts-node/node_modules/diff": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/diff/-/diff-4.0.4.tgz",
|
||||
"integrity": "sha512-X07nttJQkwkfKfvTPG/KSnE2OMdcUCao6+eXF3wmnIQRn2aPAHH3VxDbDOdegkd6JbPsXqShpvEOHfAT+nCNwQ==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">=0.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/ts-poet": {
|
||||
"version": "6.12.0",
|
||||
"resolved": "https://registry.npmjs.org/ts-poet/-/ts-poet-6.12.0.tgz",
|
||||
|
||||
+3
-6
@@ -2,7 +2,7 @@
|
||||
"name": "claude-dev",
|
||||
"displayName": "Cline",
|
||||
"description": "Autonomous coding agent right in your IDE, capable of creating/editing files, running commands, using the browser, and more with your permission every step of the way.",
|
||||
"version": "3.83.0",
|
||||
"version": "3.84.0",
|
||||
"icon": "assets/icons/icon.png",
|
||||
"workspaces": [
|
||||
"."
|
||||
@@ -449,7 +449,6 @@
|
||||
"@types/better-sqlite3": "^7.6.13",
|
||||
"@types/chai": "^5.0.1",
|
||||
"@types/clone-deep": "^4.0.4",
|
||||
"@types/diff": "^5.2.1",
|
||||
"@types/get-folder-size": "^3.0.4",
|
||||
"@types/js-yaml": "^4.0.9",
|
||||
"@types/mocha": "^10.0.7",
|
||||
@@ -539,7 +538,7 @@
|
||||
"chrome-launcher": "^1.1.2",
|
||||
"clone-deep": "^4.0.1",
|
||||
"default-shell": "^2.2.0",
|
||||
"diff": "^5.2.0",
|
||||
"diff": "8.0.4",
|
||||
"exceljs": "^4.4.0",
|
||||
"execa": "^9.5.2",
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
@@ -593,9 +592,7 @@
|
||||
"js-yaml": "^4.1.1",
|
||||
"serialize-javascript": ">=7.0.3",
|
||||
"protobufjs": "7.5.5",
|
||||
"mocha": {
|
||||
"diff": ">=8.0.3"
|
||||
}
|
||||
"diff": "8.0.4"
|
||||
},
|
||||
"c8": {
|
||||
"reporter": [
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
# Cline CLI Changelog
|
||||
|
||||
## 3.0.8
|
||||
|
||||
- Use Telegram numeric participant ids so renamed users stay linked to the same participant in the Telegram connector.
|
||||
- Keep failed plugins visible in the config UI with their load/setup phase and error details so broken plugin definitions are easier to diagnose.
|
||||
- Move the Create Session Fork shortcut from Opt+F to Opt+R so terminal word-right navigation works again.
|
||||
- Fix AWS Bedrock region and profile detection in the CLI onboarding, and surface bearer-token and additional Bedrock config fields in the provider config screens.
|
||||
- Fix inflated token usage counts caused by AgentRuntime.execute() not resetting usage between calls, which the local runtime host was then double-counting on top of the session baseline.
|
||||
|
||||
## 3.0.7
|
||||
|
||||
- Skip the ChatGPT OAuth model refresh on session startup so the CLI launches without the extra network round-trip.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@cline/cli",
|
||||
"displayName": "cline",
|
||||
"version": "3.0.7",
|
||||
"version": "3.0.8",
|
||||
"description": "Autonomous coding agent CLI - capable of creating/editing files, running commands, using the browser, and more",
|
||||
"type": "module",
|
||||
"publishConfig": {
|
||||
@@ -68,26 +68,27 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^0.16.1",
|
||||
"@clack/prompts": "^1.2.0",
|
||||
"@chat-adapter/discord": "^4.23.0",
|
||||
"@chat-adapter/gchat": "^4.23.0",
|
||||
"@chat-adapter/linear": "^4.23.0",
|
||||
"@chat-adapter/slack": "^4.23.0",
|
||||
"@chat-adapter/telegram": "^4.23.0",
|
||||
"@chat-adapter/whatsapp": "^4.23.0",
|
||||
"@clack/prompts": "^1.2.0",
|
||||
"@gramio/format": "^0.7.0",
|
||||
"chat": "^4.23.0",
|
||||
"commander": "^14.0.3",
|
||||
"@opentui-ui/dialog": "^0.1.2",
|
||||
"@opentui/core": "0.1.102",
|
||||
"@opentui/react": "0.1.102",
|
||||
"@opentui-ui/dialog": "^0.1.2",
|
||||
"chat": "^4.23.0",
|
||||
"commander": "^14.0.3",
|
||||
"fzf": "^0.5.2",
|
||||
"marked": "^15.0.12",
|
||||
"open": "^10.2.0",
|
||||
"opentui-spinner": "^0.0.6",
|
||||
"pino": "^10.3.1",
|
||||
"react": "19.2.4",
|
||||
"react-reconciler": "0.32.0",
|
||||
"react-devtools-core": "^7.0.1",
|
||||
"react-reconciler": "0.32.0",
|
||||
"yaml": "^2.8.2",
|
||||
"zod": "^4.1.11"
|
||||
},
|
||||
|
||||
@@ -232,7 +232,7 @@ export function commanderToParsedArgs(program: Command): ParsedArgs {
|
||||
if (opts.id !== undefined) result.id = opts.id;
|
||||
|
||||
// Positional args → prompt
|
||||
const positional = program.args.filter((a) => !a.startsWith("-"));
|
||||
const positional = program.args;
|
||||
if (positional.length > 0) {
|
||||
result.prompt = positional.join(" ");
|
||||
}
|
||||
|
||||
@@ -38,6 +38,53 @@ describe("telegramConnector", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("telegram participant resolution", () => {
|
||||
it("uses the stable numeric Telegram user id when username is also present", () => {
|
||||
const result = __test__.resolveTelegramParticipant({
|
||||
message: {
|
||||
from: {
|
||||
id: 1201547643,
|
||||
username: "AraFatKatze",
|
||||
first_name: "Ara",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
key: "telegram:id:1201547643",
|
||||
label: "arafatkatze",
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to username when Telegram does not provide a numeric user id", () => {
|
||||
const result = __test__.resolveTelegramParticipant({
|
||||
message: {
|
||||
from: {
|
||||
username: "Alice",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
key: "telegram:user:alice",
|
||||
label: "alice",
|
||||
});
|
||||
});
|
||||
|
||||
it("accepts string numeric user ids from raw Telegram payloads", () => {
|
||||
const result = __test__.resolveTelegramParticipant({
|
||||
message: {
|
||||
from: {
|
||||
id: "1201547643",
|
||||
username: "arafatkatze",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(result?.key).toBe("telegram:id:1201547643");
|
||||
});
|
||||
});
|
||||
|
||||
describe("telegram binding lookup", () => {
|
||||
it("falls back to channel identity when a restarted connector gets a new thread id", () => {
|
||||
const result = __test__.findBindingForThread(
|
||||
|
||||
@@ -135,12 +135,12 @@ function resolveTelegramParticipant(
|
||||
const lastName = readString(from?.last_name);
|
||||
const label =
|
||||
username || [firstName, lastName].filter(Boolean).join(" ") || userId;
|
||||
if (username) {
|
||||
return { key: `telegram:user:${username}`, label };
|
||||
}
|
||||
if (userId) {
|
||||
return { key: `telegram:id:${userId}`, label };
|
||||
}
|
||||
if (username) {
|
||||
return { key: `telegram:user:${username}`, label };
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -957,6 +957,7 @@ export const telegramConnector: ConnectCommandDefinition =
|
||||
new TelegramConnector();
|
||||
|
||||
export const __test__ = {
|
||||
resolveTelegramParticipant,
|
||||
findBindingForThread: (
|
||||
bindings: ConnectorBindingStore<TelegramThreadState>,
|
||||
thread: Pick<Thread<TelegramThreadState>, "id" | "channelId" | "isDM"> & {
|
||||
|
||||
@@ -657,6 +657,27 @@ describe("runCli lightweight command dispatch", () => {
|
||||
expect(runtimeMocks.runInteractive).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("treats dash-prefixed positional text after -- as a prompt", async () => {
|
||||
forcePromptModeInput();
|
||||
process.argv = [
|
||||
"bun",
|
||||
"src/index.ts",
|
||||
"--",
|
||||
"- You are given a PyTorch state dictionary.",
|
||||
];
|
||||
|
||||
const { runCli } = await import("./main");
|
||||
|
||||
await expect(runCli()).resolves.toBeUndefined();
|
||||
expect(runtimeMocks.runAgent).toHaveBeenCalledTimes(1);
|
||||
expect(runtimeMocks.runAgent).toHaveBeenCalledWith(
|
||||
"- You are given a PyTorch state dictionary.",
|
||||
expect.any(Object),
|
||||
expect.anything(),
|
||||
);
|
||||
expect(runtimeMocks.runInteractive).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("applies --auto-approve as a runtime policy without changing the config default", async () => {
|
||||
process.argv = ["bun", "src/index.ts", "--auto-approve", "false"];
|
||||
|
||||
|
||||
@@ -3,7 +3,10 @@ import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import type { UserInstructionConfigService } from "@cline/core";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import type { InteractiveConfigItem } from "../../tui/interactive-config";
|
||||
import {
|
||||
applyPluginFailures,
|
||||
type InteractiveConfigItem,
|
||||
} from "../../tui/interactive-config";
|
||||
import type { Config } from "../../utils/types";
|
||||
import { createInteractiveConfigDataLoader } from "./config-data";
|
||||
|
||||
@@ -206,6 +209,82 @@ Use this skill.`,
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps failed plugins visible with their load error", 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 pluginsDir = join(tempRoot, ".cline", "plugins");
|
||||
await mkdir(pluginsDir, { recursive: true });
|
||||
const pluginPath = join(pluginsDir, "broken-plugin.js");
|
||||
const invalidPluginPath = join(pluginsDir, "invalid-plugin.js");
|
||||
await writeFile(
|
||||
pluginPath,
|
||||
[
|
||||
"export default {",
|
||||
" name: 'broken-plugin',",
|
||||
" manifest: { capabilities: ['tools'] },",
|
||||
" setup() {",
|
||||
" throw new Error('setup exploded');",
|
||||
" },",
|
||||
"};",
|
||||
].join("\n"),
|
||||
);
|
||||
await writeFile(invalidPluginPath, "export default {};\n", "utf8");
|
||||
const loader = createInteractiveConfigDataLoader({
|
||||
config: createConfig(tempRoot),
|
||||
});
|
||||
|
||||
const data = await loader.loadConfigData({ includePluginTools: true });
|
||||
const plugin = data.plugins.find((item) => item.path === pluginPath);
|
||||
|
||||
expect(plugin?.name).toBe("broken-plugin");
|
||||
expect(plugin?.loadErrorPhase).toBe("setup");
|
||||
expect(plugin?.loadError).toContain("setup failed: setup exploded");
|
||||
|
||||
const invalidPlugin = data.plugins.find(
|
||||
(item) => item.path === invalidPluginPath,
|
||||
);
|
||||
expect(invalidPlugin?.name).toBe("invalid-plugin");
|
||||
expect(invalidPlugin?.loadErrorPhase).toBe("load");
|
||||
expect(invalidPlugin?.loadError).toContain("load failed:");
|
||||
});
|
||||
|
||||
it("preserves multiple load failures for the same plugin path", () => {
|
||||
const plugin: InteractiveConfigItem = {
|
||||
id: "/tmp/plugin.js",
|
||||
name: "plugin",
|
||||
path: "/tmp/plugin.js",
|
||||
enabled: true,
|
||||
kind: "plugin",
|
||||
source: "workspace-plugin",
|
||||
};
|
||||
|
||||
applyPluginFailures(
|
||||
[plugin],
|
||||
[
|
||||
{
|
||||
pluginPath: "/tmp/plugin.js",
|
||||
pluginName: "plugin",
|
||||
phase: "setup",
|
||||
message: "first failure",
|
||||
},
|
||||
{
|
||||
pluginPath: "/tmp/plugin.js",
|
||||
phase: "setup",
|
||||
message: "second failure",
|
||||
},
|
||||
],
|
||||
);
|
||||
|
||||
expect(plugin.loadError).toBe(
|
||||
"setup failed: first failure\nsetup failed: second failure",
|
||||
);
|
||||
expect(plugin.loadErrorPhase).toBeUndefined();
|
||||
});
|
||||
|
||||
it("toggles every SDK tool name for a displayed built-in tool", async () => {
|
||||
const tempRoot = await mkdtemp(join(tmpdir(), "cli-config-data-"));
|
||||
tempRoots.push(tempRoot);
|
||||
|
||||
@@ -3,7 +3,7 @@ import { isToggleableInteractiveConfigItem } from "../../interactive-config";
|
||||
|
||||
export type ExtDetailRow =
|
||||
| { kind: "header"; name: string; source: string }
|
||||
| { kind: "field"; label: "Path" | "Description"; value: string[] }
|
||||
| { kind: "field"; label: "Path" | "Description" | "Error"; value: string[] }
|
||||
| { kind: "status"; enabled: boolean };
|
||||
|
||||
const TOGGLE_FOOTER = "Space toggle status, Tab/Enter/Esc to go back";
|
||||
@@ -77,6 +77,13 @@ export function getExtDetailRows(item: InteractiveConfigItem): ExtDetailRow[] {
|
||||
value: truncateDescription(item.description),
|
||||
});
|
||||
}
|
||||
if (item.loadError) {
|
||||
rows.push({
|
||||
kind: "field",
|
||||
label: "Error",
|
||||
value: truncateDescription(item.loadError),
|
||||
});
|
||||
}
|
||||
if (
|
||||
typeof item.enabled === "boolean" &&
|
||||
isToggleableInteractiveConfigItem(item)
|
||||
|
||||
@@ -198,13 +198,8 @@ export function useAutocomplete(opts: {
|
||||
);
|
||||
|
||||
const getFilteredMentionOptions = useCallback(
|
||||
(query: string): AutocompleteOption[] => {
|
||||
const q = query.toLowerCase();
|
||||
let filtered = mentionResults;
|
||||
if (q) {
|
||||
filtered = mentionResults.filter((f) => f.toLowerCase().includes(q));
|
||||
}
|
||||
return filtered.slice(0, MAX_COMPLETION_RESULTS).map((f) => ({
|
||||
(_query: string): AutocompleteOption[] => {
|
||||
return mentionResults.slice(0, MAX_COMPLETION_RESULTS).map((f) => ({
|
||||
display: f,
|
||||
value: formatMentionAutocompleteValue(f),
|
||||
}));
|
||||
@@ -233,6 +228,7 @@ export function useAutocomplete(opts: {
|
||||
if (searchTimerRef.current) {
|
||||
clearTimeout(searchTimerRef.current);
|
||||
}
|
||||
setMentionResults([]);
|
||||
const counter = ++searchCounterRef.current;
|
||||
searchTimerRef.current = setTimeout(() => {
|
||||
searchWorkspaceFilesForMention({
|
||||
|
||||
@@ -5,7 +5,8 @@ import {
|
||||
discoverPluginModulePaths,
|
||||
hasMcpSettingsFile,
|
||||
listHookConfigFiles,
|
||||
listPluginTools,
|
||||
listPluginToolsWithDiagnostics,
|
||||
type PluginInitializationFailure,
|
||||
type RuleConfig,
|
||||
readGlobalSettings,
|
||||
resolveAgentConfigSearchPaths,
|
||||
@@ -49,6 +50,8 @@ export interface InteractiveConfigItem {
|
||||
toolNames?: string[];
|
||||
configKind?: "tool" | "plugin";
|
||||
pluginName?: string;
|
||||
loadError?: string;
|
||||
loadErrorPhase?: PluginInitializationFailure["phase"];
|
||||
source:
|
||||
| "global"
|
||||
| "workspace"
|
||||
@@ -216,6 +219,36 @@ function getPluginDisplayName(filePath: string): string {
|
||||
return basename(filePath, extname(filePath));
|
||||
}
|
||||
|
||||
function formatPluginFailure(failure: PluginInitializationFailure): string {
|
||||
return `${failure.phase === "setup" ? "setup failed" : "load failed"}: ${failure.message}`;
|
||||
}
|
||||
|
||||
export function applyPluginFailures(
|
||||
plugins: InteractiveConfigItem[],
|
||||
failures: readonly PluginInitializationFailure[],
|
||||
): void {
|
||||
const pluginsByPath = new Map(plugins.map((plugin) => [plugin.path, plugin]));
|
||||
const failuresByPath = new Map<string, PluginInitializationFailure[]>();
|
||||
for (const failure of failures) {
|
||||
const failuresForPath = failuresByPath.get(failure.pluginPath) ?? [];
|
||||
failuresForPath.push(failure);
|
||||
failuresByPath.set(failure.pluginPath, failuresForPath);
|
||||
}
|
||||
for (const [pluginPath, failuresForPath] of failuresByPath) {
|
||||
const plugin = pluginsByPath.get(pluginPath);
|
||||
if (!plugin) {
|
||||
continue;
|
||||
}
|
||||
const namedFailure = failuresForPath.find((failure) => failure.pluginName);
|
||||
if (namedFailure?.pluginName) {
|
||||
plugin.name = namedFailure.pluginName;
|
||||
}
|
||||
plugin.loadError = failuresForPath.map(formatPluginFailure).join("\n");
|
||||
plugin.loadErrorPhase =
|
||||
failuresForPath.length === 1 ? failuresForPath[0]?.phase : undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadInteractiveConfigData(input: {
|
||||
userInstructionService?: UserInstructionConfigService;
|
||||
cwd: string;
|
||||
@@ -356,12 +389,14 @@ export async function loadInteractiveConfigData(input: {
|
||||
);
|
||||
if (input.includePluginTools !== false) {
|
||||
try {
|
||||
for (const pluginTool of await listPluginTools({
|
||||
const pluginToolResult = await listPluginToolsWithDiagnostics({
|
||||
workspacePath: input.workspaceRoot,
|
||||
cwd: input.cwd,
|
||||
providerId: input.availabilityContext?.providerId,
|
||||
modelId: input.availabilityContext?.modelId,
|
||||
})) {
|
||||
});
|
||||
applyPluginFailures(plugins, pluginToolResult.failures);
|
||||
for (const pluginTool of pluginToolResult.tools) {
|
||||
tools.push({
|
||||
id: `${pluginTool.pluginName}:${pluginTool.name}:${pluginTool.path}`,
|
||||
name: pluginTool.name,
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { rankMentionPaths } from "./interactive-welcome";
|
||||
|
||||
describe("TUI file mention search ranking", () => {
|
||||
it("keeps initialism matches for compact and hyphenated input", () => {
|
||||
const paths = [
|
||||
"src/components/Button.tsx",
|
||||
"src/domain/MyAmazingClassDefinition.ts",
|
||||
"docs/MACD.md",
|
||||
"packages/core/src/runtime/manager.ts",
|
||||
];
|
||||
|
||||
expect(rankMentionPaths(paths, "MACD", 10)).toEqual([
|
||||
"docs/MACD.md",
|
||||
"src/domain/MyAmazingClassDefinition.ts",
|
||||
]);
|
||||
expect(rankMentionPaths(paths, "M-A-C-D", 10)).toEqual([
|
||||
"docs/MACD.md",
|
||||
"src/domain/MyAmazingClassDefinition.ts",
|
||||
]);
|
||||
});
|
||||
|
||||
it("normalizes common mention prefixes before matching workspace paths", () => {
|
||||
const paths = [
|
||||
"docs/architecture.md",
|
||||
"src/tui/interactive-welcome.ts",
|
||||
"src/tui/hooks/use-autocomplete.ts",
|
||||
];
|
||||
|
||||
expect(rankMentionPaths(paths, "./src/tui", 10)).toEqual([
|
||||
"src/tui/hooks/use-autocomplete.ts",
|
||||
"src/tui/interactive-welcome.ts",
|
||||
]);
|
||||
expect(rankMentionPaths(paths, "/docs", 10)).toEqual([
|
||||
"docs/architecture.md",
|
||||
]);
|
||||
});
|
||||
|
||||
it("ranks filename matches ahead of path-only fuzzy matches", () => {
|
||||
const paths = [
|
||||
"src/migrations/add-column.ts",
|
||||
"src/domain/MyAmazingClassDefinition.ts",
|
||||
"docs/classes.md",
|
||||
];
|
||||
|
||||
expect(rankMentionPaths(paths, "class", 10)[0]).toBe("docs/classes.md");
|
||||
});
|
||||
});
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
type ProviderSettings,
|
||||
type UserInstructionConfigService,
|
||||
} from "@cline/core";
|
||||
import { byLengthAsc, Fzf, type FzfResultItem } from "fzf";
|
||||
import type { Config } from "../utils/types";
|
||||
import { formatClineCredits, loadClineAccountSnapshot } from "./cline-account";
|
||||
|
||||
@@ -17,24 +18,95 @@ function normalizeLimit(limit: number | undefined): number {
|
||||
if (typeof limit !== "number" || Number.isNaN(limit)) {
|
||||
return 10;
|
||||
}
|
||||
return Math.min(50, Math.max(1, Math.trunc(limit)));
|
||||
return Math.min(200, Math.max(1, Math.trunc(limit)));
|
||||
}
|
||||
|
||||
function rankPath(path: string, query: string): number {
|
||||
if (query.length === 0) {
|
||||
return 3;
|
||||
function getPathLabel(filePath: string): string {
|
||||
const parts = filePath.split("/");
|
||||
return parts[parts.length - 1] ?? filePath;
|
||||
}
|
||||
|
||||
function normalizeMentionQuery(query: string): string {
|
||||
const trimmed = query.trim().replace(/^["']/, "");
|
||||
if (trimmed.startsWith("./")) {
|
||||
return trimmed.slice(2);
|
||||
}
|
||||
const lowerPath = path.toLowerCase();
|
||||
if (lowerPath.startsWith(query)) {
|
||||
return 0;
|
||||
if (trimmed.startsWith("/")) {
|
||||
return trimmed.slice(1);
|
||||
}
|
||||
if (lowerPath.includes(`/${query}`)) {
|
||||
return 1;
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
function compactSearchText(text: string): string {
|
||||
return text.toLowerCase().replace(/[^a-z0-9]/g, "");
|
||||
}
|
||||
|
||||
interface MentionPathItem {
|
||||
path: string;
|
||||
label: string;
|
||||
searchText: string;
|
||||
}
|
||||
|
||||
function countGaps(positions: Iterable<number>): number {
|
||||
const sortedPositions = Array.from(positions).sort((a, b) => a - b);
|
||||
let gaps = 0;
|
||||
let previous = Number.NEGATIVE_INFINITY;
|
||||
for (const position of sortedPositions) {
|
||||
if (previous !== Number.NEGATIVE_INFINITY && position - previous > 1) {
|
||||
gaps++;
|
||||
}
|
||||
previous = position;
|
||||
}
|
||||
if (lowerPath.includes(query)) {
|
||||
return 2;
|
||||
return gaps;
|
||||
}
|
||||
|
||||
function orderByMatchScore(
|
||||
left: FzfResultItem<MentionPathItem>,
|
||||
right: FzfResultItem<MentionPathItem>,
|
||||
): number {
|
||||
return countGaps(left.positions) - countGaps(right.positions);
|
||||
}
|
||||
|
||||
export function rankMentionPaths(
|
||||
paths: Iterable<string>,
|
||||
query: string,
|
||||
limit: number,
|
||||
): string[] {
|
||||
const items = Array.from(paths, (path): MentionPathItem => {
|
||||
const label = getPathLabel(path);
|
||||
return {
|
||||
path,
|
||||
label,
|
||||
searchText: [
|
||||
label,
|
||||
label,
|
||||
path,
|
||||
compactSearchText(label),
|
||||
compactSearchText(path),
|
||||
].join(" "),
|
||||
};
|
||||
});
|
||||
|
||||
const normalizedQuery = normalizeMentionQuery(query);
|
||||
if (!normalizedQuery) {
|
||||
return items
|
||||
.sort((left, right) => left.path.localeCompare(right.path))
|
||||
.slice(0, limit)
|
||||
.map((item) => item.path);
|
||||
}
|
||||
return Number.POSITIVE_INFINITY;
|
||||
|
||||
const fzf = new Fzf(items, {
|
||||
selector: (item) => item.searchText,
|
||||
tiebreakers: [orderByMatchScore, byLengthAsc],
|
||||
limit,
|
||||
});
|
||||
|
||||
const rawResults = fzf.find(normalizedQuery);
|
||||
const results =
|
||||
rawResults.length > 0
|
||||
? rawResults
|
||||
: fzf.find(compactSearchText(normalizedQuery));
|
||||
return results.map((result) => result.item.path);
|
||||
}
|
||||
|
||||
export function listInteractiveSlashCommands(
|
||||
@@ -90,21 +162,9 @@ export async function searchWorkspaceFilesForMention(input: {
|
||||
if (!workspaceRoot) {
|
||||
return [];
|
||||
}
|
||||
const query = input.query.trim().toLowerCase();
|
||||
const limit = normalizeLimit(input.limit);
|
||||
const index = await getFileIndex(workspaceRoot);
|
||||
const allPaths = Array.from(index).sort((a, b) => a.localeCompare(b));
|
||||
return allPaths
|
||||
.map((path) => ({ path, rank: rankPath(path, query) }))
|
||||
.filter((item) => Number.isFinite(item.rank))
|
||||
.sort((left, right) => {
|
||||
if (left.rank !== right.rank) {
|
||||
return left.rank - right.rank;
|
||||
}
|
||||
return left.path.localeCompare(right.path);
|
||||
})
|
||||
.slice(0, limit)
|
||||
.map((item) => item.path);
|
||||
return rankMentionPaths(index, input.query, limit);
|
||||
}
|
||||
|
||||
export async function resolveClineWelcomeLine(input: {
|
||||
|
||||
@@ -235,6 +235,17 @@ function appendToolRows(
|
||||
}
|
||||
}
|
||||
|
||||
function getPluginLoadErrorLabel(
|
||||
item: InteractiveConfigItem,
|
||||
): string | undefined {
|
||||
if (!item.loadError) {
|
||||
return undefined;
|
||||
}
|
||||
const lines = item.loadError.split("\n");
|
||||
const first = lines[0] ?? item.loadError;
|
||||
return lines.length > 1 ? `${first} (+${lines.length - 1} more)` : first;
|
||||
}
|
||||
|
||||
export function ConfigPanelContent(props: ConfigPanelProps) {
|
||||
const { resolve, dismiss, dialogId, config, loadConfigData } = props;
|
||||
const { height } = useTerminalDimensions();
|
||||
@@ -264,7 +275,7 @@ export function ConfigPanelContent(props: ConfigPanelProps) {
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
activeTab !== "tools" ||
|
||||
(activeTab !== "tools" && activeTab !== "plugins") ||
|
||||
pluginToolsLoaded ||
|
||||
pluginToolsError ||
|
||||
!loadConfigData
|
||||
@@ -288,7 +299,7 @@ export function ConfigPanelContent(props: ConfigPanelProps) {
|
||||
return;
|
||||
}
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
setPluginToolsError(`Failed to load plugin tools: ${message}`);
|
||||
setPluginToolsError(`Failed to load plugin diagnostics: ${message}`);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) {
|
||||
@@ -351,6 +362,19 @@ export function ConfigPanelContent(props: ConfigPanelProps) {
|
||||
enabled: item.enabled,
|
||||
description: item.description,
|
||||
item,
|
||||
rightLabel: getPluginLoadErrorLabel(item),
|
||||
});
|
||||
}
|
||||
if (activeTab === "plugins" && pluginToolsLoading) {
|
||||
r.push({
|
||||
kind: "detail",
|
||||
text: "Loading plugin diagnostics...",
|
||||
});
|
||||
}
|
||||
if (activeTab === "plugins" && pluginToolsError) {
|
||||
r.push({
|
||||
kind: "detail",
|
||||
text: pluginToolsError,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -635,8 +659,9 @@ export function ConfigPanelContent(props: ConfigPanelProps) {
|
||||
const rightLabel = row.rightLabel ?? "";
|
||||
const toggleable = isToggleableConfigItem(row.item);
|
||||
const prefix = " ".repeat(row.indent ?? 0);
|
||||
const rowColor =
|
||||
toggleable && enabledState === "enabled"
|
||||
const rowColor = row.item.loadError
|
||||
? "red"
|
||||
: toggleable && enabledState === "enabled"
|
||||
? palette.success
|
||||
: enabledState === "partial"
|
||||
? "yellow"
|
||||
|
||||
@@ -34,6 +34,7 @@ Requires Node.js 22+.
|
||||
| [quickstart](./quickstart) | Send one prompt, stream the response. ~15 lines of code. | `Agent`, `subscribe`, `run()` |
|
||||
| [quickstart-clinecore](./quickstart-clinecore) | Send one prompt through a persisted Core session. | `ClineCore`, `start()`, `CoreSessionEvent`, `dispose()` |
|
||||
| [cli-agent](./cli-agent) | Interactive terminal chat with a shell tool. | `createTool`, multi-turn `run()`/`continue()`, streaming |
|
||||
| [cline-core-cli-agent](./cline-core-cli-agent) | Interactive terminal chat powered by ClineCore. | `ClineCore.create()`, `cline.start()`, `cline.send()`, built-in tools, streaming |
|
||||
|
||||
### Intermediate
|
||||
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
# Cline Core CLI Agent
|
||||
|
||||
An interactive terminal chat agent powered by the `ClineCore` runtime. This example is similar in spirit to [`cli-agent`](../cli-agent), but uses stateful ClineCore sessions and built-in runtime tools instead of the stateless `Agent` class, to leverage Cline's internal agent harness.
|
||||
|
||||
## Getting started
|
||||
|
||||
Install dependencies:
|
||||
|
||||
```bash
|
||||
bun install
|
||||
bun run build:sdk
|
||||
```
|
||||
|
||||
Set an API key:
|
||||
|
||||
```bash
|
||||
export CLINE_API_KEY="sk_..."
|
||||
```
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
bun dev
|
||||
```
|
||||
|
||||
Type any message at the `you:` prompt to see a streaming response. Type `exit` to quit.
|
||||
|
||||
## Optional model configuration
|
||||
|
||||
The example defaults to Cline's gateway provider and Claude Sonnet:
|
||||
|
||||
```bash
|
||||
export CLINE_PROVIDER_ID="cline"
|
||||
export CLINE_MODEL_ID="anthropic/claude-sonnet-4.6"
|
||||
```
|
||||
|
||||
## What it does
|
||||
|
||||
- Creates a local `ClineCore` runtime with `ClineCore.create()`
|
||||
- Starts one interactive session with `cline.start()`
|
||||
- Sends each user turn with `cline.send({ sessionId, prompt })`
|
||||
- Streams `agent_event` text to stdout as the assistant responds
|
||||
- Logs tool calls and tool results inline
|
||||
- Uses ClineCore's built-in tools instead of defining custom tools
|
||||
- Calls `cline.stop()` and `cline.dispose()` during shutdown
|
||||
|
||||
## Concepts demonstrated
|
||||
|
||||
- Stateful sessions with `ClineCore`
|
||||
- Multi-turn conversation using a single `sessionId`
|
||||
- `CoreSessionEvent` subscription via `cline.subscribe()`
|
||||
- Built-in runtime tools (`read_files`, `search_codebase`, `run_commands`, etc.)
|
||||
- Basic tool policies: file reads/search are auto-approved, other tools request approval
|
||||
|
||||
## Notes
|
||||
|
||||
Use this example when you want the full ClineCore runtime with sessions, persistence, and built-in tools. For the smallest possible SDK example, see [quickstart](../quickstart). For the lightweight stateless runtime, see [cli-agent](../cli-agent).
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "@cline/example-cline-core-cli-agent",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "bun run src/index.ts",
|
||||
"build:sdk": "bun run --cwd ../../.. build:sdk",
|
||||
"build": "tsc",
|
||||
"start": "node dist/index.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@cline/sdk": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.9.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
import * as readline from "node:readline";
|
||||
import {
|
||||
type AgentEvent,
|
||||
ClineCore,
|
||||
type ToolApprovalRequest,
|
||||
} from "@cline/sdk";
|
||||
|
||||
// ClineCore does not choose a model automatically; each session config must provide one.
|
||||
// These example defaults use the Cline gateway with Claude Sonnet, and can be overridden with env vars.
|
||||
const providerId = process.env.CLINE_PROVIDER_ID ?? "cline";
|
||||
const modelId = process.env.CLINE_MODEL_ID ?? "anthropic/claude-sonnet-4.6";
|
||||
const apiKey = process.env.CLINE_API_KEY;
|
||||
const cwd = process.cwd();
|
||||
|
||||
const systemPrompt = `You are a helpful assistant in an interactive terminal chat.
|
||||
Be concise. You can use built-in tools to inspect files, search the workspace, and run shell commands when helpful.`;
|
||||
|
||||
let cline: ClineCore | undefined;
|
||||
let unsubscribe: (() => void) | undefined;
|
||||
let activeSessionId: string | undefined;
|
||||
let hasPrintedAssistantPrefix = false;
|
||||
|
||||
function printAssistantPrefix(): void {
|
||||
if (!hasPrintedAssistantPrefix) {
|
||||
process.stdout.write("\nagent: ");
|
||||
hasPrintedAssistantPrefix = true;
|
||||
}
|
||||
}
|
||||
|
||||
function formatToolValue(value: unknown): string {
|
||||
const output =
|
||||
typeof value === "string" ? value : (JSON.stringify(value, null, 2) ?? "");
|
||||
return output.length > 200 ? `${output.slice(0, 200)}...` : output;
|
||||
}
|
||||
|
||||
function handleAgentEvent(event: AgentEvent): void {
|
||||
switch (event.type) {
|
||||
case "content_start":
|
||||
if (event.contentType === "text" && event.text) {
|
||||
printAssistantPrefix();
|
||||
process.stdout.write(event.text);
|
||||
}
|
||||
if (event.contentType === "tool" && event.toolName) {
|
||||
console.log(
|
||||
`\n[tool] ${event.toolName}(${JSON.stringify(event.input ?? {})})`,
|
||||
);
|
||||
}
|
||||
break;
|
||||
case "content_update":
|
||||
if (event.contentType === "tool" && event.toolName) {
|
||||
console.log(
|
||||
`[update] ${event.toolName}: ${formatToolValue(event.update)}`,
|
||||
);
|
||||
}
|
||||
break;
|
||||
case "content_end":
|
||||
if (event.contentType === "tool" && event.toolName) {
|
||||
if (event.error) {
|
||||
console.log(`[error] ${event.toolName}: ${event.error}`);
|
||||
} else {
|
||||
console.log(`[result] ${formatToolValue(event.output)}`);
|
||||
}
|
||||
}
|
||||
break;
|
||||
case "notice":
|
||||
console.log(`\n[notice] ${event.message}`);
|
||||
break;
|
||||
case "error":
|
||||
console.error(`\n[error] ${event.error.message}`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureCline(): Promise<ClineCore> {
|
||||
if (cline) {
|
||||
return cline;
|
||||
}
|
||||
|
||||
cline = await ClineCore.create({
|
||||
clientName: "cline-core-cli-agent",
|
||||
backendMode: "local",
|
||||
capabilities: {
|
||||
requestToolApproval,
|
||||
},
|
||||
});
|
||||
unsubscribe = cline.subscribe((event) => {
|
||||
if (event.type === "agent_event") {
|
||||
handleAgentEvent(event.payload.event);
|
||||
}
|
||||
});
|
||||
return cline;
|
||||
}
|
||||
|
||||
const rl = readline.createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stdout,
|
||||
});
|
||||
|
||||
function ask(question: string): Promise<string> {
|
||||
return new Promise((resolve) => {
|
||||
rl.question(question, resolve);
|
||||
});
|
||||
}
|
||||
|
||||
async function requestToolApproval(request: ToolApprovalRequest) {
|
||||
console.log(`\n[approval] ${request.toolName} wants to run:`);
|
||||
console.log(formatToolValue(request.input));
|
||||
const answer = await ask("Approve? [y/N] ");
|
||||
const approved = answer.trim().toLowerCase() === "y";
|
||||
return {
|
||||
approved,
|
||||
...(approved ? {} : { reason: "User denied tool execution" }),
|
||||
};
|
||||
}
|
||||
|
||||
function prompt(): Promise<string> {
|
||||
return ask("\nyou: ");
|
||||
}
|
||||
|
||||
async function startSession(): Promise<string> {
|
||||
const runtime = await ensureCline();
|
||||
const result = await runtime.start({
|
||||
source: "cli",
|
||||
interactive: true,
|
||||
config: {
|
||||
providerId,
|
||||
modelId,
|
||||
apiKey,
|
||||
cwd,
|
||||
workspaceRoot: cwd,
|
||||
mode: "act",
|
||||
systemPrompt,
|
||||
maxIterations: 10,
|
||||
enableTools: true,
|
||||
enableSpawnAgent: false,
|
||||
enableAgentTeams: false,
|
||||
disableMcpSettingsTools: true,
|
||||
},
|
||||
toolPolicies: {
|
||||
"*": { autoApprove: false },
|
||||
read_files: { autoApprove: true },
|
||||
search_codebase: { autoApprove: true },
|
||||
},
|
||||
});
|
||||
return result.sessionId;
|
||||
}
|
||||
|
||||
async function runTurn(input: string): Promise<void> {
|
||||
activeSessionId ??= await startSession();
|
||||
hasPrintedAssistantPrefix = false;
|
||||
const runtime = await ensureCline();
|
||||
await runtime.send({
|
||||
sessionId: activeSessionId,
|
||||
prompt: input,
|
||||
});
|
||||
console.log();
|
||||
}
|
||||
|
||||
console.log("ClineCore CLI Agent (type 'exit' to quit)\n");
|
||||
console.log(`Provider: ${providerId}`);
|
||||
console.log(`Model: ${modelId}`);
|
||||
console.log(`CWD: ${cwd}`);
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const input = await prompt();
|
||||
const trimmed = input.trim();
|
||||
if (trimmed.toLowerCase() === "exit") {
|
||||
break;
|
||||
}
|
||||
if (!trimmed) {
|
||||
continue;
|
||||
}
|
||||
|
||||
await runTurn(trimmed);
|
||||
}
|
||||
} finally {
|
||||
rl.close();
|
||||
unsubscribe?.();
|
||||
if (activeSessionId && cline) {
|
||||
await cline.stop(activeSessionId).catch(() => undefined);
|
||||
}
|
||||
await cline?.dispose();
|
||||
console.log("Goodbye!");
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"extends": "../../tsconfig.apps.json",
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"outDir": "dist",
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
+16
-1
@@ -19,7 +19,7 @@
|
||||
},
|
||||
"apps/cli": {
|
||||
"name": "@cline/cli",
|
||||
"version": "3.0.7",
|
||||
"version": "3.0.8",
|
||||
"bin": {
|
||||
"cline": "src/index.ts",
|
||||
},
|
||||
@@ -38,6 +38,7 @@
|
||||
"@opentui/react": "0.1.102",
|
||||
"chat": "^4.23.0",
|
||||
"commander": "^14.0.3",
|
||||
"fzf": "^0.5.2",
|
||||
"marked": "^15.0.12",
|
||||
"open": "^10.2.0",
|
||||
"opentui-spinner": "^0.0.6",
|
||||
@@ -66,6 +67,16 @@
|
||||
"typescript": "^5.9.3",
|
||||
},
|
||||
},
|
||||
"apps/examples/cline-core-cli-agent": {
|
||||
"name": "@cline/example-cline-core-cli-agent",
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"@cline/sdk": "workspace:*",
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.9.3",
|
||||
},
|
||||
},
|
||||
"apps/examples/code-review-bot": {
|
||||
"name": "@cline/example-code-review-bot",
|
||||
"version": "0.0.0",
|
||||
@@ -626,6 +637,8 @@
|
||||
|
||||
"@cline/example-cli-agent": ["@cline/example-cli-agent@workspace:apps/examples/cli-agent"],
|
||||
|
||||
"@cline/example-cline-core-cli-agent": ["@cline/example-cline-core-cli-agent@workspace:apps/examples/cline-core-cli-agent"],
|
||||
|
||||
"@cline/example-code-review-bot": ["@cline/example-code-review-bot@workspace:apps/examples/code-review-bot"],
|
||||
|
||||
"@cline/example-multi-agent": ["@cline/example-multi-agent@workspace:apps/examples/multi-agent"],
|
||||
@@ -2112,6 +2125,8 @@
|
||||
|
||||
"fuzzysort": ["fuzzysort@3.1.0", "", {}, "sha512-sR9BNCjBg6LNgwvxlBd0sBABvQitkLzoVY9MYYROQVX/FvfJ4Mai9LsGhDgd8qYdds0bY77VzYd5iuB+v5rwQQ=="],
|
||||
|
||||
"fzf": ["fzf@0.5.2", "", {}, "sha512-Tt4kuxLXFKHy8KT40zwsUPUkg1CrsgY25FxA2U/j/0WgEDCk3ddc/zLTCCcbSHX9FcKtLuVaDGtGE/STWC+j3Q=="],
|
||||
|
||||
"gaxios": ["gaxios@7.1.4", "", { "dependencies": { "extend": "^3.0.2", "https-proxy-agent": "^7.0.1", "node-fetch": "^3.3.2" } }, "sha512-bTIgTsM2bWn3XklZISBTQX7ZSddGW+IO3bMdGaemHZ3tbqExMENHLx6kKZ/KlejgrMtj8q7wBItt51yegqalrA=="],
|
||||
|
||||
"gcp-metadata": ["gcp-metadata@8.1.2", "", { "dependencies": { "gaxios": "^7.0.0", "google-logging-utils": "^1.0.0", "json-bigint": "^1.0.0" } }, "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg=="],
|
||||
|
||||
@@ -1097,7 +1097,86 @@ describe("HubRuntimeHost", () => {
|
||||
version: 1,
|
||||
event: "run.aborted",
|
||||
sessionId: "sess-1",
|
||||
payload: {},
|
||||
payload: {
|
||||
snapshot: {
|
||||
version: 1,
|
||||
sessionId: "sess-1",
|
||||
status: "running",
|
||||
interactive: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(events).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
type: "agent_event",
|
||||
payload: expect.objectContaining({
|
||||
event: expect.objectContaining({
|
||||
type: "done",
|
||||
reason: "aborted",
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
]),
|
||||
);
|
||||
expect(events).not.toContainEqual(
|
||||
expect.objectContaining({
|
||||
type: "ended",
|
||||
payload: expect.objectContaining({ sessionId: "sess-1" }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("emits ended for terminal interactive hub run events", async () => {
|
||||
let onEvent:
|
||||
| ((event: {
|
||||
version: 1;
|
||||
event: "run.aborted";
|
||||
sessionId: string;
|
||||
payload?: Record<string, unknown>;
|
||||
}) => void)
|
||||
| undefined;
|
||||
subscribeMock.mockImplementation((listener) => {
|
||||
onEvent = listener;
|
||||
return () => {};
|
||||
});
|
||||
commandMock.mockResolvedValue({
|
||||
payload: {
|
||||
session: {
|
||||
sessionId: "sess-1",
|
||||
status: "running",
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
workspaceRoot: "/tmp/project",
|
||||
cwd: "/tmp/project",
|
||||
},
|
||||
},
|
||||
});
|
||||
const events: unknown[] = [];
|
||||
|
||||
const { HubRuntimeHost } = await import("./hub-runtime-host");
|
||||
const host = new HubRuntimeHost({ url: "ws://127.0.0.1:25463/hub" });
|
||||
host.subscribe((event) => events.push(event));
|
||||
|
||||
await host.startSession({
|
||||
config: createConfig(),
|
||||
source: SessionSource.CLI,
|
||||
prompt: "Hey",
|
||||
});
|
||||
|
||||
onEvent?.({
|
||||
version: 1,
|
||||
event: "run.aborted",
|
||||
sessionId: "sess-1",
|
||||
payload: {
|
||||
snapshot: {
|
||||
version: 1,
|
||||
sessionId: "sess-1",
|
||||
status: "cancelled",
|
||||
interactive: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(events).toEqual(
|
||||
|
||||
@@ -1671,6 +1671,7 @@ export class HubRuntimeHost implements RuntimeHost {
|
||||
case "run.completed":
|
||||
case "run.failed":
|
||||
case "run.aborted": {
|
||||
const snapshot = parseCoreSessionSnapshot(event.payload?.snapshot);
|
||||
const reason =
|
||||
typeof event.payload?.reason === "string"
|
||||
? event.payload.reason
|
||||
@@ -1686,6 +1687,9 @@ export class HubRuntimeHost implements RuntimeHost {
|
||||
reason,
|
||||
},
|
||||
});
|
||||
if (snapshot?.interactive === true && snapshot.status === "running") {
|
||||
return;
|
||||
}
|
||||
this.events.emit({
|
||||
type: "ended",
|
||||
payload: {
|
||||
|
||||
@@ -287,7 +287,6 @@ export async function handleRunAbort(
|
||||
(request) => request.sessionId === sessionId,
|
||||
reason,
|
||||
);
|
||||
ctx.publish(ctx.buildEvent("run.aborted", { reason }, sessionId));
|
||||
return okReply(envelope, { applied: true });
|
||||
}
|
||||
|
||||
|
||||
@@ -427,8 +427,14 @@ export {
|
||||
toggleDisabledTool,
|
||||
writeGlobalSettings,
|
||||
} from "./services/global-settings";
|
||||
export type { PluginToolSummary } from "./services/plugin-tools";
|
||||
export { listPluginTools } from "./services/plugin-tools";
|
||||
export type {
|
||||
ListPluginToolsResult,
|
||||
PluginToolSummary,
|
||||
} from "./services/plugin-tools";
|
||||
export {
|
||||
listPluginTools,
|
||||
listPluginToolsWithDiagnostics,
|
||||
} from "./services/plugin-tools";
|
||||
export {
|
||||
addLocalProvider,
|
||||
type DeleteLocalProviderRequest,
|
||||
|
||||
@@ -688,7 +688,7 @@ describe("LocalRuntimeHost", () => {
|
||||
expect(sessionService.readSessionManifest).toHaveBeenCalledWith(sessionId);
|
||||
});
|
||||
|
||||
it("marks interactive turns completed without disposing the session", async () => {
|
||||
it("keeps interactive sessions running after a completed turn", async () => {
|
||||
const sessionId = "sess-interactive-turn-status";
|
||||
const manifest = createManifest(sessionId);
|
||||
const sessionService = {
|
||||
@@ -741,20 +741,16 @@ describe("LocalRuntimeHost", () => {
|
||||
}),
|
||||
);
|
||||
|
||||
expect(sessionService.updateSessionStatus).toHaveBeenCalledWith(
|
||||
sessionId,
|
||||
"completed",
|
||||
0,
|
||||
);
|
||||
expect(sessionService.updateSessionStatus).not.toHaveBeenCalled();
|
||||
await expect(manager.getSession(sessionId)).resolves.toMatchObject({
|
||||
sessionId,
|
||||
status: "completed",
|
||||
status: "running",
|
||||
});
|
||||
expect(agent.shutdown).not.toHaveBeenCalled();
|
||||
expect(runtime.shutdown).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("disposes idle interactive sessions without changing completed status", async () => {
|
||||
it("disposes idle interactive sessions without changing status", async () => {
|
||||
const sessionId = "sess-interactive-dispose";
|
||||
const manifest = createManifest(sessionId);
|
||||
const sessionService = {
|
||||
@@ -2138,6 +2134,110 @@ describe("LocalRuntimeHost", () => {
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps the same live interactive session usable after aborting before the first response", async () => {
|
||||
const sessionId = "sess-abort-then-next-turn";
|
||||
const manifest = createManifest(sessionId);
|
||||
const sessionService = {
|
||||
ensureSessionsDir: vi.fn().mockReturnValue("/tmp/sessions"),
|
||||
createRootSessionWithArtifacts: vi.fn().mockResolvedValue({
|
||||
manifestPath: "/tmp/manifest.json",
|
||||
messagesPath: "/tmp/messages.json",
|
||||
manifest,
|
||||
}),
|
||||
persistSessionMessages: vi.fn(),
|
||||
updateSessionStatus: vi.fn().mockResolvedValue({ updated: true }),
|
||||
writeSessionManifest: vi.fn(),
|
||||
listSessions: vi.fn().mockResolvedValue([]),
|
||||
deleteSession: vi.fn().mockResolvedValue({ deleted: true }),
|
||||
};
|
||||
const runtime = { tools: [], shutdown: vi.fn() };
|
||||
const runtimeBuilder = {
|
||||
build: vi.fn().mockReturnValue(runtime),
|
||||
};
|
||||
let messages: AgentResult["messages"] = [];
|
||||
let activeRun = false;
|
||||
let rejectRun: ((error: Error) => void) | undefined;
|
||||
let markRunStarted: (() => void) | undefined;
|
||||
const runStarted = new Promise<void>((resolve) => {
|
||||
markRunStarted = resolve;
|
||||
});
|
||||
const run = vi.fn(
|
||||
() =>
|
||||
new Promise<AgentResult>((_resolve, reject) => {
|
||||
activeRun = true;
|
||||
messages = [{ role: "user", content: "slow" }];
|
||||
rejectRun = (error) => {
|
||||
activeRun = false;
|
||||
reject(error);
|
||||
};
|
||||
markRunStarted?.();
|
||||
}),
|
||||
);
|
||||
const continueTurn = vi.fn().mockResolvedValue(
|
||||
createResult({
|
||||
text: "continued after abort",
|
||||
}),
|
||||
);
|
||||
const agent = {
|
||||
run,
|
||||
continue: continueTurn,
|
||||
abort: vi.fn(),
|
||||
subscribeEvents: vi.fn().mockReturnValue(() => {}),
|
||||
getAgentId: vi.fn().mockReturnValue("agent-root-1"),
|
||||
getConversationId: vi.fn().mockReturnValue("conv-root-1"),
|
||||
shutdown: vi.fn().mockResolvedValue(undefined),
|
||||
getMessages: vi.fn(() => messages),
|
||||
canStartRun: vi.fn(() => !activeRun),
|
||||
};
|
||||
agent.abort.mockImplementation(() => {
|
||||
rejectRun?.(new Error("user cancelled before first response"));
|
||||
});
|
||||
const manager = new RuntimeHostUnderTest({
|
||||
distinctId,
|
||||
sessionService: sessionService as never,
|
||||
runtimeBuilder: runtimeBuilder as never,
|
||||
createAgent: () => agent as never,
|
||||
});
|
||||
|
||||
await manager.startSession(
|
||||
normalizeStartInput({
|
||||
config: createConfig({ sessionId }),
|
||||
interactive: true,
|
||||
}),
|
||||
);
|
||||
const events: unknown[] = [];
|
||||
manager.subscribe((event) => events.push(event));
|
||||
|
||||
const firstTurn = manager.runTurn({ sessionId, prompt: "slow" });
|
||||
await runStarted;
|
||||
await manager.abort(sessionId, new Error("test abort"));
|
||||
await expect(firstTurn).resolves.toMatchObject({
|
||||
finishReason: "aborted",
|
||||
});
|
||||
|
||||
await expect(
|
||||
manager.runTurn({ sessionId, prompt: "next turn after abort" }),
|
||||
).resolves.toMatchObject({
|
||||
finishReason: "completed",
|
||||
text: "continued after abort",
|
||||
});
|
||||
await expect(manager.getSession(sessionId)).resolves.toMatchObject({
|
||||
sessionId,
|
||||
status: "running",
|
||||
});
|
||||
expect(run).toHaveBeenCalledTimes(1);
|
||||
expect(continueTurn).toHaveBeenCalledTimes(1);
|
||||
expect(agent.shutdown).not.toHaveBeenCalled();
|
||||
expect(runtime.shutdown).not.toHaveBeenCalled();
|
||||
expect(sessionService.updateSessionStatus).not.toHaveBeenCalled();
|
||||
expect(events).not.toContainEqual(
|
||||
expect.objectContaining({
|
||||
type: "ended",
|
||||
payload: expect.objectContaining({ sessionId, reason: "aborted" }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("preserves per-turn metadata on prior assistant messages across turns", async () => {
|
||||
const sessionId = "sess-meta-multi";
|
||||
const manifest = createManifest(sessionId);
|
||||
@@ -2822,12 +2922,7 @@ describe("LocalRuntimeHost", () => {
|
||||
"running",
|
||||
null,
|
||||
);
|
||||
expect(updateSessionStatus).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
sessionId,
|
||||
"completed",
|
||||
0,
|
||||
);
|
||||
expect(updateSessionStatus).toHaveBeenCalledTimes(1);
|
||||
const persistedMetadata = updateSession.mock.calls[0]?.[0]
|
||||
.metadata as Record<string, unknown>;
|
||||
expect(persistedMetadata.title).toBe("saved title");
|
||||
|
||||
@@ -559,6 +559,7 @@ export class LocalRuntimeHost implements RuntimeHost {
|
||||
drainingPendingPrompts: false,
|
||||
pluginSandboxShutdown: bootstrap.pluginSandboxShutdown,
|
||||
submitAndExitObserved: false,
|
||||
lastInteractiveTurnFinishReason: undefined,
|
||||
};
|
||||
this.sessions.set(sessionId, active);
|
||||
this.emitStatus(sessionId, "running");
|
||||
@@ -747,6 +748,15 @@ export class LocalRuntimeHost implements RuntimeHost {
|
||||
await this.releaseSessionRuntime(session, "session_stop");
|
||||
return;
|
||||
}
|
||||
if (session.interactive && session.agent.canStartRun()) {
|
||||
await this.shutdownSession(session, {
|
||||
status: this.resolveInteractiveStopStatus(session),
|
||||
exitCode: this.resolveInteractiveStopExitCode(session),
|
||||
shutdownReason: "session_stop",
|
||||
endReason: "stopped",
|
||||
});
|
||||
return;
|
||||
}
|
||||
// Abort the agent first if it's running, so shutdown can proceed
|
||||
session.aborting = true;
|
||||
session.agent.abort(new Error("session_stop"));
|
||||
@@ -765,12 +775,19 @@ export class LocalRuntimeHost implements RuntimeHost {
|
||||
sessions.map((session) =>
|
||||
session.interactive && session.status !== "running"
|
||||
? this.releaseSessionRuntime(session, reason)
|
||||
: this.shutdownSession(session, {
|
||||
status: "cancelled",
|
||||
exitCode: 0,
|
||||
shutdownReason: reason,
|
||||
endReason: "disposed",
|
||||
}),
|
||||
: session.interactive && session.agent.canStartRun()
|
||||
? this.shutdownSession(session, {
|
||||
status: this.resolveInteractiveStopStatus(session),
|
||||
exitCode: this.resolveInteractiveStopExitCode(session),
|
||||
shutdownReason: reason,
|
||||
endReason: "disposed",
|
||||
})
|
||||
: this.shutdownSession(session, {
|
||||
status: "cancelled",
|
||||
exitCode: 0,
|
||||
shutdownReason: reason,
|
||||
endReason: "disposed",
|
||||
}),
|
||||
),
|
||||
);
|
||||
this.usageBySession.clear();
|
||||
@@ -945,24 +962,34 @@ export class LocalRuntimeHost implements RuntimeHost {
|
||||
finishReason: AgentResult["finishReason"],
|
||||
): Promise<void> {
|
||||
if (hasPendingTeamRunWork(session)) return;
|
||||
const isAborted = finishReason === "aborted" || session.aborting;
|
||||
const isError = finishReason === "error";
|
||||
await this.updateStatus(
|
||||
session,
|
||||
isAborted ? "cancelled" : isError ? "failed" : "completed",
|
||||
isError ? 1 : 0,
|
||||
);
|
||||
this.emit({
|
||||
type: "ended",
|
||||
payload: {
|
||||
sessionId: session.sessionId,
|
||||
reason: finishReason,
|
||||
ts: Date.now(),
|
||||
},
|
||||
});
|
||||
session.lastInteractiveTurnFinishReason = finishReason;
|
||||
await this.markTurnRunning(session);
|
||||
session.aborting = false;
|
||||
}
|
||||
|
||||
private resolveInteractiveStopStatus(session: ActiveSession): SessionStatus {
|
||||
const finishReason = session.lastInteractiveTurnFinishReason;
|
||||
if (!finishReason) return "cancelled";
|
||||
|
||||
switch (finishReason) {
|
||||
case "completed":
|
||||
return "completed";
|
||||
case "error":
|
||||
return "failed";
|
||||
case "aborted":
|
||||
case "max_iterations":
|
||||
case "mistake_limit":
|
||||
return "cancelled";
|
||||
}
|
||||
|
||||
const _exhaustive: never = finishReason;
|
||||
return _exhaustive;
|
||||
}
|
||||
|
||||
private resolveInteractiveStopExitCode(session: ActiveSession): number {
|
||||
return session.lastInteractiveTurnFinishReason === "error" ? 1 : 0;
|
||||
}
|
||||
|
||||
private async completeAbortedInteractiveTurn(
|
||||
session: ActiveSession,
|
||||
): Promise<AgentResult> {
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import type { AgentConfig, AgentTool } from "@cline/shared";
|
||||
import { resolveAgentPluginPaths } from "../extensions/plugin/plugin-config-loader";
|
||||
import type {
|
||||
PluginInitializationFailure,
|
||||
PluginInitializationWarning,
|
||||
} from "../extensions/plugin/plugin-load-report";
|
||||
import { loadSandboxedPlugins } from "../extensions/plugin/plugin-sandbox";
|
||||
import { resolveDisabledToolNames } from "./global-settings";
|
||||
|
||||
@@ -15,6 +19,12 @@ export interface PluginToolSummary {
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface ListPluginToolsResult {
|
||||
tools: PluginToolSummary[];
|
||||
failures: PluginInitializationFailure[];
|
||||
warnings: PluginInitializationWarning[];
|
||||
}
|
||||
|
||||
function collectRegisteredTools(
|
||||
extension: AgentExtension,
|
||||
workspaceInfo?: { rootPath: string },
|
||||
@@ -36,19 +46,21 @@ function collectRegisteredTools(
|
||||
return tools;
|
||||
}
|
||||
|
||||
export async function listPluginTools(input: {
|
||||
export async function listPluginToolsWithDiagnostics(input: {
|
||||
workspacePath: string;
|
||||
cwd?: string;
|
||||
disabledToolNames?: ReadonlyArray<string>;
|
||||
providerId?: string;
|
||||
modelId?: string;
|
||||
}): Promise<PluginToolSummary[]> {
|
||||
}): Promise<ListPluginToolsResult> {
|
||||
const pluginPaths = resolveAgentPluginPaths({
|
||||
workspacePath: input.workspacePath,
|
||||
cwd: input.cwd,
|
||||
});
|
||||
const disabled = resolveDisabledToolNames(input.disabledToolNames);
|
||||
const summaries: PluginToolSummary[] = [];
|
||||
const tools: PluginToolSummary[] = [];
|
||||
const failures: PluginInitializationFailure[] = [];
|
||||
const warnings: PluginInitializationWarning[] = [];
|
||||
|
||||
for (const pluginPath of pluginPaths) {
|
||||
let sandboxed: Awaited<ReturnType<typeof loadSandboxedPlugins>> | undefined;
|
||||
@@ -60,11 +72,13 @@ export async function listPluginTools(input: {
|
||||
modelId: input.modelId,
|
||||
workspaceInfo: { rootPath: input.workspacePath },
|
||||
});
|
||||
failures.push(...sandboxed.failures);
|
||||
warnings.push(...sandboxed.warnings);
|
||||
for (const extension of sandboxed.extensions ?? []) {
|
||||
for (const tool of collectRegisteredTools(extension, {
|
||||
rootPath: input.workspacePath,
|
||||
})) {
|
||||
summaries.push({
|
||||
tools.push({
|
||||
name: tool.name,
|
||||
pluginName: extension.name,
|
||||
path: pluginPath,
|
||||
@@ -76,9 +90,13 @@ export async function listPluginTools(input: {
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Tool listing is best effort so settings can still render built-in tools
|
||||
// if one plugin fails to initialize.
|
||||
} catch (error) {
|
||||
failures.push({
|
||||
pluginPath,
|
||||
phase: "load",
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
stack: error instanceof Error ? error.stack : undefined,
|
||||
});
|
||||
} finally {
|
||||
await sandboxed?.shutdown().catch(() => {
|
||||
// Best effort cleanup after contribution discovery.
|
||||
@@ -86,11 +104,25 @@ export async function listPluginTools(input: {
|
||||
}
|
||||
}
|
||||
|
||||
return summaries.sort((left, right) => {
|
||||
const nameOrder = left.name.localeCompare(right.name);
|
||||
if (nameOrder !== 0) {
|
||||
return nameOrder;
|
||||
}
|
||||
return left.path.localeCompare(right.path);
|
||||
});
|
||||
return {
|
||||
tools: tools.sort((left, right) => {
|
||||
const nameOrder = left.name.localeCompare(right.name);
|
||||
if (nameOrder !== 0) {
|
||||
return nameOrder;
|
||||
}
|
||||
return left.path.localeCompare(right.path);
|
||||
}),
|
||||
failures,
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
|
||||
export async function listPluginTools(input: {
|
||||
workspacePath: string;
|
||||
cwd?: string;
|
||||
disabledToolNames?: ReadonlyArray<string>;
|
||||
providerId?: string;
|
||||
modelId?: string;
|
||||
}): Promise<PluginToolSummary[]> {
|
||||
return (await listPluginToolsWithDiagnostics(input)).tools;
|
||||
}
|
||||
|
||||
@@ -120,8 +120,14 @@ export {
|
||||
toggleDisabledTool,
|
||||
writeGlobalSettings,
|
||||
} from "./services/global-settings";
|
||||
export type { PluginToolSummary } from "./services/plugin-tools";
|
||||
export { listPluginTools } from "./services/plugin-tools";
|
||||
export type {
|
||||
ListPluginToolsResult,
|
||||
PluginToolSummary,
|
||||
} from "./services/plugin-tools";
|
||||
export {
|
||||
listPluginTools,
|
||||
listPluginToolsWithDiagnostics,
|
||||
} from "./services/plugin-tools";
|
||||
export type {
|
||||
WorkspaceManager,
|
||||
WorkspaceManagerEvent,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type * as LlmsProviders from "@cline/llms";
|
||||
import type { AgentFinishReason } from "@cline/shared";
|
||||
import type { SessionAccumulatedUsage } from "../runtime/host/runtime-host";
|
||||
import type { BuiltRuntime } from "../runtime/orchestration/session-runtime";
|
||||
import type { SessionRuntime } from "../runtime/orchestration/session-runtime-orchestrator";
|
||||
@@ -35,6 +36,7 @@ export type ActiveSession = {
|
||||
turnAggregateUsageBaseline?: SessionAccumulatedUsage;
|
||||
turnPrimaryUsage?: SessionAccumulatedUsage;
|
||||
turnUsageByAgent?: Map<string, SessionAccumulatedUsage>;
|
||||
lastInteractiveTurnFinishReason?: AgentFinishReason;
|
||||
/**
|
||||
* Set to `true` once the assistant successfully invoked the canonical
|
||||
* completion tool (`submit_and_exit`) for this session. Used to:
|
||||
|
||||
@@ -15,6 +15,11 @@ alwaysApply: true
|
||||
quirks, default behavior, or wire-format details.
|
||||
- Stable, reliable known-model facts belong in typed `ModelInfo.metadata`
|
||||
helpers or `src/providers/model-facts.ts`.
|
||||
- Stable provider routing facts belong in `GatewayProviderMetadata.routing`
|
||||
and the relevant builtin provider manifest. For example, if a native
|
||||
provider uses a known reasoning wire format for a model route, add a typed
|
||||
`GatewayReasoningFormat` value and route metadata instead of matching that
|
||||
provider id directly in a rule predicate.
|
||||
- Provider wire-format encoding belongs in `PROVIDER_OPTION_RULES` and codec
|
||||
helpers under `src/providers/routing`.
|
||||
- Local or dynamic provider fallbacks, such as Ollama or routed model-id
|
||||
|
||||
@@ -2,4 +2,5 @@
|
||||
|
||||
## Next Release
|
||||
|
||||
- Supports Bedrock bearer API keys, direct IAM credentials, AWS profiles, and the default AWS SDK credential chain
|
||||
- Supports Bedrock bearer API keys, direct IAM credentials, AWS profiles, and the default AWS SDK credential chain
|
||||
- Routes Z.AI GLM thinking through provider metadata while preserving generic thinking suppression for non-GLM Z.AI custom models
|
||||
|
||||
@@ -103,4 +103,24 @@ describe("built-in provider metadata", () => {
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("routes native Z.AI providers through GLM thinking metadata", async () => {
|
||||
for (const providerId of ["zai", "zai-coding-plan"] as const) {
|
||||
await expect(getProvider(providerId)).resolves.toMatchObject({
|
||||
metadata: {
|
||||
routing: {
|
||||
reasoning: {
|
||||
format: "glm-thinking",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const models = Object.values(await getModelsForProvider(providerId));
|
||||
expect(models.length).toBeGreaterThan(0);
|
||||
for (const model of models) {
|
||||
expect(model.family?.startsWith("glm")).toBe(true);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
ANTHROPIC_ROUTING_METADATA,
|
||||
QWEN_CACHE_ROUTING_METADATA,
|
||||
} from "./routing/anthropic-compatible";
|
||||
import { GLM_THINKING_ROUTING_METADATA } from "./routing/glm-thinking";
|
||||
|
||||
export const DEFAULT_INTERNAL_OCA_BASE_URL =
|
||||
"https://code-internal.aiservice.us-chicago-1.oci.oraclecloud.com/20250206/app/litellm";
|
||||
@@ -507,6 +508,7 @@ const OPENAI_COMPATIBLE_SPECS: BuiltinSpec[] = [
|
||||
apiKeyEnv: ["ZHIPU_API_KEY"],
|
||||
modelsProviderId: "zai",
|
||||
defaults: { baseUrl: "https://api.z.ai/api/paas/v4" },
|
||||
metadata: GLM_THINKING_ROUTING_METADATA,
|
||||
},
|
||||
{
|
||||
id: "zai-coding-plan",
|
||||
@@ -518,6 +520,7 @@ const OPENAI_COMPATIBLE_SPECS: BuiltinSpec[] = [
|
||||
apiKeyEnv: ["ZHIPU_API_KEY"],
|
||||
modelsProviderId: "zai-coding-plan",
|
||||
defaults: { baseUrl: "https://api.z.ai/api/coding/paas/v4" },
|
||||
metadata: GLM_THINKING_ROUTING_METADATA,
|
||||
},
|
||||
{
|
||||
id: "moonshot",
|
||||
|
||||
@@ -2712,7 +2712,7 @@ describe("sdk-gateway", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("does not apply Z.AI GLM thinking controls to non-GLM native Z.AI models", async () => {
|
||||
it("does not apply generic thinking to non-GLM native Z.AI custom models", async () => {
|
||||
streamTextSpy.mockReturnValue({
|
||||
fullStream: makeStreamParts([
|
||||
{ type: "finish", usage: { inputTokens: 1, outputTokens: 1 } },
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type {
|
||||
GatewayModelRoute,
|
||||
GatewayProviderContext,
|
||||
GatewayReasoningFormat,
|
||||
GatewayStreamRequest,
|
||||
} from "@cline/shared";
|
||||
|
||||
@@ -20,7 +21,9 @@ function normalizedFamily(context: GatewayProviderContext): string {
|
||||
return normalizeRoutingValue(resolveModelFamily(context)) ?? "";
|
||||
}
|
||||
|
||||
function normalizedModelId(request: Pick<GatewayStreamRequest, "modelId">): string {
|
||||
function normalizedModelId(
|
||||
request: Pick<GatewayStreamRequest, "modelId">,
|
||||
): string {
|
||||
return normalizeRoutingValue(request.modelId) ?? "";
|
||||
}
|
||||
|
||||
@@ -37,7 +40,9 @@ function isClaudeLineageValue(value: string | undefined): boolean {
|
||||
|
||||
function isQwenLineageValue(value: string | undefined): boolean {
|
||||
const normalized = normalizeRoutingValue(value);
|
||||
return normalized ? /(^|[/:._-])qwen(?:$|[/:._-]|\d)/.test(normalized) : false;
|
||||
return normalized
|
||||
? /(^|[/:._-])qwen(?:$|[/:._-]|\d)/.test(normalized)
|
||||
: false;
|
||||
}
|
||||
|
||||
export function isAnthropicCompatibleModel(options: {
|
||||
@@ -128,6 +133,25 @@ export function modelRouteMatches(
|
||||
}
|
||||
}
|
||||
|
||||
export function providerReasoningRouteMatches(
|
||||
format: GatewayReasoningFormat,
|
||||
request: Pick<GatewayStreamRequest, "modelId">,
|
||||
context: GatewayProviderContext,
|
||||
): boolean {
|
||||
const reasoning = context.provider.metadata?.routing?.reasoning;
|
||||
if (reasoning?.format !== format) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return reasoning.routes.some((route) =>
|
||||
modelRouteMatches(route, {
|
||||
modelId: request.modelId,
|
||||
family: resolveModelFamily(context),
|
||||
capabilities: context.model.capabilities,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export function isGlmModel(
|
||||
request: Pick<GatewayStreamRequest, "modelId">,
|
||||
context: GatewayProviderContext,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type {
|
||||
GatewayProviderContext,
|
||||
GatewayProviderMetadata,
|
||||
GatewayStreamRequest,
|
||||
} from "@cline/shared";
|
||||
import { isGlmModel } from "../model-facts";
|
||||
@@ -14,9 +15,18 @@ import type { ProviderOptionsPatch } from "./utils";
|
||||
* composer can rely on merge order instead of out-of-band flags.
|
||||
*/
|
||||
|
||||
export function isNativeZaiProvider(providerId: string): boolean {
|
||||
return providerId === "zai" || providerId === "zai-coding-plan";
|
||||
}
|
||||
export const GLM_THINKING_ROUTING_METADATA: GatewayProviderMetadata = {
|
||||
routing: {
|
||||
reasoning: {
|
||||
format: "glm-thinking",
|
||||
routes: [
|
||||
{ matcher: "model-family", family: "glm" },
|
||||
{ matcher: "model-family", family: "glm-air" },
|
||||
{ matcher: "model-family", family: "glm-flash" },
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
function buildNativeZaiThinkingOptions(request: GatewayStreamRequest) {
|
||||
if (request.reasoning?.enabled === undefined) {
|
||||
@@ -47,28 +57,32 @@ function buildRoutedGlmReasoningOptions(request: GatewayStreamRequest) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function buildGlmThinkingProviderOptionsPatch(
|
||||
export function buildNativeGlmThinkingProviderOptionsPatch(
|
||||
request: GatewayStreamRequest,
|
||||
providerOptionsKey: string,
|
||||
): ProviderOptionsPatch | undefined {
|
||||
// Native Z.AI GLM endpoints expect `thinking.type`; they do not accept the
|
||||
// routed `reasoning.enabled` / `reasoning.exclude` shape.
|
||||
const nativeThinking = buildNativeZaiThinkingOptions(request);
|
||||
return nativeThinking
|
||||
? {
|
||||
openaiCompatible: nativeThinking,
|
||||
[request.providerId]: nativeThinking,
|
||||
...(providerOptionsKey !== request.providerId
|
||||
? { [providerOptionsKey]: nativeThinking }
|
||||
: {}),
|
||||
}
|
||||
: undefined;
|
||||
}
|
||||
|
||||
export function buildRoutedGlmReasoningProviderOptionsPatch(
|
||||
request: GatewayStreamRequest,
|
||||
context: GatewayProviderContext,
|
||||
providerOptionsKey: string,
|
||||
options?: { includeProviderBuckets?: boolean },
|
||||
): ProviderOptionsPatch | undefined {
|
||||
if (isNativeZaiProvider(request.providerId)) {
|
||||
if (!isGlmModel(request, context)) {
|
||||
return undefined;
|
||||
}
|
||||
const nativeThinking = buildNativeZaiThinkingOptions(request);
|
||||
return nativeThinking
|
||||
? {
|
||||
openaiCompatible: nativeThinking,
|
||||
[request.providerId]: nativeThinking,
|
||||
...(providerOptionsKey !== request.providerId
|
||||
? { [providerOptionsKey]: nativeThinking }
|
||||
: {}),
|
||||
}
|
||||
: undefined;
|
||||
}
|
||||
|
||||
// Routed GLM endpoints stay OpenAI-compatible and use the generic
|
||||
// `reasoning` include/exclude shape instead of native Z.AI `thinking.type`.
|
||||
if (!isGlmModel(request, context)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
import { buildGatewayReasoningOptions } from "./anthropic-compatible";
|
||||
import { buildOpenAINativeProviderOptions } from "./generic-compatible";
|
||||
import {
|
||||
buildGlmThinkingProviderOptionsPatch,
|
||||
isNativeZaiProvider,
|
||||
} from "./glm-thinking";
|
||||
import {
|
||||
isDeepSeekFamily,
|
||||
isGlmModel,
|
||||
isKimiK26Family as isKimiK26FamilyFact,
|
||||
isMoonshotKimiModelIdFallback,
|
||||
modelReasoningDefaultsOn,
|
||||
providerReasoningRouteMatches,
|
||||
} from "../model-facts";
|
||||
import { buildGatewayReasoningOptions } from "./anthropic-compatible";
|
||||
import { buildOpenAINativeProviderOptions } from "./generic-compatible";
|
||||
import {
|
||||
buildNativeGlmThinkingProviderOptionsPatch,
|
||||
buildRoutedGlmReasoningProviderOptionsPatch,
|
||||
} from "./glm-thinking";
|
||||
import type {
|
||||
MatchedProviderOptionRule,
|
||||
ProviderOptionBuildInput,
|
||||
@@ -54,6 +55,25 @@ function isOllamaReasoningDefaultOnDisable(
|
||||
);
|
||||
}
|
||||
|
||||
function usesGlmThinkingProviderRouting(
|
||||
input: ProviderOptionMatchInput,
|
||||
): boolean {
|
||||
return providerReasoningRouteMatches(
|
||||
"glm-thinking",
|
||||
input.request,
|
||||
input.context,
|
||||
);
|
||||
}
|
||||
|
||||
function hasGlmThinkingProviderRouting(
|
||||
input: ProviderOptionMatchInput,
|
||||
): boolean {
|
||||
return (
|
||||
input.context.provider.metadata?.routing?.reasoning?.format ===
|
||||
"glm-thinking"
|
||||
);
|
||||
}
|
||||
|
||||
function resolveFamilyThinkingType(
|
||||
input: ProviderOptionMatchInput,
|
||||
defaultWhenUnset: "enabled" | "disabled" | undefined,
|
||||
@@ -285,31 +305,28 @@ const ollamaReasoningDefaultOnDisableRule: ProviderOptionRule = {
|
||||
},
|
||||
};
|
||||
|
||||
const nativeZaiNonGlmSuppressionRule: ProviderOptionRule = {
|
||||
id: "provider.zai.non-glm.suppress-generic-thinking",
|
||||
const nonGlmProviderRoutingSuppressionRule: ProviderOptionRule = {
|
||||
id: "provider.routing.glm-thinking.non-glm.suppress-generic-thinking",
|
||||
phase: "provider",
|
||||
description:
|
||||
"Native Z.AI non-GLM models should not inherit adaptive OpenAI-compatible thinking.",
|
||||
"Providers with GLM thinking routing should not apply generic adaptive thinking to non-GLM models.",
|
||||
applies: (input) =>
|
||||
isNativeZaiProvider(input.request.providerId) &&
|
||||
hasGlmThinkingProviderRouting(input) &&
|
||||
input.request.reasoning?.enabled !== undefined &&
|
||||
!isGlmModel(input.request, input.context),
|
||||
!usesGlmThinkingProviderRouting(input),
|
||||
suppresses: { genericThinking: true },
|
||||
build: () => undefined,
|
||||
};
|
||||
|
||||
const nativeZaiGlmThinkingRule: ProviderOptionRule = {
|
||||
id: "family.glm.native-zai-thinking",
|
||||
id: "provider.routing.glm-thinking",
|
||||
phase: "model-overlay",
|
||||
description: "Native Z.AI GLM models use thinking.type.",
|
||||
applies: (input) =>
|
||||
isNativeZaiProvider(input.request.providerId) &&
|
||||
isGlmModel(input.request, input.context),
|
||||
description: "Providers routed to the GLM thinking format use thinking.type.",
|
||||
applies: usesGlmThinkingProviderRouting,
|
||||
suppresses: { genericThinking: true },
|
||||
build: (input) =>
|
||||
buildGlmThinkingProviderOptionsPatch(
|
||||
buildNativeGlmThinkingProviderOptionsPatch(
|
||||
input.request,
|
||||
input.context,
|
||||
input.providerOptionsKey,
|
||||
),
|
||||
};
|
||||
@@ -320,11 +337,11 @@ const routedGlmReasoningRule: ProviderOptionRule = {
|
||||
description:
|
||||
"Routed GLM models use the generic reasoning include/exclude shape, not thinking.type.",
|
||||
applies: (input) =>
|
||||
!isNativeZaiProvider(input.request.providerId) &&
|
||||
!usesGlmThinkingProviderRouting(input) &&
|
||||
isGlmModel(input.request, input.context),
|
||||
suppresses: { genericThinking: true },
|
||||
build: (input) =>
|
||||
buildGlmThinkingProviderOptionsPatch(
|
||||
buildRoutedGlmReasoningProviderOptionsPatch(
|
||||
input.request,
|
||||
input.context,
|
||||
input.providerOptionsKey,
|
||||
@@ -353,7 +370,7 @@ export const PROVIDER_OPTION_RULES: ReadonlyArray<ProviderOptionRule> = [
|
||||
kimiK26ThinkingRule,
|
||||
deepSeekThinkingRule,
|
||||
ollamaReasoningDefaultOnDisableRule,
|
||||
nativeZaiNonGlmSuppressionRule,
|
||||
nonGlmProviderRoutingSuppressionRule,
|
||||
nativeZaiGlmThinkingRule,
|
||||
routedGlmReasoningRule,
|
||||
];
|
||||
|
||||
@@ -3,6 +3,7 @@ import type {
|
||||
GatewayStreamRequest,
|
||||
} from "@cline/shared";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { GLM_THINKING_ROUTING_METADATA } from "./glm-thinking";
|
||||
import {
|
||||
composeAiSdkProviderOptions,
|
||||
mergeProviderOptionPatches,
|
||||
@@ -770,6 +771,7 @@ describe("composeAiSdkProviderOptions: family/provider thinking patches", () =>
|
||||
modelId: "glm-4.7",
|
||||
reasoning: { enabled: true },
|
||||
},
|
||||
context: { family: "glm", metadata: GLM_THINKING_ROUTING_METADATA },
|
||||
expect: [
|
||||
{ bucket: "zai", has: { thinking: { type: "enabled" } } },
|
||||
{
|
||||
@@ -779,6 +781,19 @@ describe("composeAiSdkProviderOptions: family/provider thinking patches", () =>
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "native zai custom non-GLM -> no generic adaptive thinking",
|
||||
request: {
|
||||
providerId: "zai",
|
||||
modelId: "zai-other-model",
|
||||
reasoning: { enabled: true },
|
||||
},
|
||||
context: { family: "other", metadata: GLM_THINKING_ROUTING_METADATA },
|
||||
expect: [
|
||||
{ bucket: "zai", lacks: ["thinking", "reasoning"] },
|
||||
{ bucket: "openaiCompatible", lacks: ["thinking", "reasoning"] },
|
||||
],
|
||||
},
|
||||
// Kimi K2.6 family: explicit enabled/disabled and unset defaults to enabled
|
||||
{
|
||||
name: "cline Kimi K2.6 family reasoning.enabled=false -> thinking.type=disabled",
|
||||
|
||||
@@ -35,7 +35,7 @@ export type GatewayModelCapability =
|
||||
export type GatewayPromptCacheStrategy = "anthropic-automatic";
|
||||
export type GatewayUsageCostDisplay = "show" | "hide";
|
||||
export type GatewayPromptCacheFormat = "anthropic-cache-control";
|
||||
export type GatewayReasoningFormat = "anthropic-thinking";
|
||||
export type GatewayReasoningFormat = "anthropic-thinking" | "glm-thinking";
|
||||
export type GatewayModelRoute =
|
||||
| { matcher: "anthropic-compatible" }
|
||||
| {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, it } from "mocha"
|
||||
import "should"
|
||||
import type { ApiHandlerModel } from "@core/api"
|
||||
import type { ApiHandlerModel, ApiProviderInfo } from "@core/api"
|
||||
import {
|
||||
GEMINI_FLASH_MAX_OUTPUT_TOKENS,
|
||||
isClaude4PlusModelFamily,
|
||||
@@ -8,12 +8,20 @@ import {
|
||||
isGLMModelFamily,
|
||||
isGPT5ModelFamily,
|
||||
isGptOssModelFamily,
|
||||
isNativeToolCallingConfig,
|
||||
isNextGenModelFamily,
|
||||
isPoolsideModelFamily,
|
||||
modelDoesntSupportWebp,
|
||||
shouldSkipReasoningForModel,
|
||||
} from "../model-utils"
|
||||
|
||||
// Minimal helper — modelDoesntSupportWebp only reads apiHandlerModel.id
|
||||
const m = (id: string): ApiHandlerModel => ({ id, info: {} as any })
|
||||
const m = (id: string): ApiHandlerModel => ({ id, info: { supportsPromptCache: false } })
|
||||
const providerInfo = (providerId: string, modelId: string): ApiProviderInfo => ({
|
||||
providerId,
|
||||
model: m(modelId),
|
||||
mode: "act",
|
||||
})
|
||||
|
||||
describe("shouldSkipReasoningForModel", () => {
|
||||
it("should return true for grok-4 models", () => {
|
||||
@@ -116,6 +124,26 @@ describe("isGptOssModelFamily", () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe("isPoolsideModelFamily", () => {
|
||||
it("should return true for Laguna model IDs", () => {
|
||||
isPoolsideModelFamily("poolside/laguna-m.1").should.equal(true)
|
||||
isPoolsideModelFamily("laguna-enterprise").should.equal(true)
|
||||
isPoolsideModelFamily("LAGUNA").should.equal(true)
|
||||
})
|
||||
|
||||
it("should return false for non-Poolside model IDs", () => {
|
||||
isPoolsideModelFamily("gpt-5").should.equal(false)
|
||||
isPoolsideModelFamily("deepseek-chat").should.equal(false)
|
||||
isPoolsideModelFamily("claude-sonnet-4").should.equal(false)
|
||||
})
|
||||
|
||||
it("should qualify Laguna models for next-gen and native tool calling paths", () => {
|
||||
isNextGenModelFamily("poolside/laguna-m.1").should.equal(true)
|
||||
isNativeToolCallingConfig(providerInfo("openai-compatible", "poolside/laguna-m.1"), true).should.equal(true)
|
||||
isNativeToolCallingConfig(providerInfo("openai-compatible", "poolside/laguna-m.1"), false).should.equal(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("isGeminiFlashModel", () => {
|
||||
it("should return true for Gemini Flash model IDs", () => {
|
||||
isGeminiFlashModel("google/gemini-2.5-flash").should.equal(true)
|
||||
|
||||
@@ -189,6 +189,11 @@ export function isDeepSeekNativeModelFamily(id: string): boolean {
|
||||
return modelId.includes("deepseek-chat") || modelId.includes("deepseek-reasoner")
|
||||
}
|
||||
|
||||
export function isPoolsideModelFamily(id: string): boolean {
|
||||
const modelId = normalize(id)
|
||||
return modelId.includes("laguna")
|
||||
}
|
||||
|
||||
export function isNextGenModelFamily(id: string): boolean {
|
||||
const modelId = normalize(id)
|
||||
return (
|
||||
@@ -201,7 +206,8 @@ export function isNextGenModelFamily(id: string): boolean {
|
||||
isGemini3ModelFamily(modelId) ||
|
||||
isNextGenOpenSourceModelFamily(modelId) ||
|
||||
isDeepSeek32ModelFamily(modelId) ||
|
||||
isDeepSeekNativeModelFamily(modelId)
|
||||
isDeepSeekNativeModelFamily(modelId) ||
|
||||
isPoolsideModelFamily(modelId)
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user