mirror of
https://github.com/cline/cline.git
synced 2026-09-05 14:14:01 +08:00
Compare commits
34 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b7f955c78b | |||
| 8433549327 | |||
| 860f544ab8 | |||
| 0761de984b | |||
| 940b43f72f | |||
| a466060f79 | |||
| 2fd944cb84 | |||
| 7d1abb25a7 | |||
| 628aaa0675 | |||
| af7fda87c0 | |||
| 9af6ced896 | |||
| a3258dd79a | |||
| ebb05ed963 | |||
| 8d03d176f2 | |||
| 32b3cfc081 | |||
| a6500a07b4 | |||
| 81384089c4 | |||
| 6364792c47 | |||
| 40fc8879f1 | |||
| e91cba4045 | |||
| e5e1aa3455 | |||
| d2893d2e93 | |||
| 4fc366df5f | |||
| d8eb06318b | |||
| fe4eb44c6b | |||
| 6c52bdc177 | |||
| 5260595472 | |||
| a279388451 | |||
| 7810a81efe | |||
| 2a54e2a76e | |||
| b7c38f76c9 | |||
| d20e517831 | |||
| fa3630da47 | |||
| 8229d0c9be |
@@ -1,5 +1,25 @@
|
||||
# Cline CLI Changelog
|
||||
|
||||
## 3.0.26
|
||||
|
||||
- Reverted the expandable model picker sections and ClinePass models, restoring the previous model-selection UI
|
||||
|
||||
## 3.0.25
|
||||
|
||||
- Added ClinePass support, with selectable ClinePass models in the model picker
|
||||
- Made model picker sections expandable
|
||||
- Added MCP server support to plugins, including authorizing plugin MCP OAuth during install
|
||||
- Encouraged parallel tool calls for faster task execution
|
||||
- Capped tool output for bash commands and file reads to keep large output within context limits
|
||||
- Allowed ranged reads on large files
|
||||
- Fixed apply_patch to fail when a hunk is skipped
|
||||
- Fixed run_commands to return captured stdout on failure and handle split heredocs
|
||||
- Fixed search tools to treat zero results as success
|
||||
- Fixed disabled-reasoning handling for StepFun flash
|
||||
- Fixed history resume rendering isolation
|
||||
- Fixed the Hugging Face URL
|
||||
- Fixed Cline OAuth token formatting in provider config
|
||||
|
||||
## 3.0.24
|
||||
|
||||
- Plugin commands can now submit prompts to the agent
|
||||
|
||||
@@ -85,6 +85,20 @@ const result = await Bun.build({
|
||||
],
|
||||
define: {
|
||||
"process.env.NODE_ENV": '"production"',
|
||||
...(process.env.TELEMETRY_SERVICE_API_KEY
|
||||
? {
|
||||
"process.env.TELEMETRY_SERVICE_API_KEY": defineProcessEnv(
|
||||
"TELEMETRY_SERVICE_API_KEY",
|
||||
),
|
||||
}
|
||||
: {}),
|
||||
...(process.env.ERROR_SERVICE_API_KEY
|
||||
? {
|
||||
"process.env.ERROR_SERVICE_API_KEY": defineProcessEnv(
|
||||
"ERROR_SERVICE_API_KEY",
|
||||
),
|
||||
}
|
||||
: {}),
|
||||
"process.env.OTEL_TELEMETRY_ENABLED": defineProcessEnv(
|
||||
"OTEL_TELEMETRY_ENABLED",
|
||||
),
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@cline/cli",
|
||||
"displayName": "cline",
|
||||
"version": "3.0.24",
|
||||
"version": "3.0.26",
|
||||
"description": "Autonomous coding agent CLI - capable of creating/editing files, running commands, using the browser, and more",
|
||||
"type": "module",
|
||||
"publishConfig": {
|
||||
@@ -87,6 +87,7 @@
|
||||
"open": "^10.2.0",
|
||||
"opentui-spinner": "^0.0.6",
|
||||
"pino": "^10.3.1",
|
||||
"posthog-node": "^5.8.0",
|
||||
"react": "19.2.4",
|
||||
"react-devtools-core": "^7.0.1",
|
||||
"react-reconciler": "0.32.0",
|
||||
|
||||
@@ -18,6 +18,8 @@ interface KeyStep {
|
||||
const INITIAL_RENDER_DELAY_SECONDS = 2.5;
|
||||
const POST_ACTION_SETTLE_SECONDS = 1.0;
|
||||
const INTERACTIVE_TEST_TIMEOUT_MS = 40_000;
|
||||
const HISTORY_PICKER_READY_DELAY_SECONDS = 8.0;
|
||||
const HISTORY_RESUME_READY_DELAY_SECONDS = 15.0;
|
||||
|
||||
function normalizeTerminalOutput(output: string): string {
|
||||
// biome-ignore lint/suspicious/noControlCharactersInRegex: this regex intentionally strips ANSI escape sequences
|
||||
@@ -51,16 +53,40 @@ function buildScriptCommand(scriptedInput: string, launchArgs: string): string {
|
||||
return `(${scriptedInput}) | script ${quietFlag} /dev/null ${toShellSingleQuotedLiteral(bunExec)} ${launchArgs}`;
|
||||
}
|
||||
|
||||
function runInteractiveCli(
|
||||
steps: KeyStep[],
|
||||
options?: { launchConfigView?: boolean },
|
||||
): CliResult {
|
||||
function createCliEnv(): NodeJS.ProcessEnv {
|
||||
const homeDir = mkdtempSync(path.join(os.tmpdir(), "cli-int-home-"));
|
||||
const dataDir = mkdtempSync(path.join(os.tmpdir(), "cli-int-data-"));
|
||||
const sessionDir = mkdtempSync(path.join(os.tmpdir(), "cli-int-sessions-"));
|
||||
const teamDir = mkdtempSync(path.join(os.tmpdir(), "cli-int-teams-"));
|
||||
tempDirs.push(homeDir, dataDir, sessionDir, teamDir);
|
||||
|
||||
return {
|
||||
...process.env,
|
||||
HOME: homeDir,
|
||||
CLINE_DATA_DIR: dataDir,
|
||||
CLINE_DB_DATA_DIR: path.join(dataDir, "db"),
|
||||
CLINE_SESSION_DATA_DIR: sessionDir,
|
||||
CLINE_TEAM_DATA_DIR: teamDir,
|
||||
CLINE_SESSION_BACKEND_MODE: "local",
|
||||
CLINE_PROVIDER_SETTINGS_PATH: path.join(
|
||||
dataDir,
|
||||
"settings",
|
||||
"providers.json",
|
||||
),
|
||||
CLINE_HOOKS_LOG_PATH: path.join(dataDir, "logs", "hooks.jsonl"),
|
||||
};
|
||||
}
|
||||
|
||||
function runInteractiveCli(
|
||||
steps: KeyStep[],
|
||||
options?: {
|
||||
launchConfigView?: boolean;
|
||||
launchArgs?: string[];
|
||||
env?: NodeJS.ProcessEnv;
|
||||
},
|
||||
): CliResult {
|
||||
const env = options?.env ?? createCliEnv();
|
||||
|
||||
const scriptedInput = [
|
||||
...steps,
|
||||
// Exit each interactive run explicitly so tests do not idle until timeout.
|
||||
@@ -80,9 +106,13 @@ function runInteractiveCli(
|
||||
"-k",
|
||||
"test-key",
|
||||
];
|
||||
const launchArgs = [
|
||||
...(options?.launchConfigView ? [...baseArgs, "config"] : baseArgs),
|
||||
]
|
||||
const launchArgs = (
|
||||
options?.launchArgs
|
||||
? [cliEntry, ...options.launchArgs]
|
||||
: options?.launchConfigView
|
||||
? [...baseArgs, "config"]
|
||||
: baseArgs
|
||||
)
|
||||
.map((arg) => toShellSingleQuotedLiteral(arg))
|
||||
.join(" ");
|
||||
const command = buildScriptCommand(scriptedInput, launchArgs);
|
||||
@@ -90,21 +120,7 @@ function runInteractiveCli(
|
||||
return spawnSync("bash", ["-lc", command], {
|
||||
cwd: cliRoot,
|
||||
encoding: "utf8",
|
||||
env: {
|
||||
...process.env,
|
||||
HOME: homeDir,
|
||||
CLINE_DATA_DIR: dataDir,
|
||||
CLINE_DB_DATA_DIR: path.join(dataDir, "db"),
|
||||
CLINE_SESSION_DATA_DIR: sessionDir,
|
||||
CLINE_TEAM_DATA_DIR: teamDir,
|
||||
CLINE_SESSION_BACKEND_MODE: "local",
|
||||
CLINE_PROVIDER_SETTINGS_PATH: path.join(
|
||||
dataDir,
|
||||
"settings",
|
||||
"providers.json",
|
||||
),
|
||||
CLINE_HOOKS_LOG_PATH: path.join(dataDir, "logs", "hooks.jsonl"),
|
||||
},
|
||||
env,
|
||||
timeout: INTERACTIVE_TEST_TIMEOUT_MS,
|
||||
maxBuffer: 10 * 1024 * 1024,
|
||||
});
|
||||
@@ -188,6 +204,62 @@ describe("cli interactive e2e", () => {
|
||||
expect(output).toContain("/ for commands · @ for files");
|
||||
});
|
||||
|
||||
it("resumes a history-picked session and survives Ctrl+C without a native crash", {
|
||||
timeout: 120_000,
|
||||
}, () => {
|
||||
const env = createCliEnv();
|
||||
// Seed one session; the invalid key makes the run fail fast while
|
||||
// still persisting a resumable session record.
|
||||
const seed = spawnSync(
|
||||
bunExec,
|
||||
[
|
||||
cliEntry,
|
||||
"--provider",
|
||||
"anthropic",
|
||||
"-m",
|
||||
"claude-sonnet-4-6",
|
||||
"-k",
|
||||
"test-key",
|
||||
"hello",
|
||||
],
|
||||
{ cwd: cliRoot, encoding: "utf8", env, timeout: 60_000 },
|
||||
);
|
||||
expect(seed.error).toBeUndefined();
|
||||
const history = spawnSync(bunExec, [cliEntry, "history", "--json"], {
|
||||
cwd: cliRoot,
|
||||
encoding: "utf8",
|
||||
env,
|
||||
timeout: 60_000,
|
||||
});
|
||||
expect(history.error).toBeUndefined();
|
||||
expect(history.status).toBe(0);
|
||||
const historyRows = JSON.parse(history.stdout) as unknown[];
|
||||
expect(historyRows.length).toBeGreaterThan(0);
|
||||
|
||||
// history picker -> Enter resumes the seeded session in the
|
||||
// interactive TUI -> double Ctrl+C exits it. Regression guard for
|
||||
// the Bun "panic(main thread): Segmentation fault" that occurred
|
||||
// when the resumed TUI shared the picker's process (a second
|
||||
// OpenTUI renderer in one process crashes natively on teardown).
|
||||
const result = runInteractiveCli(
|
||||
[
|
||||
// Select the seeded session in the picker.
|
||||
{ delaySeconds: HISTORY_PICKER_READY_DELAY_SECONDS, input: "\r" },
|
||||
// Give the resumed TUI time to start, then double-press
|
||||
// Ctrl+C; the harness appends the final press 0.2s later.
|
||||
{ delaySeconds: HISTORY_RESUME_READY_DELAY_SECONDS, input: "\u0003" },
|
||||
],
|
||||
{ launchArgs: ["history"], env },
|
||||
);
|
||||
const output = outputOf(result);
|
||||
// The exit summary only prints after the resumed interactive TUI ran
|
||||
// and shut down cleanly; the history picker alone never prints it.
|
||||
expect(output).toContain("Session Summary");
|
||||
expect(output).not.toContain("panic(");
|
||||
expect(output).not.toContain("Segmentation fault");
|
||||
expect(result.status).toBe(0);
|
||||
});
|
||||
|
||||
it("launches config view directly with `cline config`", () => {
|
||||
const result = runInteractiveCli(
|
||||
[{ delaySeconds: INITIAL_RENDER_DELAY_SECONDS, input: "" }],
|
||||
|
||||
@@ -4,7 +4,6 @@ import {
|
||||
createOAuthClientCallbacks,
|
||||
ensureCustomProvidersLoaded,
|
||||
getProviderAuthHandler,
|
||||
listLocalProviders,
|
||||
loginAndSaveProviderOAuthCredentials,
|
||||
type ProviderSettings,
|
||||
type ProviderSettingsManager,
|
||||
@@ -22,6 +21,8 @@ import {
|
||||
type OAuthCredentials,
|
||||
toProviderApiKey,
|
||||
} from "../utils/provider-auth";
|
||||
import { listLocalProviders } from "../utils/provider-catalog";
|
||||
import { identifyTelemetryAccount } from "../utils/telemetry";
|
||||
|
||||
export {
|
||||
getPersistedProviderApiKey,
|
||||
@@ -434,11 +435,15 @@ export async function runAuthProviderCommand(
|
||||
return 1;
|
||||
}
|
||||
try {
|
||||
await loginAndSaveProviderOAuthCredentials(
|
||||
const settings = await loginAndSaveProviderOAuthCredentials(
|
||||
providerSettingsManager,
|
||||
providerId,
|
||||
{ callbacks: createOAuthCallbacks(io) },
|
||||
);
|
||||
identifyTelemetryAccount({
|
||||
id: settings.auth?.accountId,
|
||||
provider: providerId,
|
||||
});
|
||||
io.writeln(
|
||||
`${c.green}You are now logged in to ${c.cyan}${providerId}${c.reset}`,
|
||||
);
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
} from "@cline/shared/storage";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
collectPluginMcpOAuthCandidates,
|
||||
installPlugin,
|
||||
isOfficialPluginSlug,
|
||||
parsePluginSource,
|
||||
@@ -35,6 +36,7 @@ describe("plugin install command", () => {
|
||||
let originalHome: string | undefined;
|
||||
let originalClineDir: string | undefined;
|
||||
let originalClineDataDir: string | undefined;
|
||||
let originalMcpSettingsPath: string | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
root = mkdtempSync(join(tmpdir(), "cli-plugin-install-"));
|
||||
@@ -43,6 +45,7 @@ describe("plugin install command", () => {
|
||||
originalHome = process.env.HOME;
|
||||
originalClineDir = process.env.CLINE_DIR;
|
||||
originalClineDataDir = process.env.CLINE_DATA_DIR;
|
||||
originalMcpSettingsPath = process.env.CLINE_MCP_SETTINGS_PATH;
|
||||
process.env.HOME = home;
|
||||
process.env.CLINE_DIR = join(home, ".cline");
|
||||
process.env.CLINE_DATA_DIR = join(home, ".cline", "data");
|
||||
@@ -91,6 +94,11 @@ describe("plugin install command", () => {
|
||||
} else {
|
||||
process.env.CLINE_DATA_DIR = originalClineDataDir;
|
||||
}
|
||||
if (originalMcpSettingsPath === undefined) {
|
||||
delete process.env.CLINE_MCP_SETTINGS_PATH;
|
||||
} else {
|
||||
process.env.CLINE_MCP_SETTINGS_PATH = originalMcpSettingsPath;
|
||||
}
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
@@ -676,11 +684,341 @@ describe("plugin install command", () => {
|
||||
expect(code).toBe(0);
|
||||
const parsed = JSON.parse(stdout.join("")) as { installPath: string };
|
||||
expect(parsed.installPath).toContain(join(home, ".cline", "plugins"));
|
||||
expect("mcpOAuthCandidates" in parsed).toBe(false);
|
||||
} finally {
|
||||
process.stdout.write = originalWrite;
|
||||
}
|
||||
});
|
||||
|
||||
it("does not run MCP OAuth follow-up for JSON plugin installs", async () => {
|
||||
process.env.CLINE_MCP_SETTINGS_PATH = join(root, "mcp-settings.json");
|
||||
const source = join(root, "json-oauth-mcp-plugin.js");
|
||||
writeFileSync(
|
||||
source,
|
||||
`
|
||||
export default {
|
||||
name: "json-oauth-mcp-plugin",
|
||||
manifest: { capabilities: ["mcp"] },
|
||||
setup(api) {
|
||||
api.registerMcpServer({
|
||||
name: "json-oauth-docs",
|
||||
transport: { type: "streamableHttp", url: "https://example.com/mcp" },
|
||||
})
|
||||
},
|
||||
}
|
||||
`,
|
||||
"utf8",
|
||||
);
|
||||
const stdout: string[] = [];
|
||||
const originalWrite = process.stdout.write;
|
||||
const authorize = vi.fn();
|
||||
process.stdout.write = ((chunk: string | Uint8Array) => {
|
||||
stdout.push(String(chunk));
|
||||
return true;
|
||||
}) as typeof process.stdout.write;
|
||||
try {
|
||||
const code = await runPluginInstallCommand({
|
||||
source,
|
||||
json: true,
|
||||
io: {
|
||||
writeln: () => {},
|
||||
writeErr: () => {},
|
||||
},
|
||||
mcpOAuth: {
|
||||
interactive: true,
|
||||
selectCandidates: async (candidates) => candidates,
|
||||
authorize,
|
||||
},
|
||||
});
|
||||
expect(code).toBe(0);
|
||||
expect(authorize).not.toHaveBeenCalled();
|
||||
const parsed = JSON.parse(stdout.join("")) as {
|
||||
installPath: string;
|
||||
mcpOAuthCandidates?: unknown;
|
||||
};
|
||||
expect(parsed.installPath).toContain(join(home, ".cline", "plugins"));
|
||||
expect(parsed.mcpOAuthCandidates).toBeUndefined();
|
||||
} finally {
|
||||
process.stdout.write = originalWrite;
|
||||
}
|
||||
});
|
||||
|
||||
it("warns when plugin MCP settings sync fails after install", async () => {
|
||||
const source = join(root, "mcp-plugin.js");
|
||||
writeFileSync(
|
||||
source,
|
||||
`
|
||||
export default {
|
||||
name: "mcp-plugin",
|
||||
manifest: { capabilities: ["mcp"] },
|
||||
setup(api) {
|
||||
api.registerMcpServer({
|
||||
name: "mcp-plugin",
|
||||
transport: { type: "streamableHttp", url: "https://example.com/mcp" },
|
||||
})
|
||||
},
|
||||
}
|
||||
`,
|
||||
"utf8",
|
||||
);
|
||||
const blockedDirectory = join(root, "not-a-directory");
|
||||
writeFileSync(blockedDirectory, "file", "utf8");
|
||||
const originalSettingsPath = process.env.CLINE_MCP_SETTINGS_PATH;
|
||||
process.env.CLINE_MCP_SETTINGS_PATH = join(
|
||||
blockedDirectory,
|
||||
"cline_mcp_settings.json",
|
||||
);
|
||||
const output: string[] = [];
|
||||
try {
|
||||
const code = await runPluginInstallCommand({
|
||||
source,
|
||||
io: {
|
||||
writeln: (text = "") => output.push(text),
|
||||
writeErr: (text) => output.push(text),
|
||||
},
|
||||
});
|
||||
|
||||
expect(code).toBe(0);
|
||||
expect(output.join("\n")).toContain("Installed plugin from");
|
||||
expect(output.join("\n")).toContain(
|
||||
"Warning: failed to sync plugin MCP servers",
|
||||
);
|
||||
expect(output.join("\n")).toContain("mcp-plugin");
|
||||
} finally {
|
||||
if (originalSettingsPath === undefined) {
|
||||
delete process.env.CLINE_MCP_SETTINGS_PATH;
|
||||
} else {
|
||||
process.env.CLINE_MCP_SETTINGS_PATH = originalSettingsPath;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("detects plugin-owned remote MCP servers as OAuth candidates", async () => {
|
||||
process.env.CLINE_MCP_SETTINGS_PATH = join(root, "mcp-settings.json");
|
||||
const source = join(root, "oauth-mcp-plugin.js");
|
||||
writeFileSync(
|
||||
source,
|
||||
`
|
||||
export default {
|
||||
name: "oauth-mcp-plugin",
|
||||
manifest: { capabilities: ["mcp"] },
|
||||
setup(api) {
|
||||
api.registerMcpServer({
|
||||
name: "oauth-docs",
|
||||
transport: { type: "streamableHttp", url: "https://example.com/mcp" },
|
||||
})
|
||||
},
|
||||
}
|
||||
`,
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const result = await installPlugin({ source });
|
||||
|
||||
expect(result.mcpOAuthCandidates).toEqual([
|
||||
expect.objectContaining({
|
||||
name: "oauth-docs",
|
||||
pluginName: "oauth-mcp-plugin",
|
||||
transportType: "streamableHttp",
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not treat remote MCP servers with static headers as OAuth candidates", async () => {
|
||||
process.env.CLINE_MCP_SETTINGS_PATH = join(root, "mcp-settings.json");
|
||||
const source = join(root, "headers-mcp-plugin.js");
|
||||
writeFileSync(
|
||||
source,
|
||||
`
|
||||
export default {
|
||||
name: "headers-mcp-plugin",
|
||||
manifest: { capabilities: ["mcp"] },
|
||||
setup(api) {
|
||||
api.registerMcpServer({
|
||||
name: "headers-docs",
|
||||
transport: {
|
||||
type: "streamableHttp",
|
||||
url: "https://example.com/mcp",
|
||||
headers: { Authorization: "Bearer token" },
|
||||
},
|
||||
})
|
||||
},
|
||||
}
|
||||
`,
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const result = await installPlugin({ source });
|
||||
|
||||
expect(result.mcpOAuthCandidates).toEqual([]);
|
||||
});
|
||||
|
||||
it("skips plugin MCP OAuth candidates that already have tokens", async () => {
|
||||
const settingsPath = join(root, "mcp-settings.json");
|
||||
process.env.CLINE_MCP_SETTINGS_PATH = settingsPath;
|
||||
const source = join(root, "authorized-mcp-plugin.js");
|
||||
writeFileSync(
|
||||
source,
|
||||
`
|
||||
export default {
|
||||
name: "authorized-mcp-plugin",
|
||||
manifest: { capabilities: ["mcp"] },
|
||||
setup(api) {
|
||||
api.registerMcpServer({
|
||||
name: "authorized-docs",
|
||||
transport: { type: "streamableHttp", url: "https://example.com/mcp" },
|
||||
})
|
||||
},
|
||||
}
|
||||
`,
|
||||
"utf8",
|
||||
);
|
||||
const result = await installPlugin({ source });
|
||||
const settings = JSON.parse(readFileSync(settingsPath, "utf8")) as {
|
||||
mcpServers?: Record<string, { oauth?: unknown }>;
|
||||
};
|
||||
const server = settings.mcpServers?.["authorized-docs"];
|
||||
if (!server) {
|
||||
throw new Error("Expected authorized-docs MCP server to be written");
|
||||
}
|
||||
server.oauth = { tokens: { access_token: "oauth-token" } };
|
||||
writeFileSync(settingsPath, JSON.stringify(settings, null, 2), "utf8");
|
||||
|
||||
expect(
|
||||
collectPluginMcpOAuthCandidates({
|
||||
pluginPaths: result.entryPaths,
|
||||
settingsPath,
|
||||
}),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("authorizes selected plugin MCP OAuth candidates during interactive installs", async () => {
|
||||
process.env.CLINE_MCP_SETTINGS_PATH = join(root, "mcp-settings.json");
|
||||
const source = join(root, "interactive-mcp-plugin.js");
|
||||
writeFileSync(
|
||||
source,
|
||||
`
|
||||
export default {
|
||||
name: "interactive-mcp-plugin",
|
||||
manifest: { capabilities: ["mcp"] },
|
||||
setup(api) {
|
||||
api.registerMcpServer({
|
||||
name: "interactive-docs",
|
||||
transport: { type: "streamableHttp", url: "https://example.com/mcp" },
|
||||
})
|
||||
},
|
||||
}
|
||||
`,
|
||||
"utf8",
|
||||
);
|
||||
const authorized: string[] = [];
|
||||
const output: string[] = [];
|
||||
|
||||
const code = await runPluginInstallCommand({
|
||||
source,
|
||||
io: {
|
||||
writeln: (text = "") => output.push(text),
|
||||
writeErr: (text) => output.push(text),
|
||||
},
|
||||
mcpOAuth: {
|
||||
interactive: true,
|
||||
selectCandidates: async (candidates) => candidates,
|
||||
authorize: async (candidate) => {
|
||||
authorized.push(candidate.name);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(code).toBe(0);
|
||||
expect(authorized).toEqual(["interactive-docs"]);
|
||||
expect(output.join("\n")).toContain("Installed plugin from");
|
||||
});
|
||||
|
||||
it("keeps plugin install successful when MCP OAuth authorization fails", async () => {
|
||||
process.env.CLINE_MCP_SETTINGS_PATH = join(root, "mcp-settings.json");
|
||||
const source = join(root, "failing-oauth-mcp-plugin.js");
|
||||
writeFileSync(
|
||||
source,
|
||||
`
|
||||
export default {
|
||||
name: "failing-oauth-mcp-plugin",
|
||||
manifest: { capabilities: ["mcp"] },
|
||||
setup(api) {
|
||||
api.registerMcpServer({
|
||||
name: "failing-docs",
|
||||
transport: { type: "streamableHttp", url: "https://example.com/mcp" },
|
||||
})
|
||||
},
|
||||
}
|
||||
`,
|
||||
"utf8",
|
||||
);
|
||||
const output: string[] = [];
|
||||
|
||||
const code = await runPluginInstallCommand({
|
||||
source,
|
||||
io: {
|
||||
writeln: (text = "") => output.push(text),
|
||||
writeErr: (text) => output.push(text),
|
||||
},
|
||||
mcpOAuth: {
|
||||
interactive: true,
|
||||
selectCandidates: async (candidates) => candidates,
|
||||
authorize: async () => {
|
||||
throw new Error("oauth unavailable");
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(code).toBe(0);
|
||||
expect(output.join("\n")).toContain(
|
||||
"Warning: failed to authorize MCP server failing-docs: oauth unavailable",
|
||||
);
|
||||
});
|
||||
|
||||
it("prints guidance for plugin MCP OAuth candidates in non-interactive installs", async () => {
|
||||
process.env.CLINE_MCP_SETTINGS_PATH = join(root, "mcp-settings.json");
|
||||
const source = join(root, "non-interactive-mcp-plugin.js");
|
||||
writeFileSync(
|
||||
source,
|
||||
`
|
||||
export default {
|
||||
name: "non-interactive-mcp-plugin",
|
||||
manifest: { capabilities: ["mcp"] },
|
||||
setup(api) {
|
||||
api.registerMcpServer({
|
||||
name: "non-interactive-docs",
|
||||
transport: { type: "streamableHttp", url: "https://example.com/mcp" },
|
||||
})
|
||||
},
|
||||
}
|
||||
`,
|
||||
"utf8",
|
||||
);
|
||||
const output: string[] = [];
|
||||
const authorize = vi.fn();
|
||||
|
||||
const code = await runPluginInstallCommand({
|
||||
source,
|
||||
io: {
|
||||
writeln: (text = "") => output.push(text),
|
||||
writeErr: (text) => output.push(text),
|
||||
},
|
||||
mcpOAuth: {
|
||||
interactive: false,
|
||||
authorize,
|
||||
},
|
||||
});
|
||||
|
||||
expect(code).toBe(0);
|
||||
expect(authorize).not.toHaveBeenCalled();
|
||||
expect(output.join("\n")).toContain(
|
||||
"Plugin MCP servers may require OAuth authorization",
|
||||
);
|
||||
expect(output.join("\n")).toContain("non-interactive-docs");
|
||||
expect(output.join("\n")).toContain('Run "cline mcp"');
|
||||
});
|
||||
|
||||
it("prints JSON output for official plugin installs", async () => {
|
||||
const officialPluginsRepo = await createOfficialPluginsRepo({
|
||||
"json-plugin": {
|
||||
|
||||
@@ -21,7 +21,15 @@ import {
|
||||
resolve,
|
||||
sep,
|
||||
} from "node:path";
|
||||
import { type PluginUninstallOptions, uninstallPlugin } from "@cline/core";
|
||||
import {
|
||||
type McpServerRegistration,
|
||||
type PluginMcpSettingsSyncResult,
|
||||
type PluginUninstallOptions,
|
||||
resolveDefaultMcpSettingsPath,
|
||||
resolveMcpServerRegistrations,
|
||||
syncPluginMcpServersToSettings,
|
||||
uninstallPlugin,
|
||||
} from "@cline/core";
|
||||
import {
|
||||
isPluginModulePath,
|
||||
resolveClineDir,
|
||||
@@ -36,12 +44,31 @@ export interface PluginInstallOptions {
|
||||
npmCommand?: string;
|
||||
officialPluginsRepo?: string;
|
||||
io?: PluginInstallIo;
|
||||
mcpOAuth?: PluginInstallMcpOAuthOptions;
|
||||
}
|
||||
|
||||
export interface PluginInstallResult {
|
||||
source: string;
|
||||
installPath: string;
|
||||
entryPaths: string[];
|
||||
mcpSyncFailures: PluginMcpSettingsSyncResult["failures"];
|
||||
mcpOAuthCandidates: PluginMcpOAuthCandidate[];
|
||||
}
|
||||
|
||||
export interface PluginMcpOAuthCandidate {
|
||||
name: string;
|
||||
pluginName: string;
|
||||
pluginPath: string;
|
||||
transportType: "sse" | "streamableHttp";
|
||||
lastError?: string;
|
||||
}
|
||||
|
||||
export interface PluginInstallMcpOAuthOptions {
|
||||
interactive?: boolean;
|
||||
selectCandidates?: (
|
||||
candidates: PluginMcpOAuthCandidate[],
|
||||
) => Promise<PluginMcpOAuthCandidate[]>;
|
||||
authorize?: (candidate: PluginMcpOAuthCandidate) => Promise<void>;
|
||||
}
|
||||
|
||||
export interface PluginInstallIo {
|
||||
@@ -1005,6 +1032,81 @@ function replaceInstallPath(
|
||||
}
|
||||
}
|
||||
|
||||
function hasStaticHeaders(registration: McpServerRegistration): boolean {
|
||||
const transport = registration.transport;
|
||||
if (transport.type === "stdio") {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
transport.headers !== undefined && Object.keys(transport.headers).length > 0
|
||||
);
|
||||
}
|
||||
|
||||
function hasOAuthAccessToken(registration: McpServerRegistration): boolean {
|
||||
const accessToken = registration.oauth?.tokens?.access_token;
|
||||
return typeof accessToken === "string" && accessToken.trim().length > 0;
|
||||
}
|
||||
|
||||
function getPluginOwner(
|
||||
registration: McpServerRegistration,
|
||||
): { pluginName: string; pluginPath: string } | undefined {
|
||||
const metadata = registration.metadata;
|
||||
if (
|
||||
!metadata ||
|
||||
metadata.source !== "plugin" ||
|
||||
typeof metadata.pluginName !== "string" ||
|
||||
typeof metadata.pluginPath !== "string"
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
pluginName: metadata.pluginName,
|
||||
pluginPath: metadata.pluginPath,
|
||||
};
|
||||
}
|
||||
|
||||
export function collectPluginMcpOAuthCandidates(input: {
|
||||
pluginPaths: readonly string[];
|
||||
settingsPath?: string;
|
||||
}): PluginMcpOAuthCandidate[] {
|
||||
const pluginPaths = new Set(input.pluginPaths.map((path) => resolve(path)));
|
||||
if (pluginPaths.size === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
let registrations: McpServerRegistration[];
|
||||
try {
|
||||
registrations = resolveMcpServerRegistrations({
|
||||
filePath: input.settingsPath ?? resolveDefaultMcpSettingsPath(),
|
||||
});
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
|
||||
const candidates: PluginMcpOAuthCandidate[] = [];
|
||||
for (const registration of registrations) {
|
||||
const owner = getPluginOwner(registration);
|
||||
if (!owner || !pluginPaths.has(resolve(owner.pluginPath))) {
|
||||
continue;
|
||||
}
|
||||
const transportType = registration.transport.type;
|
||||
if (transportType === "stdio") {
|
||||
continue;
|
||||
}
|
||||
if (hasStaticHeaders(registration) || hasOAuthAccessToken(registration)) {
|
||||
continue;
|
||||
}
|
||||
candidates.push({
|
||||
name: registration.name,
|
||||
pluginName: owner.pluginName,
|
||||
pluginPath: owner.pluginPath,
|
||||
transportType,
|
||||
lastError: registration.oauth?.lastError,
|
||||
});
|
||||
}
|
||||
return candidates.sort((left, right) => left.name.localeCompare(right.name));
|
||||
}
|
||||
|
||||
export async function installPlugin(
|
||||
options: PluginInstallOptions,
|
||||
): Promise<PluginInstallResult> {
|
||||
@@ -1071,28 +1173,161 @@ export async function installPlugin(
|
||||
}
|
||||
|
||||
replaceInstallPath(stagingRoot, installPath, force);
|
||||
return {
|
||||
const result = {
|
||||
source,
|
||||
installPath,
|
||||
entryPaths: entryPaths.map((entry) => resolve(installPath, entry)),
|
||||
mcpSyncFailures: [] as PluginMcpSettingsSyncResult["failures"],
|
||||
mcpOAuthCandidates: [] as PluginMcpOAuthCandidate[],
|
||||
};
|
||||
const syncResult = await syncPluginMcpServersToSettings({
|
||||
pluginPaths: result.entryPaths,
|
||||
cwd,
|
||||
workspacePath: cwd,
|
||||
});
|
||||
result.mcpSyncFailures = syncResult.failures;
|
||||
result.mcpOAuthCandidates = collectPluginMcpOAuthCandidates({
|
||||
pluginPaths: result.entryPaths,
|
||||
});
|
||||
return result;
|
||||
} catch (error) {
|
||||
rmSync(stagingRoot, { recursive: true, force: true });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function serializePluginInstallResult(
|
||||
result: PluginInstallResult,
|
||||
): Omit<PluginInstallResult, "mcpOAuthCandidates"> {
|
||||
return {
|
||||
source: result.source,
|
||||
installPath: result.installPath,
|
||||
entryPaths: result.entryPaths,
|
||||
mcpSyncFailures: result.mcpSyncFailures,
|
||||
};
|
||||
}
|
||||
|
||||
function isInteractivePluginInstall(
|
||||
options: PluginInstallOptions & { json?: boolean },
|
||||
): boolean {
|
||||
return (
|
||||
options.mcpOAuth?.interactive ??
|
||||
(options.json !== true && process.stdin.isTTY && process.stdout.isTTY)
|
||||
);
|
||||
}
|
||||
|
||||
async function selectMcpOAuthCandidatesWithClack(
|
||||
candidates: PluginMcpOAuthCandidate[],
|
||||
): Promise<PluginMcpOAuthCandidate[]> {
|
||||
const p = await import("@clack/prompts");
|
||||
const action = await p.select({
|
||||
message: "Authorize plugin MCP servers now?",
|
||||
options: [
|
||||
{
|
||||
value: "all",
|
||||
label: "Authorize all",
|
||||
hint: "open browser authorization for each server",
|
||||
},
|
||||
{
|
||||
value: "choose",
|
||||
label: "Choose servers",
|
||||
hint: "select which servers to authorize",
|
||||
},
|
||||
{
|
||||
value: "skip",
|
||||
label: "Skip",
|
||||
},
|
||||
],
|
||||
});
|
||||
if (p.isCancel(action) || action === "skip") {
|
||||
return [];
|
||||
}
|
||||
if (action === "all") {
|
||||
return candidates;
|
||||
}
|
||||
|
||||
const selectedNames = await p.multiselect({
|
||||
message: "Select MCP servers to authorize",
|
||||
options: candidates.map((candidate) => ({
|
||||
value: candidate.name,
|
||||
label: candidate.name,
|
||||
hint: `${candidate.transportType} [${candidate.pluginName}]`,
|
||||
})),
|
||||
required: false,
|
||||
});
|
||||
if (p.isCancel(selectedNames) || !Array.isArray(selectedNames)) {
|
||||
return [];
|
||||
}
|
||||
const selected = new Set(selectedNames);
|
||||
return candidates.filter((candidate) => selected.has(candidate.name));
|
||||
}
|
||||
|
||||
async function authorizeMcpOAuthCandidate(
|
||||
candidate: PluginMcpOAuthCandidate,
|
||||
): Promise<void> {
|
||||
const { authorizeMcpServerOAuthWithBrowser } = await import(
|
||||
"../wizards/mcp/oauth"
|
||||
);
|
||||
await authorizeMcpServerOAuthWithBrowser(candidate.name);
|
||||
}
|
||||
|
||||
async function runPluginMcpOAuthFollowup(
|
||||
candidates: PluginMcpOAuthCandidate[],
|
||||
options: PluginInstallOptions & { json?: boolean },
|
||||
): Promise<void> {
|
||||
if (candidates.length === 0 || options.json === true) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isInteractivePluginInstall(options)) {
|
||||
options.io?.writeln("Plugin MCP servers may require OAuth authorization:");
|
||||
for (const candidate of candidates) {
|
||||
options.io?.writeln(
|
||||
` ${candidate.name} (${candidate.transportType}, plugin: ${candidate.pluginName})`,
|
||||
);
|
||||
}
|
||||
options.io?.writeln(
|
||||
'Run "cline mcp" and choose "Authorize OAuth" to authorize them.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const selected =
|
||||
options.mcpOAuth?.selectCandidates !== undefined
|
||||
? await options.mcpOAuth.selectCandidates(candidates)
|
||||
: await selectMcpOAuthCandidatesWithClack(candidates);
|
||||
const authorize = options.mcpOAuth?.authorize ?? authorizeMcpOAuthCandidate;
|
||||
for (const candidate of selected) {
|
||||
try {
|
||||
await authorize(candidate);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
options.io?.writeErr(
|
||||
`Warning: failed to authorize MCP server ${candidate.name}: ${message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function runPluginInstallCommand(
|
||||
options: PluginInstallOptions & { json?: boolean },
|
||||
): Promise<number> {
|
||||
try {
|
||||
const result = await installPlugin(options);
|
||||
if (options.json) {
|
||||
process.stdout.write(JSON.stringify(result));
|
||||
process.stdout.write(
|
||||
JSON.stringify(serializePluginInstallResult(result)),
|
||||
);
|
||||
return 0;
|
||||
}
|
||||
options.io?.writeln(`Installed plugin from ${result.source}`);
|
||||
options.io?.writeln(` Path: ${result.installPath}`);
|
||||
for (const failure of result.mcpSyncFailures) {
|
||||
options.io?.writeErr(
|
||||
`Warning: failed to sync plugin MCP servers for ${failure.pluginName ?? failure.pluginPath}: ${failure.message}`,
|
||||
);
|
||||
}
|
||||
await runPluginMcpOAuthFollowup(result.mcpOAuthCandidates, options);
|
||||
return 0;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const {
|
||||
mockGetLastUsedProviderSettings,
|
||||
mockGetProviderSettings,
|
||||
mockResolveSystemPrompt,
|
||||
mockGetProviderCollection,
|
||||
mockGetBooleanFlagEnabled,
|
||||
} = vi.hoisted(() => ({
|
||||
mockGetLastUsedProviderSettings: vi.fn(),
|
||||
mockGetProviderSettings: vi.fn(),
|
||||
mockResolveSystemPrompt: vi.fn(),
|
||||
mockGetProviderCollection: vi.fn(),
|
||||
mockGetBooleanFlagEnabled: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@cline/core", async () => {
|
||||
@@ -18,8 +20,8 @@ vi.mock("@cline/core", async () => {
|
||||
return {
|
||||
...actual,
|
||||
ProviderSettingsManager: class {
|
||||
getLastUsedProviderSettings() {
|
||||
return mockGetLastUsedProviderSettings();
|
||||
getLastUsedProviderSettings(options?: unknown) {
|
||||
return mockGetLastUsedProviderSettings(options);
|
||||
}
|
||||
|
||||
getProviderSettings(providerId: string) {
|
||||
@@ -43,6 +45,12 @@ vi.mock("../utils/helpers", () => ({
|
||||
resolveWorkspaceRoot: vi.fn((cwd: string) => cwd),
|
||||
}));
|
||||
|
||||
vi.mock("../utils/feature-flags", () => ({
|
||||
getCliFeatureFlagsService: () => ({
|
||||
getBooleanFlagEnabled: mockGetBooleanFlagEnabled,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../commands/auth", async () => {
|
||||
const actual =
|
||||
await vi.importActual<typeof import("../commands/auth")>(
|
||||
@@ -57,6 +65,10 @@ vi.mock("../commands/auth", async () => {
|
||||
import { buildConnectorStartRequest } from "./session-runtime";
|
||||
|
||||
describe("buildConnectorStartRequest", () => {
|
||||
beforeEach(() => {
|
||||
mockGetBooleanFlagEnabled.mockReturnValue(false);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
delete process.env.OPENROUTER_API_KEY;
|
||||
@@ -88,5 +100,64 @@ describe("buildConnectorStartRequest", () => {
|
||||
expect(request.provider).toBe("openrouter");
|
||||
expect(request.apiKey).toBe("env-openrouter-key");
|
||||
expect(request.model).toBe("anthropic/claude-sonnet-4.6");
|
||||
expect(mockGetLastUsedProviderSettings).toHaveBeenCalledWith({
|
||||
isClinePassEnabled: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("uses auth material resolved by provider settings manager", async () => {
|
||||
mockGetLastUsedProviderSettings.mockReturnValue({ provider: "cline-pass" });
|
||||
mockGetProviderSettings.mockReturnValue({
|
||||
provider: "cline-pass",
|
||||
auth: { accessToken: "workos:resolved-token" },
|
||||
});
|
||||
mockGetProviderCollection.mockReturnValue({
|
||||
provider: { env: ["CLINE_API_KEY"] },
|
||||
});
|
||||
mockResolveSystemPrompt.mockResolvedValue("system");
|
||||
|
||||
const request = await buildConnectorStartRequest({
|
||||
options: {
|
||||
cwd: "/tmp/work",
|
||||
mode: "act",
|
||||
enableTools: false,
|
||||
},
|
||||
io: { writeln: vi.fn(), writeErr: vi.fn() },
|
||||
loggerConfig: { enabled: false, level: "info", destination: "stdout" },
|
||||
systemRules: "Rules",
|
||||
defaultModel: "cline-pass/glm-5.1",
|
||||
});
|
||||
|
||||
expect(request.provider).toBe("cline-pass");
|
||||
expect(request.apiKey).toBe("workos:resolved-token");
|
||||
expect(request.model).toBe("cline-pass/glm-5.1");
|
||||
});
|
||||
|
||||
it("uses auth material resolved by provider settings manager", async () => {
|
||||
mockGetLastUsedProviderSettings.mockReturnValue({ provider: "cline-pass" });
|
||||
mockGetProviderSettings.mockReturnValue({
|
||||
provider: "cline-pass",
|
||||
auth: { accessToken: "workos:resolved-token" },
|
||||
});
|
||||
mockGetProviderCollection.mockReturnValue({
|
||||
provider: { env: ["CLINE_API_KEY"] },
|
||||
});
|
||||
mockResolveSystemPrompt.mockResolvedValue("system");
|
||||
|
||||
const request = await buildConnectorStartRequest({
|
||||
options: {
|
||||
cwd: "/tmp/work",
|
||||
mode: "act",
|
||||
enableTools: false,
|
||||
},
|
||||
io: { writeln: vi.fn(), writeErr: vi.fn() },
|
||||
loggerConfig: { enabled: false, level: "info", destination: "stdout" },
|
||||
systemRules: "Rules",
|
||||
defaultModel: "cline-pass/glm-5.1",
|
||||
});
|
||||
|
||||
expect(request.provider).toBe("cline-pass");
|
||||
expect(request.apiKey).toBe("workos:resolved-token");
|
||||
expect(request.model).toBe("cline-pass/glm-5.1");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
import type { CliLoggerAdapter } from "../logging/adapter";
|
||||
import { resolveSystemPrompt } from "../runtime/prompt";
|
||||
import { resolveCliSessionMetadata } from "../utils/enterprise";
|
||||
import { getCliFeatureFlagsService } from "../utils/feature-flags";
|
||||
import { resolveWorkspaceRoot } from "../utils/helpers";
|
||||
import {
|
||||
parseLocalRowMetadata,
|
||||
@@ -62,7 +63,10 @@ export async function buildConnectorStartRequest(input: {
|
||||
}): Promise<ChatStartSessionRequest> {
|
||||
const providerSettingsManager = new ProviderSettingsManager();
|
||||
const lastUsedProviderSettings =
|
||||
providerSettingsManager.getLastUsedProviderSettings();
|
||||
providerSettingsManager.getLastUsedProviderSettings({
|
||||
isClinePassEnabled:
|
||||
getCliFeatureFlagsService().getBooleanFlagEnabled("ext-cline-pass"),
|
||||
});
|
||||
const provider = normalizeProviderId(
|
||||
input.options.provider?.trim() ||
|
||||
lastUsedProviderSettings?.provider ||
|
||||
|
||||
@@ -29,7 +29,9 @@ const authMocks = vi.hoisted(() => ({
|
||||
runAuthCommand: vi.fn(),
|
||||
}));
|
||||
const providerSettingsMocks = vi.hoisted(() => ({
|
||||
getLastUsedProviderSettings: vi.fn<() => unknown>(() => undefined),
|
||||
getLastUsedProviderSettings: vi.fn<(options?: unknown) => unknown>(
|
||||
() => undefined,
|
||||
),
|
||||
getProviderConfig: vi.fn<(providerId: string, options?: unknown) => unknown>(
|
||||
() => undefined,
|
||||
),
|
||||
@@ -82,6 +84,11 @@ const historyMocks = vi.hoisted(() => ({
|
||||
runHistoryExport: vi.fn(async () => 0),
|
||||
runHistoryUpdate: vi.fn(async () => 0),
|
||||
}));
|
||||
const historyResumeMocks = vi.hoisted(() => ({
|
||||
spawnHistoryResume: vi.fn<() => Promise<number | undefined>>(
|
||||
async () => undefined,
|
||||
),
|
||||
}));
|
||||
const loggingMocks = vi.hoisted(() => ({
|
||||
createCliLoggerAdapter: vi.fn(() => ({
|
||||
core: {
|
||||
@@ -101,10 +108,13 @@ const hubRuntimeMocks = vi.hoisted(() => ({
|
||||
}));
|
||||
const telemetryMocks = vi.hoisted(() => ({
|
||||
captureCliExtensionActivated: vi.fn(),
|
||||
identifyCliTelemetryAccount: vi.fn(),
|
||||
identifyTelemetryAccount: vi.fn(),
|
||||
getCliTelemetryService: vi.fn(),
|
||||
disposeCliTelemetryService: vi.fn(async () => {}),
|
||||
}));
|
||||
const featureFlagMocks = vi.hoisted(() => ({
|
||||
getBooleanFlagEnabled: vi.fn(() => false),
|
||||
}));
|
||||
|
||||
function forcePromptModeInput() {
|
||||
Object.defineProperty(process.stdin, "isTTY", {
|
||||
@@ -148,8 +158,8 @@ vi.mock("@cline/core", () => {
|
||||
stop: vi.fn(),
|
||||
})),
|
||||
ProviderSettingsManager: class {
|
||||
getLastUsedProviderSettings() {
|
||||
return providerSettingsMocks.getLastUsedProviderSettings();
|
||||
getLastUsedProviderSettings(options?: unknown) {
|
||||
return providerSettingsMocks.getLastUsedProviderSettings(options);
|
||||
}
|
||||
getProviderSettings(providerId: string) {
|
||||
return providerSettingsMocks.getProviderSettings(providerId);
|
||||
@@ -164,6 +174,12 @@ vi.mock("@cline/core", () => {
|
||||
};
|
||||
});
|
||||
vi.mock("./utils/provider-auth", () => authMocks);
|
||||
vi.mock("./utils/feature-flags", () => ({
|
||||
getCliFeatureFlagsService: () => ({
|
||||
getBooleanFlagEnabled: featureFlagMocks.getBooleanFlagEnabled,
|
||||
}),
|
||||
refreshCliFeatureFlagsInBackground: vi.fn(),
|
||||
}));
|
||||
vi.mock("./runtime/prompt", () => ({
|
||||
resolveSystemPrompt: promptMocks.resolveSystemPrompt,
|
||||
}));
|
||||
@@ -172,6 +188,7 @@ vi.mock("./commands/dashboard", () => dashboardMocks);
|
||||
vi.mock("./kanban-migration/notice", () => migrationNoticeMocks);
|
||||
vi.mock("./commands/update", () => updateMocks);
|
||||
vi.mock("./commands/history", () => historyMocks);
|
||||
vi.mock("./utils/history-resume", () => historyResumeMocks);
|
||||
vi.mock("./logging/adapter", () => loggingMocks);
|
||||
vi.mock("./utils/hub-runtime", () => hubRuntimeMocks);
|
||||
vi.mock("./utils/telemetry", () => telemetryMocks);
|
||||
@@ -191,6 +208,8 @@ describe("runCli lightweight command dispatch", () => {
|
||||
historyMocks.runHistoryExport.mockResolvedValue(0);
|
||||
historyMocks.runHistoryUpdate.mockReset();
|
||||
historyMocks.runHistoryUpdate.mockResolvedValue(0);
|
||||
historyResumeMocks.spawnHistoryResume.mockReset();
|
||||
historyResumeMocks.spawnHistoryResume.mockResolvedValue(undefined);
|
||||
sessionMocks.getSessionRow.mockReset();
|
||||
sessionMocks.getSessionRow.mockResolvedValue({
|
||||
sessionId: "sess_123",
|
||||
@@ -246,7 +265,7 @@ describe("runCli lightweight command dispatch", () => {
|
||||
updateMocks.getPreferredKanbanInstaller.mockReset();
|
||||
updateMocks.getPreferredKanbanInstaller.mockReturnValue(undefined);
|
||||
telemetryMocks.captureCliExtensionActivated.mockReset();
|
||||
telemetryMocks.identifyCliTelemetryAccount.mockReset();
|
||||
telemetryMocks.identifyTelemetryAccount.mockReset();
|
||||
telemetryMocks.getCliTelemetryService.mockReset();
|
||||
telemetryMocks.disposeCliTelemetryService.mockReset();
|
||||
telemetryMocks.disposeCliTelemetryService.mockResolvedValue(undefined);
|
||||
@@ -719,10 +738,47 @@ describe("runCli lightweight command dispatch", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("forces chat view when resuming from history picker", async () => {
|
||||
it("resumes a history-picked session in a child process", async () => {
|
||||
historyMocks.runHistoryList.mockImplementationOnce(
|
||||
async () => "sess_from_history",
|
||||
);
|
||||
historyResumeMocks.spawnHistoryResume.mockResolvedValueOnce(0);
|
||||
process.argv = ["bun", "src/index.ts", "history"];
|
||||
|
||||
const { runCli } = await import("./main");
|
||||
|
||||
await expect(runCli()).resolves.toBeUndefined();
|
||||
expect(historyResumeMocks.spawnHistoryResume).toHaveBeenCalledTimes(1);
|
||||
expect(historyResumeMocks.spawnHistoryResume).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
sessionId: "sess_from_history",
|
||||
normalizedArgs: ["history"],
|
||||
remainingArgs: ["history"],
|
||||
}),
|
||||
);
|
||||
expect(runtimeMocks.runInteractive).not.toHaveBeenCalled();
|
||||
expect(process.exitCode).toBe(0);
|
||||
});
|
||||
|
||||
it("propagates the child exit code when resuming from history picker", async () => {
|
||||
historyMocks.runHistoryList.mockImplementationOnce(
|
||||
async () => "sess_from_history",
|
||||
);
|
||||
historyResumeMocks.spawnHistoryResume.mockResolvedValueOnce(3);
|
||||
process.argv = ["bun", "src/index.ts", "history"];
|
||||
|
||||
const { runCli } = await import("./main");
|
||||
|
||||
await expect(runCli()).resolves.toBeUndefined();
|
||||
expect(process.exitCode).toBe(3);
|
||||
expect(runtimeMocks.runInteractive).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("forces chat view when the history-picker child cannot launch", async () => {
|
||||
historyMocks.runHistoryList.mockImplementationOnce(
|
||||
async () => "sess_from_history",
|
||||
);
|
||||
historyResumeMocks.spawnHistoryResume.mockResolvedValueOnce(undefined);
|
||||
process.argv = ["bun", "src/index.ts", "history"];
|
||||
|
||||
const { runCli } = await import("./main");
|
||||
|
||||
+24
-1
@@ -19,6 +19,10 @@ import {
|
||||
buildCliCompactionConfig,
|
||||
CLI_COMPACTION_MODE_EXPECTED_TEXT,
|
||||
} from "./utils/compaction-mode";
|
||||
import {
|
||||
getCliFeatureFlagsService,
|
||||
refreshCliFeatureFlagsInBackground,
|
||||
} from "./utils/feature-flags";
|
||||
import {
|
||||
configureSandboxEnvironment,
|
||||
normalizeAutoApproveArgs,
|
||||
@@ -663,6 +667,21 @@ export async function runCli(): Promise<void> {
|
||||
|
||||
let resumeSessionId: string | undefined = ctx.resumeSessionId;
|
||||
if (resumeSessionId) {
|
||||
// The history picker already created (and tore down) an OpenTUI renderer
|
||||
// in this process; starting the interactive TUI here would create a
|
||||
// second one, which can crash natively during teardown. Resume in a
|
||||
// fresh `cline --id <session-id>` child process instead.
|
||||
const { spawnHistoryResume } = await import("./utils/history-resume");
|
||||
const childExitCode = await spawnHistoryResume({
|
||||
sessionId: resumeSessionId,
|
||||
normalizedArgs,
|
||||
remainingArgs: program.args,
|
||||
configDir,
|
||||
});
|
||||
if (childExitCode !== undefined) {
|
||||
process.exitCode = childExitCode;
|
||||
return;
|
||||
}
|
||||
args = {
|
||||
...args,
|
||||
interactive: true,
|
||||
@@ -836,8 +855,12 @@ export async function runCli(): Promise<void> {
|
||||
};
|
||||
registerDisposable(stopUserInstructionService);
|
||||
try {
|
||||
refreshCliFeatureFlagsInBackground();
|
||||
const lastUsedProviderSettings =
|
||||
providerSettingsManager.getLastUsedProviderSettings();
|
||||
providerSettingsManager.getLastUsedProviderSettings({
|
||||
isClinePassEnabled:
|
||||
getCliFeatureFlagsService().getBooleanFlagEnabled("ext-cline-pass"),
|
||||
});
|
||||
const provider = normalizeProviderId(
|
||||
args.provider?.trim() || lastUsedProviderSettings?.provider || "cline",
|
||||
);
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
type ChatCommandState,
|
||||
createChatCommandHost,
|
||||
chatCommandHost,
|
||||
createChatCommandHost,
|
||||
} from "../../utils/chat-commands";
|
||||
import type { Config } from "../../utils/types";
|
||||
import {
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import {
|
||||
chmod,
|
||||
mkdir,
|
||||
mkdtemp,
|
||||
readFile,
|
||||
rm,
|
||||
writeFile,
|
||||
} from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import type { UserInstructionConfigService } from "@cline/core";
|
||||
@@ -43,9 +50,17 @@ describe("interactive config data loader", () => {
|
||||
};
|
||||
|
||||
afterEach(async () => {
|
||||
process.env.CLINE_GLOBAL_SETTINGS_PATH =
|
||||
envSnapshot.CLINE_GLOBAL_SETTINGS_PATH;
|
||||
process.env.CLINE_MCP_SETTINGS_PATH = envSnapshot.CLINE_MCP_SETTINGS_PATH;
|
||||
if (envSnapshot.CLINE_GLOBAL_SETTINGS_PATH === undefined) {
|
||||
delete process.env.CLINE_GLOBAL_SETTINGS_PATH;
|
||||
} else {
|
||||
process.env.CLINE_GLOBAL_SETTINGS_PATH =
|
||||
envSnapshot.CLINE_GLOBAL_SETTINGS_PATH;
|
||||
}
|
||||
if (envSnapshot.CLINE_MCP_SETTINGS_PATH === undefined) {
|
||||
delete process.env.CLINE_MCP_SETTINGS_PATH;
|
||||
} else {
|
||||
process.env.CLINE_MCP_SETTINGS_PATH = envSnapshot.CLINE_MCP_SETTINGS_PATH;
|
||||
}
|
||||
await Promise.all(
|
||||
tempRoots.map((dir) => rm(dir, { recursive: true, force: true })),
|
||||
);
|
||||
@@ -76,6 +91,28 @@ describe("interactive config data loader", () => {
|
||||
return pluginPath;
|
||||
}
|
||||
|
||||
async function writeMcpSettingsPlugin(tempRoot: string): Promise<string> {
|
||||
const pluginsDir = join(tempRoot, ".cline", "plugins");
|
||||
await mkdir(pluginsDir, { recursive: true });
|
||||
const pluginPath = join(pluginsDir, "settings-mcp-plugin.js");
|
||||
await writeFile(
|
||||
pluginPath,
|
||||
[
|
||||
"export default {",
|
||||
" name: 'settings-mcp-plugin',",
|
||||
" manifest: { capabilities: ['mcp'] },",
|
||||
" setup(api) {",
|
||||
" api.registerMcpServer({",
|
||||
" name: 'smoke',",
|
||||
" transport: { type: 'stdio', command: process.execPath, args: ['-e', 'process.exit(0)'] },",
|
||||
" });",
|
||||
" },",
|
||||
"};",
|
||||
].join("\n"),
|
||||
);
|
||||
return pluginPath;
|
||||
}
|
||||
|
||||
it("toggles a skill item to the opposite enabled state and refreshes before reload", async () => {
|
||||
const tempRoot = await mkdtemp(join(tmpdir(), "cli-config-data-"));
|
||||
tempRoots.push(tempRoot);
|
||||
@@ -311,6 +348,70 @@ Find installable skills.`,
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("loads plugin-owned MCP servers from settings", async () => {
|
||||
const tempRoot = await mkdtemp(join(tmpdir(), "cli-config-data-"));
|
||||
tempRoots.push(tempRoot);
|
||||
const settingsPath = join(tempRoot, "cline_mcp_settings.json");
|
||||
process.env.CLINE_MCP_SETTINGS_PATH = settingsPath;
|
||||
const pluginPath = await writeMcpSettingsPlugin(tempRoot);
|
||||
await writeFile(
|
||||
settingsPath,
|
||||
`${JSON.stringify(
|
||||
{
|
||||
mcpServers: {
|
||||
smoke: {
|
||||
transport: {
|
||||
type: "stdio",
|
||||
command: process.execPath,
|
||||
args: ["-e", "process.exit(0)"],
|
||||
},
|
||||
metadata: {
|
||||
source: "plugin",
|
||||
pluginName: "settings-mcp-plugin",
|
||||
pluginPath,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`,
|
||||
);
|
||||
const loader = createInteractiveConfigDataLoader({
|
||||
config: createConfig(tempRoot),
|
||||
});
|
||||
|
||||
const data = await loader.loadConfigData({ includePluginTools: true });
|
||||
|
||||
expect(
|
||||
data.mcp.some(
|
||||
(item) =>
|
||||
item.name === "smoke" &&
|
||||
item.pluginName === "settings-mcp-plugin" &&
|
||||
item.pluginPath === pluginPath &&
|
||||
item.kind === "mcp",
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("does not load plugin MCP rows directly from plugin diagnostics", 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 pluginPath = await writeMcpSettingsPlugin(tempRoot);
|
||||
const loader = createInteractiveConfigDataLoader({
|
||||
config: createConfig(tempRoot),
|
||||
});
|
||||
|
||||
const data = await loader.loadConfigData({ includePluginTools: true });
|
||||
|
||||
expect(data.plugins.some((item) => item.path === pluginPath)).toBe(true);
|
||||
expect(data.mcp.some((item) => item.pluginPath === pluginPath)).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps failed plugins visible with their load error", async () => {
|
||||
const tempRoot = await mkdtemp(join(tmpdir(), "cli-config-data-"));
|
||||
tempRoots.push(tempRoot);
|
||||
@@ -731,6 +832,142 @@ Review with the bundled skill.`,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("disables and re-syncs plugin-owned MCP servers when toggling plugins", async () => {
|
||||
const tempRoot = await mkdtemp(join(tmpdir(), "cli-config-data-"));
|
||||
tempRoots.push(tempRoot);
|
||||
const settingsPath = join(tempRoot, "cline_mcp_settings.json");
|
||||
process.env.CLINE_GLOBAL_SETTINGS_PATH = join(
|
||||
tempRoot,
|
||||
"global-settings.json",
|
||||
);
|
||||
process.env.CLINE_MCP_SETTINGS_PATH = settingsPath;
|
||||
const pluginPath = await writeMcpSettingsPlugin(tempRoot);
|
||||
await writeFile(
|
||||
settingsPath,
|
||||
`${JSON.stringify(
|
||||
{
|
||||
mcpServers: {
|
||||
smoke: {
|
||||
transport: {
|
||||
type: "stdio",
|
||||
command: process.execPath,
|
||||
args: ["-e", "process.exit(0)"],
|
||||
},
|
||||
oauth: {
|
||||
tokens: {
|
||||
access_token: "token",
|
||||
},
|
||||
},
|
||||
metadata: {
|
||||
source: "plugin",
|
||||
pluginName: "settings-mcp-plugin",
|
||||
pluginPath,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`,
|
||||
);
|
||||
const loader = createInteractiveConfigDataLoader({
|
||||
config: createConfig(tempRoot),
|
||||
});
|
||||
const item: InteractiveConfigItem = {
|
||||
id: pluginPath,
|
||||
name: "settings-mcp-plugin",
|
||||
path: pluginPath,
|
||||
enabled: true,
|
||||
source: "workspace-plugin",
|
||||
kind: "plugin",
|
||||
};
|
||||
|
||||
await loader.onToggleConfigItem(item);
|
||||
let settings = JSON.parse(await readFile(settingsPath, "utf8")) as {
|
||||
mcpServers?: Record<
|
||||
string,
|
||||
{ disabled?: boolean; oauth?: { tokens?: Record<string, string> } }
|
||||
>;
|
||||
};
|
||||
expect(settings.mcpServers?.smoke?.disabled).toBe(true);
|
||||
expect(settings.mcpServers?.smoke?.oauth?.tokens?.access_token).toBe(
|
||||
"token",
|
||||
);
|
||||
|
||||
await loader.onToggleConfigItem({ ...item, enabled: false });
|
||||
settings = JSON.parse(await readFile(settingsPath, "utf8")) as {
|
||||
mcpServers?: Record<
|
||||
string,
|
||||
{ disabled?: boolean; oauth?: { tokens?: Record<string, string> } }
|
||||
>;
|
||||
};
|
||||
expect(settings.mcpServers?.smoke?.disabled).toBeUndefined();
|
||||
expect(settings.mcpServers?.smoke?.oauth?.tokens?.access_token).toBe(
|
||||
"token",
|
||||
);
|
||||
});
|
||||
|
||||
it.skipIf(process.platform === "win32")(
|
||||
"does not mark plugin disabled when MCP disable write fails",
|
||||
async () => {
|
||||
const tempRoot = await mkdtemp(join(tmpdir(), "cli-config-data-"));
|
||||
tempRoots.push(tempRoot);
|
||||
const settingsPath = join(tempRoot, "cline_mcp_settings.json");
|
||||
const globalSettingsPath = join(tempRoot, "global-settings.json");
|
||||
process.env.CLINE_GLOBAL_SETTINGS_PATH = globalSettingsPath;
|
||||
process.env.CLINE_MCP_SETTINGS_PATH = settingsPath;
|
||||
const pluginPath = await writeMcpSettingsPlugin(tempRoot);
|
||||
await writeFile(
|
||||
settingsPath,
|
||||
`${JSON.stringify(
|
||||
{
|
||||
mcpServers: {
|
||||
smoke: {
|
||||
transport: {
|
||||
type: "stdio",
|
||||
command: process.execPath,
|
||||
args: ["-e", "process.exit(0)"],
|
||||
},
|
||||
metadata: {
|
||||
source: "plugin",
|
||||
pluginName: "settings-mcp-plugin",
|
||||
pluginPath,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`,
|
||||
);
|
||||
await chmod(settingsPath, 0o444);
|
||||
const loader = createInteractiveConfigDataLoader({
|
||||
config: createConfig(tempRoot),
|
||||
});
|
||||
|
||||
try {
|
||||
await expect(
|
||||
loader.onToggleConfigItem({
|
||||
id: pluginPath,
|
||||
name: "settings-mcp-plugin",
|
||||
path: pluginPath,
|
||||
enabled: true,
|
||||
source: "workspace-plugin",
|
||||
kind: "plugin",
|
||||
}),
|
||||
).rejects.toThrow();
|
||||
} finally {
|
||||
await chmod(settingsPath, 0o644);
|
||||
}
|
||||
|
||||
await expect(readFile(globalSettingsPath, "utf8")).rejects.toThrow();
|
||||
const settings = JSON.parse(await readFile(settingsPath, "utf8")) as {
|
||||
mcpServers?: Record<string, { disabled?: boolean }>;
|
||||
};
|
||||
expect(settings.mcpServers?.smoke?.disabled).toBeUndefined();
|
||||
},
|
||||
);
|
||||
|
||||
it("surfaces MCP OAuth status and errors", async () => {
|
||||
const tempRoot = await mkdtemp(join(tmpdir(), "cli-config-data-"));
|
||||
tempRoots.push(tempRoot);
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import {
|
||||
createCoreSettingsService,
|
||||
disablePluginMcpServersInSettings,
|
||||
setDisabledPlugin,
|
||||
setDisabledTools,
|
||||
syncPluginMcpServersToSettings,
|
||||
type UserInstructionConfigService,
|
||||
uninstallPlugin,
|
||||
} from "@cline/core";
|
||||
@@ -70,7 +72,32 @@ export function createInteractiveConfigDataLoader(input: {
|
||||
}
|
||||
|
||||
if (item.kind === "plugin" && typeof item.enabled === "boolean") {
|
||||
setDisabledPlugin(item.path, item.enabled);
|
||||
if (item.enabled) {
|
||||
disablePluginMcpServersInSettings({ pluginPaths: [item.path] });
|
||||
setDisabledPlugin(item.path, true);
|
||||
} else {
|
||||
const ownedMcpMutations = disablePluginMcpServersInSettings({
|
||||
pluginPaths: [item.path],
|
||||
});
|
||||
const result = await syncPluginMcpServersToSettings({
|
||||
pluginPaths: [item.path],
|
||||
cwd: input.config.cwd,
|
||||
workspacePath: workspaceRoot(),
|
||||
providerId: input.config.providerId,
|
||||
modelId: input.config.modelId,
|
||||
});
|
||||
if (ownedMcpMutations.length > 0 && result.failures.length > 0) {
|
||||
throw new Error(
|
||||
`Failed to sync plugin MCP servers: ${result.failures
|
||||
.map((failure) => {
|
||||
const plugin = failure.pluginName ?? failure.pluginPath;
|
||||
return `${plugin}: ${failure.message}`;
|
||||
})
|
||||
.join("; ")}`,
|
||||
);
|
||||
}
|
||||
setDisabledPlugin(item.path, false);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import type { CliMigrationNotice } from "../kanban-migration/notice";
|
||||
import { logCliError } from "../logging/errors";
|
||||
import {
|
||||
loadClineAccountSnapshot,
|
||||
onProviderChange,
|
||||
switchClineAccount,
|
||||
} from "../tui/cline-account";
|
||||
import type {
|
||||
@@ -611,6 +612,10 @@ export async function runInteractive(
|
||||
},
|
||||
onModelChange: async () => {
|
||||
await sessionRuntime.ensureReady();
|
||||
await onProviderChange({
|
||||
config,
|
||||
providerId: config.providerId,
|
||||
});
|
||||
const existing = providerSettingsManager.getProviderSettings(
|
||||
config.providerId,
|
||||
) ?? {
|
||||
@@ -631,6 +636,16 @@ export async function runInteractive(
|
||||
},
|
||||
onAccountChange: async () => {
|
||||
await sessionRuntime.ensureReady();
|
||||
await loadClineAccountSnapshot({
|
||||
config,
|
||||
clineApiBaseUrl: options?.clineApiBaseUrl,
|
||||
}).catch((error) => {
|
||||
logCliError(
|
||||
config.logger,
|
||||
"Cline account refresh after account change failed",
|
||||
{ error },
|
||||
);
|
||||
});
|
||||
await sessionRuntime.restartWithCurrentMessages();
|
||||
},
|
||||
onResumeSession: async (sessionId: string) => {
|
||||
|
||||
@@ -12,6 +12,8 @@ const createCore = vi.fn();
|
||||
const getCliTelemetryService = vi.fn(() => undefined);
|
||||
const resolveSessionBackend = vi.fn();
|
||||
const listSessionHistoryFromBackend = vi.fn();
|
||||
const featureFlagsPoll = vi.fn(async () => {});
|
||||
const featureFlagsDispose = vi.fn(async () => {});
|
||||
|
||||
vi.mock("@cline/core", async () => {
|
||||
const actual =
|
||||
@@ -49,6 +51,10 @@ describe("createCliCore", () => {
|
||||
listSessionHistoryFromBackend.mockReset();
|
||||
createCore.mockResolvedValue({
|
||||
runtimeAddress: "127.0.0.1:25463",
|
||||
featureFlags: {
|
||||
poll: featureFlagsPoll,
|
||||
dispose: featureFlagsDispose,
|
||||
},
|
||||
start: vi.fn(),
|
||||
send: vi.fn(),
|
||||
getAccumulatedUsage: vi.fn(),
|
||||
@@ -68,6 +74,8 @@ describe("createCliCore", () => {
|
||||
delete process.env.CLINE_RPC_ADDRESS;
|
||||
delete process.env.CLINE_SESSION_BACKEND_MODE;
|
||||
delete process.env.CLINE_VCR;
|
||||
featureFlagsPoll.mockClear();
|
||||
featureFlagsDispose.mockClear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -108,6 +116,7 @@ describe("createCliCore", () => {
|
||||
backendMode: expect.anything(),
|
||||
}),
|
||||
);
|
||||
expect(featureFlagsPoll).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("forces the local backend when requested by the caller", async () => {
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
createCliMessagesArtifactUploader,
|
||||
prepareCliEnterpriseIntegration,
|
||||
} from "../utils/enterprise";
|
||||
import { getCliFeatureFlagsService } from "../utils/feature-flags";
|
||||
import { resolveWorkspaceRoot } from "../utils/helpers";
|
||||
import { getCliTelemetryService } from "../utils/telemetry";
|
||||
import type { ConversationHistory } from "./export";
|
||||
@@ -40,6 +41,11 @@ export async function createCliCore(options?: {
|
||||
const cwd = options?.cwd?.trim() || process.cwd();
|
||||
const workspaceRoot =
|
||||
options?.workspaceRoot?.trim() || resolveWorkspaceRoot(cwd);
|
||||
const telemetry = getCliTelemetryService(options?.logger);
|
||||
const featureFlags = getCliFeatureFlagsService({
|
||||
logger: options?.logger,
|
||||
telemetry,
|
||||
});
|
||||
const core = await ClineCore.create({
|
||||
...(explicitBackendMode ? { backendMode: explicitBackendMode } : {}),
|
||||
...(options?.forceLocalBackend !== true
|
||||
@@ -53,12 +59,18 @@ export async function createCliCore(options?: {
|
||||
}
|
||||
: {}),
|
||||
capabilities: options?.capabilities,
|
||||
telemetry: getCliTelemetryService(options?.logger),
|
||||
telemetry,
|
||||
featureFlags,
|
||||
logger: options?.logger,
|
||||
toolPolicies: options?.toolPolicies,
|
||||
messagesArtifactUploader: createCliMessagesArtifactUploader(),
|
||||
prepare: prepareCliEnterpriseIntegration,
|
||||
});
|
||||
try {
|
||||
await core.featureFlags.poll();
|
||||
} catch (error) {
|
||||
options?.logger?.error?.("Error polling CLI feature flags", { error });
|
||||
}
|
||||
options?.logger?.log("CLI core runtime routing selected", {
|
||||
backendMode: explicitBackendMode ?? "env-managed",
|
||||
rpcAddress: core.runtimeAddress,
|
||||
|
||||
@@ -9,9 +9,15 @@ const coreMocks = vi.hoisted(() => {
|
||||
return {
|
||||
getProviderSettings: vi.fn(),
|
||||
saveProviderSettings: vi.fn(),
|
||||
fetchMe: vi.fn(),
|
||||
fetchBalance: vi.fn(),
|
||||
fetchOrganizationBalance: vi.fn(),
|
||||
serviceOptions,
|
||||
};
|
||||
});
|
||||
const telemetryMocks = vi.hoisted(() => ({
|
||||
identifyTelemetryAccount: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@cline/core", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@cline/core")>();
|
||||
@@ -24,6 +30,15 @@ vi.mock("@cline/core", async (importOriginal) => {
|
||||
}) {
|
||||
coreMocks.serviceOptions.push(options);
|
||||
}
|
||||
fetchMe() {
|
||||
return coreMocks.fetchMe();
|
||||
}
|
||||
fetchBalance(userId?: string) {
|
||||
return coreMocks.fetchBalance(userId);
|
||||
}
|
||||
fetchOrganizationBalance(organizationId: string) {
|
||||
return coreMocks.fetchOrganizationBalance(organizationId);
|
||||
}
|
||||
},
|
||||
ProviderSettingsManager: class {
|
||||
getProviderSettings(providerId: string) {
|
||||
@@ -36,6 +51,10 @@ vi.mock("@cline/core", async (importOriginal) => {
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../utils/telemetry", () => ({
|
||||
identifyTelemetryAccount: telemetryMocks.identifyTelemetryAccount,
|
||||
}));
|
||||
|
||||
function makeConfig(overrides: Partial<Config> = {}): Config {
|
||||
return {
|
||||
providerId: "cline",
|
||||
@@ -78,7 +97,11 @@ describe("createClineAccountService", () => {
|
||||
vi.unstubAllGlobals();
|
||||
coreMocks.getProviderSettings.mockReset();
|
||||
coreMocks.saveProviderSettings.mockReset();
|
||||
coreMocks.fetchMe.mockReset();
|
||||
coreMocks.fetchBalance.mockReset();
|
||||
coreMocks.fetchOrganizationBalance.mockReset();
|
||||
coreMocks.serviceOptions.length = 0;
|
||||
telemetryMocks.identifyTelemetryAccount.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -163,3 +186,66 @@ describe("createClineAccountService", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("loadClineAccountSnapshot", () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
coreMocks.getProviderSettings.mockReset();
|
||||
coreMocks.saveProviderSettings.mockReset();
|
||||
coreMocks.fetchMe.mockReset();
|
||||
coreMocks.fetchBalance.mockReset();
|
||||
coreMocks.fetchOrganizationBalance.mockReset();
|
||||
coreMocks.serviceOptions.length = 0;
|
||||
telemetryMocks.identifyTelemetryAccount.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("identifies the loaded Cline account for telemetry and feature flags", async () => {
|
||||
coreMocks.getProviderSettings.mockReturnValue({
|
||||
provider: "cline",
|
||||
apiKey: "account-token",
|
||||
});
|
||||
const { loadClineAccountSnapshot } = await import("./cline-account");
|
||||
coreMocks.fetchMe.mockResolvedValue({
|
||||
id: "user-1",
|
||||
email: "user@example.com",
|
||||
displayName: "User One",
|
||||
photoUrl: "",
|
||||
createdAt: "",
|
||||
updatedAt: "",
|
||||
organizations: [
|
||||
{
|
||||
active: true,
|
||||
memberId: "member-1",
|
||||
name: "Acme",
|
||||
organizationId: "org-1",
|
||||
roles: ["member"],
|
||||
},
|
||||
],
|
||||
});
|
||||
coreMocks.fetchBalance.mockResolvedValue({ balance: 10, userId: "user-1" });
|
||||
coreMocks.fetchOrganizationBalance.mockResolvedValue({
|
||||
balance: 20,
|
||||
organizationId: "org-1",
|
||||
});
|
||||
|
||||
await loadClineAccountSnapshot({ config: makeConfig() });
|
||||
|
||||
expect(telemetryMocks.identifyTelemetryAccount).toHaveBeenCalledWith(
|
||||
{
|
||||
id: "user-1",
|
||||
email: "user@example.com",
|
||||
provider: "cline",
|
||||
organizationId: "org-1",
|
||||
organizationName: "Acme",
|
||||
memberId: "member-1",
|
||||
},
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,12 +14,15 @@ import {
|
||||
} from "@cline/core";
|
||||
import { getClineEnvironmentConfig } from "@cline/shared";
|
||||
import { formatCreditBalance, normalizeCreditBalance } from "../utils/output";
|
||||
import { identifyTelemetryAccount } from "../utils/telemetry";
|
||||
import type { Config } from "../utils/types";
|
||||
|
||||
export const CLINE_CREDITS_DASHBOARD_URL =
|
||||
"https://app.cline.bot/dashboard/account?tab=credits";
|
||||
|
||||
type ClineAccountConfig = Pick<Config, "apiKey" | "providerId">;
|
||||
type ClineAccountConfig = Pick<Config, "apiKey" | "logger" | "providerId">;
|
||||
|
||||
const CLINE_PASS_PROVIDER_ID = "cline-pass";
|
||||
|
||||
export interface ClineAccountSnapshot {
|
||||
user: ClineAccountUser;
|
||||
@@ -167,6 +170,15 @@ export async function loadClineAccountSnapshot(input: {
|
||||
const displayedBalance = activeOrganization
|
||||
? (organizationBalance?.balance ?? balance.balance)
|
||||
: balance.balance;
|
||||
const accountContext = {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
provider: "cline",
|
||||
organizationId: activeOrganization?.organizationId,
|
||||
organizationName: activeOrganization?.name,
|
||||
memberId: activeOrganization?.memberId,
|
||||
};
|
||||
identifyTelemetryAccount(accountContext, input.config.logger);
|
||||
|
||||
return {
|
||||
user,
|
||||
@@ -190,3 +202,27 @@ export async function switchClineAccount(input: {
|
||||
}
|
||||
await service.switchAccount(input.organizationId);
|
||||
}
|
||||
|
||||
async function onChangeToClinePass(config: ClineAccountConfig) {
|
||||
try {
|
||||
await switchClineAccount({
|
||||
config: config,
|
||||
organizationId: null,
|
||||
});
|
||||
} catch (error) {
|
||||
config.logger?.debug("Failed to switch ClinePass to personal account", {
|
||||
error,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function onProviderChange(input: {
|
||||
config: ClineAccountConfig;
|
||||
providerId: string;
|
||||
}): Promise<void> {
|
||||
if (input.providerId === CLINE_PASS_PROVIDER_ID) {
|
||||
return onChangeToClinePass(input.config);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -58,7 +58,11 @@ describe("mcp manager dialog helpers", () => {
|
||||
};
|
||||
|
||||
afterEach(async () => {
|
||||
process.env.CLINE_MCP_SETTINGS_PATH = envSnapshot.CLINE_MCP_SETTINGS_PATH;
|
||||
if (envSnapshot.CLINE_MCP_SETTINGS_PATH === undefined) {
|
||||
delete process.env.CLINE_MCP_SETTINGS_PATH;
|
||||
} else {
|
||||
process.env.CLINE_MCP_SETTINGS_PATH = envSnapshot.CLINE_MCP_SETTINGS_PATH;
|
||||
}
|
||||
await Promise.all(
|
||||
tempRoots.map((directory) =>
|
||||
rm(directory, { recursive: true, force: true }),
|
||||
@@ -107,6 +111,44 @@ describe("mcp manager dialog helpers", () => {
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not toggle plugin-owned servers", async () => {
|
||||
const tempRoot = await mkdtemp(join(tmpdir(), "cli-mcp-manager-"));
|
||||
tempRoots.push(tempRoot);
|
||||
const settingsPath = join(tempRoot, "cline_mcp_settings.json");
|
||||
await writeFile(
|
||||
settingsPath,
|
||||
`${JSON.stringify(
|
||||
{
|
||||
mcpServers: {
|
||||
docs: {
|
||||
transport: {
|
||||
type: "stdio",
|
||||
command: "node",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`,
|
||||
);
|
||||
|
||||
const result = toggleMcpServer({
|
||||
name: "docs",
|
||||
path: settingsPath,
|
||||
enabled: true,
|
||||
pluginName: "repo-docs",
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.message).toContain('managed by plugin "repo-docs"');
|
||||
}
|
||||
expect((await readSettings(settingsPath)).mcpServers?.docs?.disabled).toBe(
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
it("returns a visible error message when toggling fails", async () => {
|
||||
const tempRoot = await mkdtemp(join(tmpdir(), "cli-mcp-manager-"));
|
||||
tempRoots.push(tempRoot);
|
||||
|
||||
@@ -13,6 +13,7 @@ export interface McpEntry {
|
||||
enabled?: boolean;
|
||||
description?: string;
|
||||
lastError?: string;
|
||||
pluginName?: string;
|
||||
}
|
||||
|
||||
export type McpServerToggleResult =
|
||||
@@ -36,6 +37,12 @@ export function getMcpManagerEntryStatus(
|
||||
}
|
||||
|
||||
export function toggleMcpServer(server: McpEntry): McpServerToggleResult {
|
||||
if (server.pluginName) {
|
||||
return {
|
||||
ok: false,
|
||||
message: `MCP server "${server.name}" is managed by plugin "${server.pluginName}". Disable the plugin to disable this server.`,
|
||||
};
|
||||
}
|
||||
try {
|
||||
const currentlyEnabled = server.enabled !== false;
|
||||
setMcpServerDisabled({
|
||||
@@ -71,6 +78,7 @@ export function McpManagerContent(
|
||||
const settingsPath = servers[0]?.path ?? resolveDefaultMcpSettingsPath();
|
||||
const itemCount = servers.length;
|
||||
const selectedServer = servers[selected];
|
||||
const hasPluginOwnedServers = servers.some((server) => server.pluginName);
|
||||
|
||||
useDialogKeyboard((key) => {
|
||||
if (key.name === "escape") {
|
||||
@@ -150,6 +158,7 @@ export function McpManagerContent(
|
||||
{isSel ? "\u25b8 " : " "}
|
||||
{enabledIcon}
|
||||
{srv.name}
|
||||
{srv.pluginName ? " *" : ""}
|
||||
</text>
|
||||
{status && (
|
||||
<text fg={srv.lastError ? palette.error : "gray"}>
|
||||
@@ -184,6 +193,12 @@ export function McpManagerContent(
|
||||
</box>
|
||||
)}
|
||||
|
||||
{hasPluginOwnedServers && (
|
||||
<text fg="gray" marginTop={1}>
|
||||
* managed by plugin; disable the plugin to disable the server.
|
||||
</text>
|
||||
)}
|
||||
|
||||
<text fg="gray" marginTop={1}>
|
||||
<em>{getMcpManagerFooterText(servers.length > 0)}</em>
|
||||
</text>
|
||||
|
||||
@@ -2,7 +2,6 @@ import {
|
||||
completeClineDeviceAuth,
|
||||
getProviderConfigFields,
|
||||
isOAuthProvider,
|
||||
listLocalProviders,
|
||||
loginLocalProvider,
|
||||
type ProviderConfigFieldKey,
|
||||
type ProviderConfigFieldRequirement,
|
||||
@@ -22,6 +21,7 @@ import {
|
||||
checkCodexCliInstalled,
|
||||
isOpenAICodexCliProvider,
|
||||
} from "../../../utils/codex-cli";
|
||||
import { listLocalProviders } from "../../../utils/provider-catalog";
|
||||
import { palette } from "../../palette";
|
||||
import {
|
||||
getDefaultAwsRegion,
|
||||
|
||||
@@ -17,7 +17,9 @@ export async function renderHistoryStandalone(input: {
|
||||
});
|
||||
|
||||
return new Promise((resolve) => {
|
||||
let settled = false;
|
||||
let result: number | string = 0;
|
||||
let resolved = false;
|
||||
let destroyStarted = false;
|
||||
let unmounted = false;
|
||||
const root = createRoot(renderer);
|
||||
|
||||
@@ -29,24 +31,29 @@ export async function renderHistoryStandalone(input: {
|
||||
root.unmount();
|
||||
};
|
||||
|
||||
const settle = (value: number | string) => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
unmountRoot();
|
||||
renderer.destroy();
|
||||
resolve(value);
|
||||
};
|
||||
|
||||
// Resolve only once teardown has finished, so callers never run while
|
||||
// the renderer is still restoring the terminal.
|
||||
renderer.on("destroy", () => {
|
||||
unmountRoot();
|
||||
if (!settled) {
|
||||
settled = true;
|
||||
resolve(0);
|
||||
if (!resolved) {
|
||||
resolved = true;
|
||||
resolve(result);
|
||||
}
|
||||
});
|
||||
|
||||
const settle = (value: number | string) => {
|
||||
if (destroyStarted) {
|
||||
return;
|
||||
}
|
||||
destroyStarted = true;
|
||||
result = value;
|
||||
unmountRoot();
|
||||
// Let OpenTUI finish parsing the current stdin batch before teardown.
|
||||
queueMicrotask(() => {
|
||||
renderer.destroy();
|
||||
});
|
||||
};
|
||||
|
||||
root.render(
|
||||
React.createElement(HistoryStandaloneContent, {
|
||||
rows: input.rows,
|
||||
|
||||
@@ -17,6 +17,7 @@ function toMcpEntries(items: InteractiveConfigItem[]): McpEntry[] {
|
||||
enabled: item.enabled,
|
||||
description: item.description,
|
||||
lastError: item.loadError,
|
||||
pluginName: item.pluginName,
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
@@ -179,7 +179,6 @@ async function runProviderChange(
|
||||
|
||||
config.providerId = newProviderId;
|
||||
config.apiKey = newApiKey;
|
||||
|
||||
const resolved = await resolveProviderConfig(
|
||||
newProviderId,
|
||||
{
|
||||
|
||||
@@ -86,6 +86,7 @@ export interface InteractiveConfigData {
|
||||
mcp: InteractiveConfigItem[];
|
||||
tools: InteractiveConfigItem[];
|
||||
workflowSlashCommands: InteractiveSlashCommand[];
|
||||
pluginDiagnosticsLoaded?: boolean;
|
||||
}
|
||||
|
||||
export interface LoadInteractiveConfigDataOptions {
|
||||
@@ -93,12 +94,14 @@ export interface LoadInteractiveConfigDataOptions {
|
||||
}
|
||||
|
||||
export function isToggleableInteractiveConfigItem(
|
||||
item: Pick<InteractiveConfigItem, "kind" | "source">,
|
||||
item: Pick<InteractiveConfigItem, "kind" | "source" | "pluginName">,
|
||||
): boolean {
|
||||
if (item.kind === "mcp") {
|
||||
return !item.pluginName;
|
||||
}
|
||||
return (
|
||||
item.kind === "skill" ||
|
||||
item.kind === "plugin" ||
|
||||
item.kind === "mcp" ||
|
||||
item.source === "builtin" ||
|
||||
item.source === "workspace-plugin" ||
|
||||
item.source === "global-plugin"
|
||||
@@ -242,9 +245,10 @@ function readPackageName(packageJsonPath: string): string | undefined {
|
||||
}
|
||||
}
|
||||
|
||||
function getPluginDisplayName(filePath: string): string {
|
||||
function getPluginDisplayName(filePath: string, searchRoot: string): string {
|
||||
let current = dirname(filePath);
|
||||
for (let depth = 0; depth < 4; depth++) {
|
||||
const root = resolve(searchRoot);
|
||||
while (isPathWithin(root, current)) {
|
||||
const packageJsonPath = join(current, "package.json");
|
||||
if (existsSync(packageJsonPath)) {
|
||||
const packageName = readPackageName(packageJsonPath);
|
||||
@@ -384,7 +388,7 @@ export async function loadInteractiveConfigData(input: {
|
||||
for (const filePath of discoverPluginModulePaths(directory)) {
|
||||
plugins.push({
|
||||
id: filePath,
|
||||
name: getPluginDisplayName(filePath),
|
||||
name: getPluginDisplayName(filePath, directory),
|
||||
path: filePath,
|
||||
enabled: !disabledPlugins.has(filePath),
|
||||
kind: "plugin",
|
||||
@@ -458,6 +462,16 @@ export async function loadInteractiveConfigData(input: {
|
||||
for (const registration of resolveMcpServerRegistrations({
|
||||
filePath: mcpSettingsPath,
|
||||
})) {
|
||||
const pluginName =
|
||||
registration.metadata?.source === "plugin" &&
|
||||
typeof registration.metadata.pluginName === "string"
|
||||
? registration.metadata.pluginName
|
||||
: undefined;
|
||||
const pluginPath =
|
||||
registration.metadata?.source === "plugin" &&
|
||||
typeof registration.metadata.pluginPath === "string"
|
||||
? registration.metadata.pluginPath
|
||||
: undefined;
|
||||
mcp.push({
|
||||
id: registration.name,
|
||||
name: registration.name,
|
||||
@@ -467,6 +481,8 @@ export async function loadInteractiveConfigData(input: {
|
||||
source: detectSource(mcpSettingsPath, input.workspaceRoot),
|
||||
description: getMcpDescription(registration),
|
||||
loadError: registration.oauth?.lastError,
|
||||
pluginName,
|
||||
pluginPath,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
@@ -514,6 +530,7 @@ export async function loadInteractiveConfigData(input: {
|
||||
toolNames: [pluginTool.name],
|
||||
configKind: "tool",
|
||||
pluginName: pluginTool.pluginName,
|
||||
pluginPath: pluginTool.path,
|
||||
source: pluginTool.source,
|
||||
description: pluginTool.description,
|
||||
});
|
||||
@@ -533,5 +550,6 @@ export async function loadInteractiveConfigData(input: {
|
||||
mcp: toSorted(mcp.filter((item) => existsSync(item.path))),
|
||||
tools: toSorted(tools),
|
||||
workflowSlashCommands,
|
||||
pluginDiagnosticsLoaded: input.includePluginTools !== false,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -921,6 +921,7 @@ function App(props: TuiProps) {
|
||||
if (result.reasoningEffort !== undefined) {
|
||||
props.config.reasoningEffort = result.reasoningEffort;
|
||||
}
|
||||
|
||||
handleModelChange().then(() => setAppView("home"));
|
||||
}}
|
||||
onExit={() => {
|
||||
|
||||
@@ -229,3 +229,15 @@ export function getConfigFooterText({
|
||||
export function getConfigItemDisplayName(name: string): string {
|
||||
return name;
|
||||
}
|
||||
|
||||
export function getPluginDiagnosticsLoadingText(
|
||||
tab: InteractiveConfigTab,
|
||||
): string | undefined {
|
||||
if (tab === "tools") {
|
||||
return "Loading plugin tools...";
|
||||
}
|
||||
if (tab === "plugins") {
|
||||
return "Loading plugin diagnostics...";
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -59,6 +59,18 @@ describe("config view helpers", () => {
|
||||
expect(isToggleableConfigItem(createItem({ kind: "mcp" }))).toBe(true);
|
||||
});
|
||||
|
||||
it("does not treat plugin MCP rows as toggleable", () => {
|
||||
expect(
|
||||
isToggleableConfigItem(
|
||||
createItem({
|
||||
kind: "mcp",
|
||||
pluginName: "plugin",
|
||||
source: "workspace-plugin",
|
||||
}),
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("resolves Enter/Tab on a skill row to details", () => {
|
||||
const skill = createItem({
|
||||
kind: "skill",
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
getConfigFooterText,
|
||||
getConfigItemDisplayName,
|
||||
getConfigTabs,
|
||||
getPluginDiagnosticsLoadingText,
|
||||
isInlineConfigAction,
|
||||
isToggleableConfigItem,
|
||||
resolveActiveConfigItems,
|
||||
@@ -198,6 +199,7 @@ function appendToolGroupRows(
|
||||
rightLabel: `${enabledCount}/${groupItems.length} tools enabled`,
|
||||
indent: 2,
|
||||
});
|
||||
|
||||
for (const item of sortBySourceThenName(groupItems)) {
|
||||
rows.push({
|
||||
kind: "ext",
|
||||
@@ -245,17 +247,24 @@ function appendToolRows(
|
||||
appendExtRows(rows, builtinTools);
|
||||
}
|
||||
|
||||
const pluginGroups = groupToolItems(items.filter((item) => item.pluginName));
|
||||
const pluginToolItems = items.filter((item) => item.pluginName);
|
||||
const pluginGroups = groupToolItems(pluginToolItems);
|
||||
if (pluginGroups.length > 0) {
|
||||
rows.push({ kind: "head", label: "Plugins" });
|
||||
appendToolGroupRows(
|
||||
rows,
|
||||
pluginGroups,
|
||||
getSharedToolNames(items.filter((item) => item.pluginName)),
|
||||
getSharedToolNames(pluginToolItems),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function hasPluginDiagnostics(data: InteractiveConfigData): boolean {
|
||||
return (
|
||||
data.pluginDiagnosticsLoaded || data.tools.some((item) => item.pluginName)
|
||||
);
|
||||
}
|
||||
|
||||
function appendSkillRows(
|
||||
rows: ConfigRow[],
|
||||
items: InteractiveConfigItem[],
|
||||
@@ -305,11 +314,18 @@ function withOptimisticToggle(
|
||||
).filter(Boolean),
|
||||
);
|
||||
const updateItems = (items: InteractiveConfigItem[]) =>
|
||||
items.map((candidate) =>
|
||||
matchesItem(candidate)
|
||||
? { ...candidate, enabled: nextEnabled }
|
||||
: candidate,
|
||||
);
|
||||
items.map((candidate) => {
|
||||
if (matchesItem(candidate)) {
|
||||
return { ...candidate, enabled: nextEnabled };
|
||||
}
|
||||
if (
|
||||
item.kind === "plugin" &&
|
||||
(candidate.path === item.path || candidate.pluginPath === item.path)
|
||||
) {
|
||||
return { ...candidate, enabled: nextEnabled };
|
||||
}
|
||||
return candidate;
|
||||
});
|
||||
const updateTools = (items: InteractiveConfigItem[]) =>
|
||||
items.map((candidate) => {
|
||||
if (matchesItem(candidate)) {
|
||||
@@ -381,7 +397,7 @@ export function ConfigPanelContent(props: ConfigPanelProps) {
|
||||
);
|
||||
const [configData, setConfigData] = useState(props.configData);
|
||||
const [pluginToolsLoaded, setPluginToolsLoaded] = useState(
|
||||
props.configData.tools.some((item) => item.pluginName),
|
||||
hasPluginDiagnostics(props.configData),
|
||||
);
|
||||
const [pluginToolsLoading, setPluginToolsLoading] = useState(false);
|
||||
const [pluginToolsError, setPluginToolsError] = useState<
|
||||
@@ -465,10 +481,11 @@ export function ConfigPanelContent(props: ConfigPanelProps) {
|
||||
});
|
||||
} else if (activeTab === "tools") {
|
||||
appendToolRows(r, activeItems);
|
||||
if (pluginToolsLoading) {
|
||||
const loadingText = getPluginDiagnosticsLoadingText(activeTab);
|
||||
if (pluginToolsLoading && loadingText) {
|
||||
r.push({
|
||||
kind: "detail",
|
||||
text: "Loading plugin tools...",
|
||||
text: loadingText,
|
||||
});
|
||||
}
|
||||
if (pluginToolsError) {
|
||||
@@ -499,9 +516,10 @@ export function ConfigPanelContent(props: ConfigPanelProps) {
|
||||
});
|
||||
}
|
||||
if (activeTab === "plugins" && pluginToolsLoading) {
|
||||
const loadingText = getPluginDiagnosticsLoadingText(activeTab);
|
||||
r.push({
|
||||
kind: "detail",
|
||||
text: "Loading plugin diagnostics...",
|
||||
text: loadingText ?? "Loading plugin diagnostics...",
|
||||
});
|
||||
}
|
||||
if (activeTab === "plugins" && pluginToolsError) {
|
||||
@@ -549,15 +567,13 @@ export function ConfigPanelContent(props: ConfigPanelProps) {
|
||||
});
|
||||
if (nextData) {
|
||||
setConfigData(nextData);
|
||||
setPluginToolsLoaded(nextData.tools.some((tool) => tool.pluginName));
|
||||
setPluginToolsLoaded(hasPluginDiagnostics(nextData));
|
||||
} else if (item.kind === "plugin" && loadConfigData) {
|
||||
const refreshedData = await loadConfigData({
|
||||
includePluginTools: true,
|
||||
});
|
||||
setConfigData(refreshedData);
|
||||
setPluginToolsLoaded(
|
||||
refreshedData.tools.some((tool) => tool.pluginName),
|
||||
);
|
||||
setPluginToolsLoaded(hasPluginDiagnostics(refreshedData));
|
||||
setPluginToolsError(undefined);
|
||||
}
|
||||
} catch (error) {
|
||||
|
||||
@@ -2,7 +2,6 @@ import {
|
||||
captureProviderConfigured,
|
||||
getLocalProviderModels,
|
||||
getProviderConfigFields,
|
||||
listLocalProviders,
|
||||
type ProviderConfigFieldKey,
|
||||
type ProviderConfigFields,
|
||||
ProviderSettingsManager,
|
||||
@@ -17,6 +16,7 @@ import {
|
||||
isOpenAICodexCliProvider,
|
||||
} from "../../../utils/codex-cli";
|
||||
import { getPersistedProviderApiKey } from "../../../utils/provider-auth";
|
||||
import { listLocalProviders } from "../../../utils/provider-catalog";
|
||||
import { getCliTelemetryService } from "../../../utils/telemetry";
|
||||
import {
|
||||
buildClineModelEntries,
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
disposeCliFeatureFlagsService,
|
||||
getCliFeatureFlagsService,
|
||||
} from "./feature-flags";
|
||||
|
||||
describe("CLI feature flags singleton", () => {
|
||||
afterEach(async () => {
|
||||
await disposeCliFeatureFlagsService();
|
||||
});
|
||||
|
||||
it("recreates the singleton after disposal", async () => {
|
||||
const service = getCliFeatureFlagsService();
|
||||
|
||||
await disposeCliFeatureFlagsService();
|
||||
|
||||
expect(getCliFeatureFlagsService()).not.toBe(service);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,111 @@
|
||||
import { join } from "node:path";
|
||||
import {
|
||||
type BasicLogger,
|
||||
type FeatureFlagsContext,
|
||||
FeatureFlagsService,
|
||||
type ITelemetryService,
|
||||
NoOpFeatureFlagsProvider,
|
||||
registerDisposable,
|
||||
resolveCoreDistinctId,
|
||||
} from "@cline/core";
|
||||
import {
|
||||
buildClinePostHogClient,
|
||||
PostHogFeatureFlagsProvider,
|
||||
} from "@cline/core/services/feature-flags/posthog";
|
||||
import { resolveClineDataDir } from "@cline/shared/storage";
|
||||
|
||||
let cliFeatureFlagsContext: FeatureFlagsContext = { clientName: "cline-cli" };
|
||||
let cliFeatureFlagsService: FeatureFlagsService | undefined;
|
||||
|
||||
const CLI_FEATURE_FLAGS_CACHE_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000;
|
||||
|
||||
function resolveCliFeatureFlagsCachePath(): string {
|
||||
return join(resolveClineDataDir(), "cache", "feature-flags.json");
|
||||
}
|
||||
|
||||
function ensureCliDistinctId(): string {
|
||||
const distinctId = cliFeatureFlagsContext.distinctId?.trim();
|
||||
if (distinctId) {
|
||||
return distinctId;
|
||||
}
|
||||
const resolved = resolveCoreDistinctId();
|
||||
cliFeatureFlagsContext.distinctId = resolved;
|
||||
return resolved;
|
||||
}
|
||||
|
||||
export function getCliFeatureFlagsContext(): FeatureFlagsContext {
|
||||
ensureCliDistinctId();
|
||||
return { ...cliFeatureFlagsContext };
|
||||
}
|
||||
|
||||
export function getCliFeatureFlagsService(options?: {
|
||||
logger?: BasicLogger;
|
||||
telemetry?: ITelemetryService;
|
||||
}): FeatureFlagsService {
|
||||
if (!cliFeatureFlagsService) {
|
||||
const apiKey = process.env.TELEMETRY_SERVICE_API_KEY;
|
||||
const provider =
|
||||
apiKey &&
|
||||
process.env.IS_TEST !== "true" &&
|
||||
process.env.E2E_TEST !== "true"
|
||||
? new PostHogFeatureFlagsProvider({
|
||||
client: buildClinePostHogClient(apiKey),
|
||||
config: {
|
||||
logger: options?.logger,
|
||||
},
|
||||
})
|
||||
: new NoOpFeatureFlagsProvider();
|
||||
|
||||
cliFeatureFlagsService = new FeatureFlagsService({
|
||||
provider,
|
||||
telemetry: options?.telemetry,
|
||||
logger: options?.logger,
|
||||
context: getCliFeatureFlagsContext(),
|
||||
cacheFilePath: resolveCliFeatureFlagsCachePath(),
|
||||
persistentCacheMaxAgeMs: CLI_FEATURE_FLAGS_CACHE_MAX_AGE_MS,
|
||||
});
|
||||
registerDisposable(disposeCliFeatureFlagsService);
|
||||
}
|
||||
|
||||
return cliFeatureFlagsService;
|
||||
}
|
||||
|
||||
export function refreshCliFeatureFlagsInBackground(logger?: BasicLogger): void {
|
||||
const service = getCliFeatureFlagsService({ logger });
|
||||
void service.poll().catch((error) => {
|
||||
logger?.error?.("Error refreshing CLI feature flags", { error });
|
||||
});
|
||||
}
|
||||
|
||||
export async function disposeCliFeatureFlagsService(): Promise<void> {
|
||||
if (!cliFeatureFlagsService) {
|
||||
return;
|
||||
}
|
||||
|
||||
const current = cliFeatureFlagsService;
|
||||
cliFeatureFlagsService = undefined;
|
||||
await current.dispose();
|
||||
}
|
||||
|
||||
export async function identifyFeatureFlagsAccount(
|
||||
account: { id?: string; email?: string },
|
||||
logger?: BasicLogger,
|
||||
): Promise<void> {
|
||||
const accountId = account.id?.trim();
|
||||
cliFeatureFlagsContext = {
|
||||
...cliFeatureFlagsContext,
|
||||
...(accountId ? { distinctId: accountId, userId: accountId } : {}),
|
||||
...(account.email?.trim() ? { email: account.email.trim() } : {}),
|
||||
};
|
||||
|
||||
if (!cliFeatureFlagsService) {
|
||||
return;
|
||||
}
|
||||
|
||||
cliFeatureFlagsService.setContext(getCliFeatureFlagsContext());
|
||||
try {
|
||||
await cliFeatureFlagsService.poll();
|
||||
} catch (error) {
|
||||
logger?.error?.("Error polling CLI feature flags", { error });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildHistoryResumeArgs } from "./history-resume";
|
||||
|
||||
describe("buildHistoryResumeArgs", () => {
|
||||
it("replaces the history subcommand with --id", () => {
|
||||
expect(
|
||||
buildHistoryResumeArgs({
|
||||
sessionId: "sess_1",
|
||||
normalizedArgs: ["history"],
|
||||
remainingArgs: ["history"],
|
||||
}),
|
||||
).toEqual(["--id", "sess_1"]);
|
||||
});
|
||||
|
||||
it("preserves global flags that precede the subcommand", () => {
|
||||
expect(
|
||||
buildHistoryResumeArgs({
|
||||
sessionId: "sess_1",
|
||||
normalizedArgs: [
|
||||
"--data-dir",
|
||||
"/tmp/data",
|
||||
"-m",
|
||||
"claude-sonnet-4-6",
|
||||
"history",
|
||||
"--limit",
|
||||
"5",
|
||||
],
|
||||
remainingArgs: ["history", "--limit", "5"],
|
||||
}),
|
||||
).toEqual([
|
||||
"--data-dir",
|
||||
"/tmp/data",
|
||||
"-m",
|
||||
"claude-sonnet-4-6",
|
||||
"--id",
|
||||
"sess_1",
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps a global flag value that matches the subcommand alias", () => {
|
||||
expect(
|
||||
buildHistoryResumeArgs({
|
||||
sessionId: "sess_1",
|
||||
normalizedArgs: ["-m", "h", "h"],
|
||||
remainingArgs: ["h"],
|
||||
}),
|
||||
).toEqual(["-m", "h", "--id", "sess_1"]);
|
||||
});
|
||||
|
||||
it("forwards a config dir passed as a subcommand option", () => {
|
||||
expect(
|
||||
buildHistoryResumeArgs({
|
||||
sessionId: "sess_1",
|
||||
normalizedArgs: ["history", "--config", "/tmp/conf"],
|
||||
remainingArgs: ["history", "--config", "/tmp/conf"],
|
||||
configDir: "/tmp/conf",
|
||||
}),
|
||||
).toEqual(["--config", "/tmp/conf", "--id", "sess_1"]);
|
||||
});
|
||||
|
||||
it("does not duplicate a config dir already in the global flags", () => {
|
||||
expect(
|
||||
buildHistoryResumeArgs({
|
||||
sessionId: "sess_1",
|
||||
normalizedArgs: ["--config", "/tmp/conf", "history"],
|
||||
remainingArgs: ["history"],
|
||||
configDir: "/tmp/conf",
|
||||
}),
|
||||
).toEqual(["--config", "/tmp/conf", "--id", "sess_1"]);
|
||||
});
|
||||
|
||||
it("recognizes the --config=<dir> spelling in global flags", () => {
|
||||
expect(
|
||||
buildHistoryResumeArgs({
|
||||
sessionId: "sess_1",
|
||||
normalizedArgs: ["--config=/tmp/conf", "history"],
|
||||
remainingArgs: ["history"],
|
||||
configDir: "/tmp/conf",
|
||||
}),
|
||||
).toEqual(["--config=/tmp/conf", "--id", "sess_1"]);
|
||||
});
|
||||
|
||||
it("returns undefined when remaining args are not a suffix of argv", () => {
|
||||
expect(
|
||||
buildHistoryResumeArgs({
|
||||
sessionId: "sess_1",
|
||||
normalizedArgs: ["history", "--limit", "5"],
|
||||
remainingArgs: ["history", "--limit", "9"],
|
||||
}),
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
buildHistoryResumeArgs({
|
||||
sessionId: "sess_1",
|
||||
normalizedArgs: ["history"],
|
||||
remainingArgs: ["extra", "history"],
|
||||
}),
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,115 @@
|
||||
import { resolveCliLaunchSpec } from "./internal-launch";
|
||||
|
||||
export interface HistoryResumeCommand {
|
||||
launcher: string;
|
||||
childArgs: string[];
|
||||
}
|
||||
|
||||
export interface BuildHistoryResumeArgsInput {
|
||||
sessionId: string;
|
||||
/** Full normalized CLI args (process.argv.slice(2) after normalization). */
|
||||
normalizedArgs: string[];
|
||||
/**
|
||||
* Commander's `program.args` after parsing: the `history` subcommand token
|
||||
* and everything following it. Must be a suffix of `normalizedArgs`.
|
||||
*/
|
||||
remainingArgs: string[];
|
||||
/**
|
||||
* Config dir resolved from the full argv. Forwarded explicitly because
|
||||
* `--config` may have been passed as a `history` subcommand option, which
|
||||
* would otherwise be dropped with the rest of the subcommand args.
|
||||
*/
|
||||
configDir?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds argv for relaunching the CLI as `cline <globalFlags> --id <sessionId>`
|
||||
* after a session is picked in `cline history`. Returns undefined when the
|
||||
* global-flag prefix cannot be derived safely (caller falls back to resuming
|
||||
* in-process).
|
||||
*/
|
||||
export function buildHistoryResumeArgs(
|
||||
input: BuildHistoryResumeArgsInput,
|
||||
): string[] | undefined {
|
||||
const { sessionId, normalizedArgs, remainingArgs, configDir } = input;
|
||||
const splitIndex = normalizedArgs.length - remainingArgs.length;
|
||||
if (splitIndex < 0) {
|
||||
return undefined;
|
||||
}
|
||||
for (let i = 0; i < remainingArgs.length; i++) {
|
||||
if (normalizedArgs[splitIndex + i] !== remainingArgs[i]) {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
const globalArgs = normalizedArgs.slice(0, splitIndex);
|
||||
const args = [...globalArgs];
|
||||
const hasConfigFlag = globalArgs.some(
|
||||
(arg) => arg === "--config" || arg.startsWith("--config="),
|
||||
);
|
||||
if (configDir && !hasConfigFlag) {
|
||||
args.push("--config", configDir);
|
||||
}
|
||||
args.push("--id", sessionId);
|
||||
return args;
|
||||
}
|
||||
|
||||
export function buildHistoryResumeCommand(
|
||||
input: BuildHistoryResumeArgsInput,
|
||||
): HistoryResumeCommand | undefined {
|
||||
const childArgs = buildHistoryResumeArgs(input);
|
||||
if (!childArgs) {
|
||||
return undefined;
|
||||
}
|
||||
const spec = resolveCliLaunchSpec();
|
||||
if (!spec) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
launcher: spec.launcher,
|
||||
childArgs: [...spec.childArgsPrefix, ...childArgs],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Resumes a history-picked session in a fresh `cline --id <sessionId>` child
|
||||
* process with inherited stdio, and returns its exit code. Creating a second
|
||||
* OpenTUI renderer in the picker's process can crash natively during teardown
|
||||
* (Bun "panic(main thread): Segmentation fault" on Ctrl+C), so the resumed
|
||||
* interactive TUI must get a process of its own.
|
||||
*
|
||||
* Returns undefined when the child cannot be launched; the caller should fall
|
||||
* back to resuming in-process.
|
||||
*/
|
||||
export async function spawnHistoryResume(
|
||||
input: BuildHistoryResumeArgsInput,
|
||||
): Promise<number | undefined> {
|
||||
const command = buildHistoryResumeCommand(input);
|
||||
if (!command) {
|
||||
return undefined;
|
||||
}
|
||||
const { spawn } = await import("node:child_process");
|
||||
return await new Promise<number | undefined>((resolve) => {
|
||||
let child: ReturnType<typeof spawn>;
|
||||
try {
|
||||
child = spawn(command.launcher, command.childArgs, {
|
||||
stdio: "inherit",
|
||||
});
|
||||
} catch {
|
||||
resolve(undefined);
|
||||
return;
|
||||
}
|
||||
// The child shares this foreground process group, so terminal-generated
|
||||
// Ctrl+C already reaches it. Keep the parent alive to reap the child
|
||||
// without re-forwarding a second signal into the TUI teardown path.
|
||||
const suppressParentSignal = () => {};
|
||||
process.on("SIGINT", suppressParentSignal);
|
||||
process.on("SIGTERM", suppressParentSignal);
|
||||
const finish = (value: number | undefined) => {
|
||||
process.off("SIGINT", suppressParentSignal);
|
||||
process.off("SIGTERM", suppressParentSignal);
|
||||
resolve(value);
|
||||
};
|
||||
child.once("error", () => finish(undefined));
|
||||
child.once("exit", (code, signal) => finish(signal ? 1 : (code ?? 0)));
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
listLocalProviders: vi.fn(async () => ({ providers: [], settingsPath: "" })),
|
||||
getBooleanFlagEnabled: vi.fn(() => true),
|
||||
}));
|
||||
|
||||
vi.mock("@cline/core", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@cline/core")>();
|
||||
return {
|
||||
...actual,
|
||||
listLocalProviders: mocks.listLocalProviders,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("./feature-flags", () => ({
|
||||
getCliFeatureFlagsService: () => ({
|
||||
getBooleanFlagEnabled: mocks.getBooleanFlagEnabled,
|
||||
}),
|
||||
}));
|
||||
|
||||
describe("listLocalProviders", () => {
|
||||
it("passes the ClinePass feature flag into the SDK provider list", async () => {
|
||||
const { listLocalProviders } = await import("./provider-catalog");
|
||||
const manager = {} as never;
|
||||
|
||||
await listLocalProviders(manager);
|
||||
|
||||
expect(mocks.getBooleanFlagEnabled).toHaveBeenCalledWith("ext-cline-pass");
|
||||
expect(mocks.listLocalProviders).toHaveBeenCalledWith(manager, {
|
||||
isClinePassEnabled: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
import {
|
||||
listLocalProviders as internalListLocalProviders,
|
||||
type ProviderSettingsManager,
|
||||
} from "@cline/core";
|
||||
import { getCliFeatureFlagsService } from "./feature-flags";
|
||||
|
||||
export async function listLocalProviders(
|
||||
manager: ProviderSettingsManager,
|
||||
): ReturnType<typeof internalListLocalProviders> {
|
||||
return await internalListLocalProviders(manager, {
|
||||
isClinePassEnabled:
|
||||
getCliFeatureFlagsService().getBooleanFlagEnabled("ext-cline-pass"),
|
||||
});
|
||||
}
|
||||
@@ -34,7 +34,7 @@ vi.mock("./telemetry", async (importOriginal) => {
|
||||
|
||||
import {
|
||||
captureCliExtensionActivated,
|
||||
identifyCliTelemetryAccount,
|
||||
identifyTelemetryAccount,
|
||||
} from "./telemetry";
|
||||
import { resetCliExtensionActivationForTests } from "./telemetry.test-helpers";
|
||||
|
||||
@@ -93,7 +93,7 @@ describe("captureCliExtensionActivated", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("identifyCliTelemetryAccount", () => {
|
||||
describe("identifyTelemetryAccount", () => {
|
||||
beforeEach(() => {
|
||||
hoisted.identifyAccount.mockClear();
|
||||
hoisted.getCliTelemetryService.mockClear();
|
||||
@@ -107,7 +107,7 @@ describe("identifyCliTelemetryAccount", () => {
|
||||
memberId: "member-7",
|
||||
provider: "cline",
|
||||
};
|
||||
identifyCliTelemetryAccount(account);
|
||||
identifyTelemetryAccount(account);
|
||||
expect(hoisted.identifyAccount).toHaveBeenCalledWith(undefined, account);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
TelemetryLoggerSink,
|
||||
} from "@cline/core";
|
||||
import { getCliBuildInfo } from "./common";
|
||||
import { identifyFeatureFlagsAccount } from "./feature-flags";
|
||||
import {
|
||||
markActivationCaptured,
|
||||
wasActivationCaptured,
|
||||
@@ -102,11 +103,12 @@ export interface CliTelemetryAccountContext {
|
||||
* Safe to call multiple times; the latest values win, mirroring the legacy
|
||||
* singleton-based behavior.
|
||||
*/
|
||||
export function identifyCliTelemetryAccount(
|
||||
export function identifyTelemetryAccount(
|
||||
account: CliTelemetryAccountContext,
|
||||
logger?: BasicLogger,
|
||||
): void {
|
||||
identifyAccount(getCliTelemetryService(logger), account);
|
||||
void identifyFeatureFlagsAccount(account, logger);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -134,6 +136,7 @@ export function captureCliExtensionActivated(
|
||||
const telemetry = getCliTelemetryService(logger);
|
||||
if (account) {
|
||||
identifyAccount(telemetry, account);
|
||||
void identifyFeatureFlagsAccount(account, logger);
|
||||
}
|
||||
captureExtensionActivated(telemetry);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import * as p from "@clack/prompts";
|
||||
import { authorizeMcpServerOAuth } from "@cline/core";
|
||||
import open from "open";
|
||||
import { authorizeMcpServerOAuthWithBrowser as authorizeOAuth } from "./oauth";
|
||||
import {
|
||||
addServer,
|
||||
clearServerOAuth,
|
||||
@@ -17,16 +16,6 @@ function isCancel(value: unknown): value is symbol {
|
||||
return p.isCancel(value);
|
||||
}
|
||||
|
||||
function toErrorMessage(error: unknown): string {
|
||||
if (error instanceof Error) {
|
||||
const message = error.message.trim();
|
||||
if (message.length > 0) {
|
||||
return message;
|
||||
}
|
||||
}
|
||||
return String(error);
|
||||
}
|
||||
|
||||
function transportLabel(t: McpTransport): string {
|
||||
if (t.type === "stdio") return `stdio: ${t.command}`;
|
||||
return `${t.type}: ${t.url}`;
|
||||
@@ -222,29 +211,6 @@ async function collectUrlTransport(
|
||||
};
|
||||
}
|
||||
|
||||
async function authorizeOAuth(name: string): Promise<void> {
|
||||
p.log.info("Opening browser for MCP OAuth authorization");
|
||||
try {
|
||||
const result = await authorizeMcpServerOAuth({
|
||||
serverName: name,
|
||||
filePath: getSettingsPath(),
|
||||
openUrl: async (url) => {
|
||||
p.log.message(`Authorization URL: ${url}`);
|
||||
await open(url, { wait: false });
|
||||
},
|
||||
onServerListening: (info) => {
|
||||
p.log.message(`Waiting for OAuth callback at ${info.callbackUrl}`);
|
||||
},
|
||||
});
|
||||
p.log.success(result.message);
|
||||
} catch (error) {
|
||||
p.log.error(`OAuth authorization failed: ${toErrorMessage(error)}`);
|
||||
p.log.warn(
|
||||
`Server "${name}" is still saved. Choose "Authorize OAuth" to retry.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function actionAdd(): Promise<void> {
|
||||
const name = await p.text({
|
||||
message: "Server name",
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import * as p from "@clack/prompts";
|
||||
import {
|
||||
authorizeMcpServerOAuth,
|
||||
resolveDefaultMcpSettingsPath,
|
||||
} from "@cline/core";
|
||||
import open from "open";
|
||||
|
||||
function toErrorMessage(error: unknown): string {
|
||||
if (error instanceof Error) {
|
||||
const message = error.message.trim();
|
||||
if (message.length > 0) {
|
||||
return message;
|
||||
}
|
||||
}
|
||||
return String(error);
|
||||
}
|
||||
|
||||
export async function authorizeMcpServerOAuthWithBrowser(
|
||||
name: string,
|
||||
): Promise<void> {
|
||||
p.log.info("Opening browser for MCP OAuth authorization");
|
||||
try {
|
||||
const result = await authorizeMcpServerOAuth({
|
||||
serverName: name,
|
||||
filePath: resolveDefaultMcpSettingsPath(),
|
||||
openUrl: async (url) => {
|
||||
p.log.message(`Authorization URL: ${url}`);
|
||||
await open(url, { wait: false });
|
||||
},
|
||||
onServerListening: (info) => {
|
||||
p.log.message(`Waiting for OAuth callback at ${info.callbackUrl}`);
|
||||
},
|
||||
});
|
||||
p.log.success(result.message);
|
||||
} catch (error) {
|
||||
p.log.error(`OAuth authorization failed: ${toErrorMessage(error)}`);
|
||||
p.log.warn(
|
||||
`Server "${name}" is still saved. Choose "Authorize OAuth" to retry.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -389,7 +389,7 @@
|
||||
"protos": "node scripts/build-proto.mjs",
|
||||
"protos-python": "node scripts/build-python-proto.mjs",
|
||||
"download-ripgrep": "node scripts/download-ripgrep.mjs",
|
||||
"postprotos": "biome format --config-path ./biome.jsonc src/shared/proto src/core/controller src/hosts/ webview-ui/src/services src/generated --write --no-errors-on-unmatched",
|
||||
"postprotos": "biome format --config-path ./biome.jsonc src/shared/proto src/generated webview-ui/src/services/grpc-client.ts --write --no-errors-on-unmatched",
|
||||
"clean:build": "rimraf dist dist-standalone webview-ui/build src/generated out/",
|
||||
"clean:deps": "rimraf node_modules webview-ui/node_modules",
|
||||
"clean:all": "npm run clean:build && npm run clean:deps",
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import "should";
|
||||
import {
|
||||
huggingFaceDefaultModelId,
|
||||
huggingFaceModels,
|
||||
} from "../../../../shared/api";
|
||||
import { HuggingFaceHandler } from "../huggingface";
|
||||
|
||||
describe("HuggingFaceHandler", () => {
|
||||
it("uses dynamic Hugging Face model info for models outside the static list", () => {
|
||||
const modelInfo = {
|
||||
maxTokens: 8192,
|
||||
contextWindow: 128_000,
|
||||
supportsImages: false,
|
||||
supportsPromptCache: false,
|
||||
inputPrice: 0,
|
||||
outputPrice: 0,
|
||||
description: "Available on providers: test-provider",
|
||||
};
|
||||
|
||||
const handler = new HuggingFaceHandler({
|
||||
huggingFaceApiKey: "test-api-key",
|
||||
huggingFaceModelId: "Qwen/Qwen3-Coder-480B-A35B-Instruct",
|
||||
huggingFaceModelInfo: modelInfo,
|
||||
});
|
||||
|
||||
handler.getModel().should.deepEqual({
|
||||
id: "Qwen/Qwen3-Coder-480B-A35B-Instruct",
|
||||
info: modelInfo,
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves unknown model IDs when model info is unavailable", () => {
|
||||
const handler = new HuggingFaceHandler({
|
||||
huggingFaceApiKey: "test-api-key",
|
||||
huggingFaceModelId: "Qwen/Qwen3-Coder-480B-A35B-Instruct",
|
||||
});
|
||||
|
||||
handler.getModel().should.deepEqual({
|
||||
id: "Qwen/Qwen3-Coder-480B-A35B-Instruct",
|
||||
info: huggingFaceModels[huggingFaceDefaultModelId],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,56 +1,66 @@
|
||||
import { HuggingFaceModelId, huggingFaceDefaultModelId, huggingFaceModels, ModelInfo } from "@shared/api"
|
||||
import { calculateApiCostOpenAI } from "@utils/cost"
|
||||
import OpenAI from "openai"
|
||||
import type { ChatCompletionTool as OpenAITool } from "openai/resources/chat/completions"
|
||||
import { ClineStorageMessage } from "@/shared/messages/content"
|
||||
import { createOpenAIClient } from "@/shared/net"
|
||||
import { ApiHandler, CommonApiHandlerOptions } from "../"
|
||||
import { withRetry } from "../retry"
|
||||
import { convertToOpenAiMessages } from "../transform/openai-format"
|
||||
import { ApiStream } from "../transform/stream"
|
||||
import { getOpenAIToolParams, ToolCallProcessor } from "../transform/tool-call-processor"
|
||||
import {
|
||||
huggingFaceDefaultModelId,
|
||||
huggingFaceModels,
|
||||
type ModelInfo,
|
||||
} from "@shared/api";
|
||||
import { calculateApiCostOpenAI } from "@utils/cost";
|
||||
import type OpenAI from "openai";
|
||||
import type { ChatCompletionTool as OpenAITool } from "openai/resources/chat/completions";
|
||||
import type { ClineStorageMessage } from "@/shared/messages/content";
|
||||
import { createOpenAIClient } from "@/shared/net";
|
||||
import type { ApiHandler, CommonApiHandlerOptions } from "../";
|
||||
import { withRetry } from "../retry";
|
||||
import { convertToOpenAiMessages } from "../transform/openai-format";
|
||||
import type { ApiStream } from "../transform/stream";
|
||||
import {
|
||||
getOpenAIToolParams,
|
||||
ToolCallProcessor,
|
||||
} from "../transform/tool-call-processor";
|
||||
|
||||
interface HuggingFaceHandlerOptions extends CommonApiHandlerOptions {
|
||||
huggingFaceApiKey?: string
|
||||
huggingFaceModelId?: string
|
||||
huggingFaceModelInfo?: ModelInfo
|
||||
huggingFaceApiKey?: string;
|
||||
huggingFaceModelId?: string;
|
||||
huggingFaceModelInfo?: ModelInfo;
|
||||
}
|
||||
|
||||
export class HuggingFaceHandler implements ApiHandler {
|
||||
private options: HuggingFaceHandlerOptions
|
||||
private client: OpenAI | undefined
|
||||
private cachedModel: { id: HuggingFaceModelId; info: ModelInfo } | undefined
|
||||
private options: HuggingFaceHandlerOptions;
|
||||
private client: OpenAI | undefined;
|
||||
private cachedModel: { id: string; info: ModelInfo } | undefined;
|
||||
|
||||
constructor(options: HuggingFaceHandlerOptions) {
|
||||
this.options = options
|
||||
this.options = options;
|
||||
}
|
||||
|
||||
private ensureClient(): OpenAI {
|
||||
if (!this.client) {
|
||||
if (!this.options.huggingFaceApiKey) {
|
||||
throw new Error("Hugging Face API key is required")
|
||||
throw new Error("Hugging Face API key is required");
|
||||
}
|
||||
|
||||
try {
|
||||
this.client = createOpenAIClient({
|
||||
baseURL: "https://router.huggingface.co/v1",
|
||||
apiKey: this.options.huggingFaceApiKey,
|
||||
})
|
||||
});
|
||||
} catch (error: any) {
|
||||
throw new Error(`Error creating Hugging Face client: ${error.message}`)
|
||||
throw new Error(`Error creating Hugging Face client: ${error.message}`);
|
||||
}
|
||||
}
|
||||
return this.client
|
||||
return this.client;
|
||||
}
|
||||
|
||||
private async *yieldUsage(info: ModelInfo, usage: OpenAI.Completions.CompletionUsage | undefined): ApiStream {
|
||||
private async *yieldUsage(
|
||||
info: ModelInfo,
|
||||
usage: OpenAI.Completions.CompletionUsage | undefined,
|
||||
): ApiStream {
|
||||
if (!usage) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
|
||||
const inputTokens = usage.prompt_tokens || 0
|
||||
const outputTokens = usage.completion_tokens || 0
|
||||
const totalCost = calculateApiCostOpenAI(info, inputTokens, outputTokens)
|
||||
const inputTokens = usage.prompt_tokens || 0;
|
||||
const outputTokens = usage.completion_tokens || 0;
|
||||
const totalCost = calculateApiCostOpenAI(info, inputTokens, outputTokens);
|
||||
|
||||
const usageData = {
|
||||
type: "usage" as const,
|
||||
@@ -59,21 +69,25 @@ export class HuggingFaceHandler implements ApiHandler {
|
||||
cacheWriteTokens: 0,
|
||||
cacheReadTokens: 0,
|
||||
totalCost: totalCost,
|
||||
}
|
||||
};
|
||||
|
||||
yield usageData
|
||||
yield usageData;
|
||||
}
|
||||
|
||||
@withRetry()
|
||||
async *createMessage(systemPrompt: string, messages: ClineStorageMessage[], tools?: OpenAITool[]): ApiStream {
|
||||
async *createMessage(
|
||||
systemPrompt: string,
|
||||
messages: ClineStorageMessage[],
|
||||
tools?: OpenAITool[],
|
||||
): ApiStream {
|
||||
try {
|
||||
const client = this.ensureClient()
|
||||
const model = this.getModel()
|
||||
const client = this.ensureClient();
|
||||
const model = this.getModel();
|
||||
|
||||
const openAiMessages: OpenAI.Chat.ChatCompletionMessageParam[] = [
|
||||
{ role: "system", content: systemPrompt },
|
||||
...convertToOpenAiMessages(messages),
|
||||
]
|
||||
];
|
||||
|
||||
const requestParams = {
|
||||
model: model.id,
|
||||
@@ -83,66 +97,71 @@ export class HuggingFaceHandler implements ApiHandler {
|
||||
stream_options: { include_usage: true },
|
||||
temperature: 0,
|
||||
...getOpenAIToolParams(tools),
|
||||
}
|
||||
};
|
||||
|
||||
const toolCallProcessor = new ToolCallProcessor()
|
||||
const stream = (await client.chat.completions.create(requestParams)) as any
|
||||
const toolCallProcessor = new ToolCallProcessor();
|
||||
const stream = (await client.chat.completions.create(
|
||||
requestParams,
|
||||
)) as any;
|
||||
|
||||
let _chunkCount = 0
|
||||
let _totalContent = ""
|
||||
let _chunkCount = 0;
|
||||
let _totalContent = "";
|
||||
|
||||
for await (const chunk of stream) {
|
||||
_chunkCount++
|
||||
const delta = chunk.choices?.[0]?.delta
|
||||
_chunkCount++;
|
||||
const delta = chunk.choices?.[0]?.delta;
|
||||
if (delta?.content) {
|
||||
_totalContent += delta.content
|
||||
_totalContent += delta.content;
|
||||
|
||||
yield {
|
||||
type: "text",
|
||||
text: delta.content,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
if (delta?.tool_calls) {
|
||||
yield* toolCallProcessor.processToolCallDeltas(delta.tool_calls)
|
||||
yield* toolCallProcessor.processToolCallDeltas(delta.tool_calls);
|
||||
}
|
||||
|
||||
if (chunk.usage) {
|
||||
yield* this.yieldUsage(model.info, chunk.usage)
|
||||
yield* this.yieldUsage(model.info, chunk.usage);
|
||||
}
|
||||
}
|
||||
} catch (error: any) {
|
||||
throw error
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
getModel(): { id: HuggingFaceModelId; info: ModelInfo } {
|
||||
getModel(): { id: string; info: ModelInfo } {
|
||||
// Return cached model if available
|
||||
if (this.cachedModel) {
|
||||
return this.cachedModel
|
||||
return this.cachedModel;
|
||||
}
|
||||
|
||||
const modelId = this.options.huggingFaceModelId
|
||||
|
||||
// List all available models for debugging
|
||||
const _availableModels = Object.keys(huggingFaceModels)
|
||||
let result: { id: HuggingFaceModelId; info: ModelInfo }
|
||||
const modelId = this.options.huggingFaceModelId;
|
||||
let result: { id: string; info: ModelInfo };
|
||||
|
||||
if (modelId && modelId in huggingFaceModels) {
|
||||
const id = modelId as HuggingFaceModelId
|
||||
const modelInfo = huggingFaceModels[id]
|
||||
result = { id, info: modelInfo }
|
||||
const id = modelId as keyof typeof huggingFaceModels;
|
||||
const modelInfo = huggingFaceModels[id];
|
||||
result = { id, info: modelInfo };
|
||||
} else if (modelId) {
|
||||
const defaultInfo = huggingFaceModels[huggingFaceDefaultModelId];
|
||||
result = {
|
||||
id: modelId,
|
||||
info: this.options.huggingFaceModelInfo || defaultInfo,
|
||||
};
|
||||
} else {
|
||||
const defaultInfo = huggingFaceModels[huggingFaceDefaultModelId]
|
||||
const defaultInfo = huggingFaceModels[huggingFaceDefaultModelId];
|
||||
result = {
|
||||
id: huggingFaceDefaultModelId,
|
||||
info: defaultInfo,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Cache the result for future calls
|
||||
this.cachedModel = result
|
||||
this.cachedModel = result;
|
||||
|
||||
return result
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,8 @@
|
||||
import { Anthropic } from "@anthropic-ai/sdk"
|
||||
import { ClineStorageMessage, convertClineStorageToAnthropicMessage } from "@/shared/messages/content"
|
||||
import type { Anthropic } from "@anthropic-ai/sdk";
|
||||
import {
|
||||
type ClineStorageMessage,
|
||||
convertClineStorageToAnthropicMessage,
|
||||
} from "@/shared/messages/content";
|
||||
|
||||
/**
|
||||
* Converts Cline storage messages to Anthropic API format with optional cache control.
|
||||
@@ -21,32 +24,37 @@ export function sanitizeAnthropicMessages(
|
||||
// know the last message to retrieve from the cache for the current request.
|
||||
const userMsgIndices = clineMessages.reduce((acc, msg, index) => {
|
||||
if (msg.role === "user") {
|
||||
acc.push(index)
|
||||
acc.push(index);
|
||||
}
|
||||
return acc
|
||||
}, [] as number[])
|
||||
return acc;
|
||||
}, [] as number[]);
|
||||
// Set to -1 if there are no user messages so the indices are invalid
|
||||
const indicesLength = userMsgIndices.length ?? -1
|
||||
const lastUserMsgIndex = userMsgIndices[indicesLength - 1]
|
||||
const secondLastMsgUserIndex = userMsgIndices[indicesLength - 2]
|
||||
const indicesLength = userMsgIndices.length ?? -1;
|
||||
const lastUserMsgIndex = userMsgIndices[indicesLength - 1];
|
||||
const secondLastMsgUserIndex = userMsgIndices[indicesLength - 2];
|
||||
|
||||
return clineMessages.map((msg, index) => {
|
||||
const anthropicMsg = convertClineStorageToAnthropicMessage(msg)
|
||||
const anthropicMsg = convertClineStorageToAnthropicMessage(msg);
|
||||
|
||||
// Add cache control to the last two user messages
|
||||
if (supportCache && (index === lastUserMsgIndex || index === secondLastMsgUserIndex)) {
|
||||
return addCacheControl(anthropicMsg)
|
||||
if (
|
||||
supportCache &&
|
||||
(index === lastUserMsgIndex || index === secondLastMsgUserIndex)
|
||||
) {
|
||||
return addCacheControl(anthropicMsg);
|
||||
}
|
||||
|
||||
return anthropicMsg
|
||||
})
|
||||
return anthropicMsg;
|
||||
});
|
||||
}
|
||||
|
||||
const isThinkingBlock = (
|
||||
block: Anthropic.ContentBlockParam,
|
||||
): block is Anthropic.Messages.ThinkingBlockParam | Anthropic.Messages.RedactedThinkingBlockParam => {
|
||||
return block.type === "thinking" || block.type === "redacted_thinking"
|
||||
}
|
||||
): block is
|
||||
| Anthropic.Messages.ThinkingBlockParam
|
||||
| Anthropic.Messages.RedactedThinkingBlockParam => {
|
||||
return block.type === "thinking" || block.type === "redacted_thinking";
|
||||
};
|
||||
|
||||
/**
|
||||
* Adds ephemeral cache control to the last content block of a message.
|
||||
@@ -55,7 +63,9 @@ const isThinkingBlock = (
|
||||
* @param message - The Anthropic message to add cache control to
|
||||
* @returns A new message with cache control added to the last content block
|
||||
*/
|
||||
function addCacheControl(message: Anthropic.MessageParam): Anthropic.MessageParam {
|
||||
function addCacheControl(
|
||||
message: Anthropic.MessageParam,
|
||||
): Anthropic.MessageParam {
|
||||
// Convert string content to array format
|
||||
if (typeof message.content === "string") {
|
||||
return {
|
||||
@@ -67,24 +77,24 @@ function addCacheControl(message: Anthropic.MessageParam): Anthropic.MessagePara
|
||||
cache_control: { type: "ephemeral" },
|
||||
} satisfies Anthropic.TextBlockParam,
|
||||
],
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Handle array content - add cache control to the last block
|
||||
const content = [...message.content]
|
||||
const lastIndex = content.length - 1
|
||||
const content = [...message.content];
|
||||
const lastIndex = content.length - 1;
|
||||
|
||||
if (lastIndex >= 0) {
|
||||
const lastBlock = content[lastIndex]
|
||||
const lastBlock = content[lastIndex];
|
||||
|
||||
// Only add cache_control to block types that support it (not ThinkingBlockParam)
|
||||
if (!isThinkingBlock(lastBlock)) {
|
||||
content[lastIndex] = {
|
||||
...lastBlock,
|
||||
cache_control: { type: "ephemeral" },
|
||||
} satisfies Anthropic.ContentBlockParam
|
||||
} satisfies Anthropic.ContentBlockParam;
|
||||
}
|
||||
}
|
||||
|
||||
return { ...message, content }
|
||||
return { ...message, content };
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Anthropic } from "@anthropic-ai/sdk"
|
||||
import { Content, GenerateContentResponse, Part } from "@google/genai"
|
||||
import { ClineStorageMessage } from "@/shared/messages/content"
|
||||
import type { Anthropic } from "@anthropic-ai/sdk";
|
||||
import type { Content, GenerateContentResponse, Part } from "@google/genai";
|
||||
import type { ClineStorageMessage } from "@/shared/messages/content";
|
||||
|
||||
// Source: https://ai.google.dev/gemini-api/docs/thought-signatures#faqs
|
||||
// While injecting custom function call blocks into the request is strongly discouraged,
|
||||
@@ -8,27 +8,29 @@ import { ClineStorageMessage } from "@/shared/messages/content"
|
||||
// calls and responses that were executed deterministically by the client, or transferring a
|
||||
// trace from a different model that does not include thought signatures, you can set the following dummy signatures of either
|
||||
// "context_engineering_is_the_way_to_go" or "skip_thought_signature_validator" in the thought signature field to skip validation.
|
||||
const GEMINI_DUMMY_THOUGHT_SIGNATURE = "skip_thought_signature_validator"
|
||||
const GEMINI_DUMMY_THOUGHT_SIGNATURE = "skip_thought_signature_validator";
|
||||
|
||||
export function convertAnthropicContentToGemini(content: string | ClineStorageMessage["content"]): Part[] {
|
||||
export function convertAnthropicContentToGemini(
|
||||
content: string | ClineStorageMessage["content"],
|
||||
): Part[] {
|
||||
if (typeof content === "string") {
|
||||
return [{ text: content }]
|
||||
return [{ text: content }];
|
||||
}
|
||||
return content
|
||||
.flatMap((block): Part | undefined => {
|
||||
switch (block.type) {
|
||||
case "text":
|
||||
return { text: block.text, thoughtSignature: block.signature }
|
||||
return { text: block.text, thoughtSignature: block.signature };
|
||||
case "image":
|
||||
if (block.source.type !== "base64") {
|
||||
throw new Error("Unsupported image source type")
|
||||
throw new Error("Unsupported image source type");
|
||||
}
|
||||
return {
|
||||
inlineData: {
|
||||
data: block.source.data,
|
||||
mimeType: block.source.media_type,
|
||||
},
|
||||
}
|
||||
};
|
||||
case "tool_use":
|
||||
return {
|
||||
functionCall: {
|
||||
@@ -37,7 +39,7 @@ export function convertAnthropicContentToGemini(content: string | ClineStorageMe
|
||||
},
|
||||
// Thought signature is required, so provide a dummy one if not present
|
||||
thoughtSignature: block.signature || GEMINI_DUMMY_THOUGHT_SIGNATURE,
|
||||
}
|
||||
};
|
||||
case "tool_result":
|
||||
return {
|
||||
functionResponse: {
|
||||
@@ -46,57 +48,66 @@ export function convertAnthropicContentToGemini(content: string | ClineStorageMe
|
||||
result: block.content,
|
||||
},
|
||||
},
|
||||
}
|
||||
};
|
||||
case "thinking":
|
||||
return {
|
||||
text: block.thinking,
|
||||
thought: true,
|
||||
thoughtSignature: block.signature || GEMINI_DUMMY_THOUGHT_SIGNATURE,
|
||||
}
|
||||
};
|
||||
default:
|
||||
return undefined
|
||||
return undefined;
|
||||
}
|
||||
})
|
||||
.filter((part): part is Part => part !== undefined) // Filter out unsupported blocks
|
||||
.filter((part): part is Part => part !== undefined); // Filter out unsupported blocks
|
||||
}
|
||||
|
||||
export function convertAnthropicMessageToGemini(message: ClineStorageMessage): Content {
|
||||
export function convertAnthropicMessageToGemini(
|
||||
message: ClineStorageMessage,
|
||||
): Content {
|
||||
return {
|
||||
role: message.role === "assistant" ? "model" : "user",
|
||||
parts: convertAnthropicContentToGemini(message.content),
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/*
|
||||
It looks like gemini likes to double escape certain characters when writing file contents: https://discuss.ai.google.dev/t/function-call-string-property-is-double-escaped/37867
|
||||
*/
|
||||
export function unescapeGeminiContent(content: string) {
|
||||
return content.replace(/\\n/g, "\n").replace(/\\'/g, "'").replace(/\\"/g, '"').replace(/\\r/g, "\r").replace(/\\t/g, "\t")
|
||||
return content
|
||||
.replace(/\\n/g, "\n")
|
||||
.replace(/\\'/g, "'")
|
||||
.replace(/\\"/g, '"')
|
||||
.replace(/\\r/g, "\r")
|
||||
.replace(/\\t/g, "\t");
|
||||
}
|
||||
|
||||
export function convertGeminiResponseToAnthropic(response: GenerateContentResponse): Anthropic.Messages.Message {
|
||||
const content: Anthropic.Messages.ContentBlock[] = []
|
||||
export function convertGeminiResponseToAnthropic(
|
||||
response: GenerateContentResponse,
|
||||
): Anthropic.Messages.Message {
|
||||
const content: Anthropic.Messages.ContentBlock[] = [];
|
||||
|
||||
const text = response.text
|
||||
const text = response.text;
|
||||
if (text) {
|
||||
content.push({ type: "text", text, citations: null })
|
||||
content.push({ type: "text", text, citations: null });
|
||||
}
|
||||
|
||||
let stop_reason: Anthropic.Messages.Message["stop_reason"] = null
|
||||
const finishReason = response.candidates?.[0]?.finishReason
|
||||
let stop_reason: Anthropic.Messages.Message["stop_reason"] = null;
|
||||
const finishReason = response.candidates?.[0]?.finishReason;
|
||||
if (finishReason) {
|
||||
switch (finishReason) {
|
||||
case "STOP":
|
||||
stop_reason = "end_turn"
|
||||
break
|
||||
stop_reason = "end_turn";
|
||||
break;
|
||||
case "MAX_TOKENS":
|
||||
stop_reason = "max_tokens"
|
||||
break
|
||||
stop_reason = "max_tokens";
|
||||
break;
|
||||
case "SAFETY":
|
||||
case "RECITATION":
|
||||
case "OTHER":
|
||||
stop_reason = "stop_sequence"
|
||||
break
|
||||
stop_reason = "stop_sequence";
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -115,5 +126,5 @@ export function convertGeminiResponseToAnthropic(response: GenerateContentRespon
|
||||
cache_read_input_tokens: null,
|
||||
server_tool_use: null,
|
||||
},
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,30 +1,32 @@
|
||||
import { Anthropic } from "@anthropic-ai/sdk"
|
||||
import { AssistantMessage } from "@mistralai/mistralai/models/components/assistantmessage"
|
||||
import { SystemMessage } from "@mistralai/mistralai/models/components/systemmessage"
|
||||
import { ToolMessage } from "@mistralai/mistralai/models/components/toolmessage"
|
||||
import { UserMessage } from "@mistralai/mistralai/models/components/usermessage"
|
||||
import { getImageDataUrl } from "@/shared/messages/content"
|
||||
import type { Anthropic } from "@anthropic-ai/sdk";
|
||||
import type { AssistantMessage } from "@mistralai/mistralai/models/components/assistantmessage";
|
||||
import type { SystemMessage } from "@mistralai/mistralai/models/components/systemmessage";
|
||||
import type { ToolMessage } from "@mistralai/mistralai/models/components/toolmessage";
|
||||
import type { UserMessage } from "@mistralai/mistralai/models/components/usermessage";
|
||||
import { getImageDataUrl } from "@/shared/messages/content";
|
||||
|
||||
export type MistralMessage =
|
||||
| (SystemMessage & { role: "system" })
|
||||
| (UserMessage & { role: "user" })
|
||||
| (AssistantMessage & { role: "assistant" })
|
||||
| (ToolMessage & { role: "tool" })
|
||||
| (ToolMessage & { role: "tool" });
|
||||
|
||||
export function convertToMistralMessages(anthropicMessages: Anthropic.Messages.MessageParam[]): MistralMessage[] {
|
||||
const mistralMessages: MistralMessage[] = []
|
||||
export function convertToMistralMessages(
|
||||
anthropicMessages: Anthropic.Messages.MessageParam[],
|
||||
): MistralMessage[] {
|
||||
const mistralMessages: MistralMessage[] = [];
|
||||
for (const anthropicMessage of anthropicMessages) {
|
||||
if (typeof anthropicMessage.content === "string") {
|
||||
mistralMessages.push({
|
||||
role: anthropicMessage.role,
|
||||
content: anthropicMessage.content,
|
||||
})
|
||||
});
|
||||
} else {
|
||||
if (anthropicMessage.role === "user") {
|
||||
// Filter to only include text and image blocks
|
||||
const textAndImageBlocks = anthropicMessage.content.filter(
|
||||
(part) => part.type === "text" || part.type === "image",
|
||||
)
|
||||
);
|
||||
|
||||
if (textAndImageBlocks.length > 0) {
|
||||
mistralMessages.push({
|
||||
@@ -36,27 +38,29 @@ export function convertToMistralMessages(anthropicMessages: Anthropic.Messages.M
|
||||
imageUrl: {
|
||||
url: getImageDataUrl(part.source),
|
||||
},
|
||||
}
|
||||
};
|
||||
}
|
||||
return { type: "text", text: part.text }
|
||||
return { type: "text", text: part.text };
|
||||
}),
|
||||
})
|
||||
});
|
||||
}
|
||||
} else if (anthropicMessage.role === "assistant") {
|
||||
// Only process text blocks - assistant cannot send images or other content types in Mistral's API format
|
||||
const textBlocks = anthropicMessage.content.filter((part) => part.type === "text")
|
||||
const textBlocks = anthropicMessage.content.filter(
|
||||
(part) => part.type === "text",
|
||||
);
|
||||
|
||||
if (textBlocks.length > 0) {
|
||||
const content = textBlocks.map((part) => part.text).join("\n")
|
||||
const content = textBlocks.map((part) => part.text).join("\n");
|
||||
|
||||
mistralMessages.push({
|
||||
role: "assistant",
|
||||
content,
|
||||
})
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return mistralMessages
|
||||
return mistralMessages;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Anthropic } from "@anthropic-ai/sdk"
|
||||
import OpenAI from "openai"
|
||||
import { Logger } from "@/shared/services/Logger"
|
||||
import type { Anthropic } from "@anthropic-ai/sdk";
|
||||
import type OpenAI from "openai";
|
||||
import { Logger } from "@/shared/services/Logger";
|
||||
|
||||
const o1SystemPrompt = (systemPrompt: string) => `
|
||||
# System Prompt
|
||||
@@ -164,7 +164,7 @@ I've analyzed the project structure, but I need more information to proceed. Let
|
||||
<ask_followup_question>
|
||||
<question>Which specific feature would you like me to implement in the example.py file?</question>
|
||||
</ask_followup_question>
|
||||
`
|
||||
`;
|
||||
|
||||
export function convertToO1Messages(
|
||||
openAiMessages: OpenAI.Chat.ChatCompletionMessageParam[],
|
||||
@@ -176,26 +176,26 @@ export function convertToO1Messages(
|
||||
acc.push({
|
||||
role: "user",
|
||||
content: message.content || "",
|
||||
})
|
||||
});
|
||||
} else if (message.role === "assistant" && message.tool_calls) {
|
||||
// Convert tool calls to content and remove tool_calls
|
||||
let content = message.content || ""
|
||||
let content = message.content || "";
|
||||
message.tool_calls.forEach((toolCall) => {
|
||||
if (toolCall.type === "function") {
|
||||
content += `\nTool Call: ${toolCall.function.name}\nArguments: ${toolCall.function.arguments}`
|
||||
content += `\nTool Call: ${toolCall.function.name}\nArguments: ${toolCall.function.arguments}`;
|
||||
}
|
||||
})
|
||||
});
|
||||
acc.push({
|
||||
role: "assistant",
|
||||
content: content,
|
||||
tool_calls: undefined,
|
||||
})
|
||||
});
|
||||
} else {
|
||||
// Keep other messages as they are
|
||||
acc.push(message)
|
||||
acc.push(message);
|
||||
}
|
||||
return acc
|
||||
}, [] as OpenAI.Chat.ChatCompletionMessageParam[])
|
||||
return acc;
|
||||
}, [] as OpenAI.Chat.ChatCompletionMessageParam[]);
|
||||
|
||||
// Find the index of the last assistant message
|
||||
// const lastAssistantIndex = findLastIndex(toolsReplaced, (message) => message.role === "assistant")
|
||||
@@ -207,7 +207,7 @@ export function convertToO1Messages(
|
||||
content: o1SystemPrompt(systemPrompt),
|
||||
} as OpenAI.Chat.ChatCompletionUserMessageParam,
|
||||
...toolsReplaced,
|
||||
]
|
||||
];
|
||||
|
||||
// If there's an assistant message, insert the system prompt after it
|
||||
// if (lastAssistantIndex !== -1) {
|
||||
@@ -226,12 +226,12 @@ export function convertToO1Messages(
|
||||
// })
|
||||
// }
|
||||
|
||||
return messagesWithSystemPrompt
|
||||
return messagesWithSystemPrompt;
|
||||
}
|
||||
|
||||
interface ToolCall {
|
||||
tool: string
|
||||
tool_input: Record<string, string>
|
||||
tool: string;
|
||||
tool_input: Record<string, string>;
|
||||
}
|
||||
|
||||
const toolNames = [
|
||||
@@ -243,106 +243,116 @@ const toolNames = [
|
||||
"write_to_file",
|
||||
"ask_followup_question",
|
||||
"attempt_completion",
|
||||
]
|
||||
];
|
||||
|
||||
function parseAIResponse(response: string): {
|
||||
normalText: string
|
||||
toolCalls: ToolCall[]
|
||||
normalText: string;
|
||||
toolCalls: ToolCall[];
|
||||
} {
|
||||
// Create a regex pattern to match any tool call opening tag
|
||||
const toolCallPattern = new RegExp(`<(${toolNames.join("|")})`, "i")
|
||||
const match = response.match(toolCallPattern)
|
||||
const toolCallPattern = new RegExp(`<(${toolNames.join("|")})`, "i");
|
||||
const match = response.match(toolCallPattern);
|
||||
|
||||
if (!match) {
|
||||
// No tool calls found
|
||||
return { normalText: response.trim(), toolCalls: [] }
|
||||
return { normalText: response.trim(), toolCalls: [] };
|
||||
}
|
||||
|
||||
const toolCallStart = match.index!
|
||||
const normalText = response.slice(0, toolCallStart).trim()
|
||||
const toolCallsText = response.slice(toolCallStart)
|
||||
const toolCallStart = match.index!;
|
||||
const normalText = response.slice(0, toolCallStart).trim();
|
||||
const toolCallsText = response.slice(toolCallStart);
|
||||
|
||||
const toolCalls = parseToolCalls(toolCallsText)
|
||||
const toolCalls = parseToolCalls(toolCallsText);
|
||||
|
||||
return { normalText, toolCalls }
|
||||
return { normalText, toolCalls };
|
||||
}
|
||||
|
||||
function parseToolCalls(toolCallsText: string): ToolCall[] {
|
||||
const toolCalls: ToolCall[] = []
|
||||
const toolCalls: ToolCall[] = [];
|
||||
|
||||
let remainingText = toolCallsText
|
||||
let remainingText = toolCallsText;
|
||||
|
||||
while (remainingText.length > 0) {
|
||||
const toolMatch = toolNames.find((tool) => new RegExp(`<${tool}`, "i").test(remainingText))
|
||||
const toolMatch = toolNames.find((tool) =>
|
||||
new RegExp(`<${tool}`, "i").test(remainingText),
|
||||
);
|
||||
|
||||
if (!toolMatch) {
|
||||
break // No more tool calls found
|
||||
break; // No more tool calls found
|
||||
}
|
||||
|
||||
const startTag = `<${toolMatch}`
|
||||
const endTag = `</${toolMatch}>`
|
||||
const startIndex = remainingText.indexOf(startTag)
|
||||
const endIndex = remainingText.indexOf(endTag, startIndex)
|
||||
const startTag = `<${toolMatch}`;
|
||||
const endTag = `</${toolMatch}>`;
|
||||
const startIndex = remainingText.indexOf(startTag);
|
||||
const endIndex = remainingText.indexOf(endTag, startIndex);
|
||||
|
||||
if (endIndex === -1) {
|
||||
break // Malformed XML, no closing tag found
|
||||
break; // Malformed XML, no closing tag found
|
||||
}
|
||||
|
||||
const toolCallContent = remainingText.slice(startIndex, endIndex + endTag.length)
|
||||
remainingText = remainingText.slice(endIndex + endTag.length).trim()
|
||||
const toolCallContent = remainingText.slice(
|
||||
startIndex,
|
||||
endIndex + endTag.length,
|
||||
);
|
||||
remainingText = remainingText.slice(endIndex + endTag.length).trim();
|
||||
|
||||
const toolCall = parseToolCall(toolMatch, toolCallContent)
|
||||
const toolCall = parseToolCall(toolMatch, toolCallContent);
|
||||
if (toolCall) {
|
||||
toolCalls.push(toolCall)
|
||||
toolCalls.push(toolCall);
|
||||
}
|
||||
}
|
||||
|
||||
return toolCalls
|
||||
return toolCalls;
|
||||
}
|
||||
|
||||
function parseToolCall(toolName: string, content: string): ToolCall | null {
|
||||
const tool_input: Record<string, string> = {}
|
||||
const tool_input: Record<string, string> = {};
|
||||
|
||||
// Remove the outer tool tags
|
||||
const innerContent = content.replace(new RegExp(`^<${toolName}>|</${toolName}>$`, "g"), "").trim()
|
||||
const innerContent = content
|
||||
.replace(new RegExp(`^<${toolName}>|</${toolName}>$`, "g"), "")
|
||||
.trim();
|
||||
|
||||
// Parse nested XML elements
|
||||
const paramRegex = /<(\w+)>([\s\S]*?)<\/\1>/gs
|
||||
let match: RegExpExecArray | null
|
||||
const paramRegex = /<(\w+)>([\s\S]*?)<\/\1>/gs;
|
||||
let match: RegExpExecArray | null;
|
||||
|
||||
while ((match = paramRegex.exec(innerContent)) !== null) {
|
||||
const [, paramName, paramValue] = match
|
||||
const [, paramName, paramValue] = match;
|
||||
// Preserve newlines and trim only leading/trailing whitespace
|
||||
tool_input[paramName] = paramValue.replace(/^\s+|\s+$/g, "")
|
||||
tool_input[paramName] = paramValue.replace(/^\s+|\s+$/g, "");
|
||||
}
|
||||
|
||||
// Validate required parameters
|
||||
if (!validateToolInput(toolName, tool_input)) {
|
||||
Logger.error(`Invalid tool call for ${toolName}:`, content)
|
||||
return null
|
||||
Logger.error(`Invalid tool call for ${toolName}:`, content);
|
||||
return null;
|
||||
}
|
||||
|
||||
return { tool: toolName, tool_input }
|
||||
return { tool: toolName, tool_input };
|
||||
}
|
||||
|
||||
function validateToolInput(toolName: string, tool_input: Record<string, string>): boolean {
|
||||
function validateToolInput(
|
||||
toolName: string,
|
||||
tool_input: Record<string, string>,
|
||||
): boolean {
|
||||
switch (toolName) {
|
||||
case "execute_command":
|
||||
return "command" in tool_input
|
||||
return "command" in tool_input;
|
||||
case "read_file":
|
||||
case "list_code_definition_names":
|
||||
case "list_files":
|
||||
return "path" in tool_input
|
||||
return "path" in tool_input;
|
||||
case "search_files":
|
||||
return "path" in tool_input && "regex" in tool_input
|
||||
return "path" in tool_input && "regex" in tool_input;
|
||||
case "write_to_file":
|
||||
return "path" in tool_input && "content" in tool_input
|
||||
return "path" in tool_input && "content" in tool_input;
|
||||
case "ask_followup_question":
|
||||
return "question" in tool_input
|
||||
return "question" in tool_input;
|
||||
case "attempt_completion":
|
||||
return "result" in tool_input
|
||||
return "result" in tool_input;
|
||||
default:
|
||||
return false
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -366,8 +376,10 @@ function validateToolInput(toolName: string, tool_input: Record<string, string>)
|
||||
export function convertO1ResponseToAnthropicMessage(
|
||||
completion: OpenAI.Chat.Completions.ChatCompletion,
|
||||
): Anthropic.Messages.Message {
|
||||
const openAiMessage = completion.choices[0].message
|
||||
const { normalText, toolCalls } = parseAIResponse(openAiMessage.content || "")
|
||||
const openAiMessage = completion.choices[0].message;
|
||||
const { normalText, toolCalls } = parseAIResponse(
|
||||
openAiMessage.content || "",
|
||||
);
|
||||
|
||||
const anthropicMessage: Anthropic.Messages.Message = {
|
||||
id: completion.id,
|
||||
@@ -384,14 +396,14 @@ export function convertO1ResponseToAnthropicMessage(
|
||||
stop_reason: (() => {
|
||||
switch (completion.choices[0].finish_reason) {
|
||||
case "stop":
|
||||
return "end_turn"
|
||||
return "end_turn";
|
||||
case "length":
|
||||
return "max_tokens"
|
||||
return "max_tokens";
|
||||
case "tool_calls":
|
||||
return "tool_use"
|
||||
return "tool_use";
|
||||
case "content_filter": // Anthropic doesn't have an exact equivalent
|
||||
default:
|
||||
return null
|
||||
return null;
|
||||
}
|
||||
})(),
|
||||
stop_sequence: null, // which custom stop_sequence was generated, if any (not applicable if you don't use stop_sequence)
|
||||
@@ -402,22 +414,24 @@ export function convertO1ResponseToAnthropicMessage(
|
||||
cache_read_input_tokens: null,
|
||||
server_tool_use: null,
|
||||
},
|
||||
}
|
||||
};
|
||||
|
||||
if (toolCalls.length > 0) {
|
||||
anthropicMessage.content.push(
|
||||
...toolCalls.map((toolCall: ToolCall, index: number): Anthropic.ToolUseBlock => {
|
||||
return {
|
||||
type: "tool_use",
|
||||
id: `call_${index}_${Date.now()}`, // Generate a unique ID for each tool call
|
||||
name: toolCall.tool,
|
||||
input: toolCall.tool_input,
|
||||
}
|
||||
}),
|
||||
)
|
||||
...toolCalls.map(
|
||||
(toolCall: ToolCall, index: number): Anthropic.ToolUseBlock => {
|
||||
return {
|
||||
type: "tool_use",
|
||||
id: `call_${index}_${Date.now()}`, // Generate a unique ID for each tool call
|
||||
name: toolCall.tool,
|
||||
input: toolCall.tool_input,
|
||||
};
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return anthropicMessage
|
||||
return anthropicMessage;
|
||||
}
|
||||
|
||||
// Example usage:
|
||||
|
||||
@@ -1,65 +1,68 @@
|
||||
import { Message } from "ollama"
|
||||
import type { Message } from "ollama";
|
||||
import {
|
||||
ClineAssistantToolUseBlock,
|
||||
ClineImageContentBlock,
|
||||
ClineStorageMessage,
|
||||
ClineTextContentBlock,
|
||||
ClineUserToolResultContentBlock,
|
||||
type ClineAssistantToolUseBlock,
|
||||
type ClineImageContentBlock,
|
||||
type ClineStorageMessage,
|
||||
type ClineTextContentBlock,
|
||||
type ClineUserToolResultContentBlock,
|
||||
getImageDataUrl,
|
||||
} from "@/shared/messages/content"
|
||||
} from "@/shared/messages/content";
|
||||
|
||||
export function convertToOllamaMessages(anthropicMessages: Omit<ClineStorageMessage, "modelInfo">[]): Message[] {
|
||||
const ollamaMessages: Message[] = []
|
||||
export function convertToOllamaMessages(
|
||||
anthropicMessages: Omit<ClineStorageMessage, "modelInfo">[],
|
||||
): Message[] {
|
||||
const ollamaMessages: Message[] = [];
|
||||
|
||||
for (const anthropicMessage of anthropicMessages) {
|
||||
if (typeof anthropicMessage.content === "string") {
|
||||
ollamaMessages.push({
|
||||
role: anthropicMessage.role,
|
||||
content: anthropicMessage.content,
|
||||
})
|
||||
});
|
||||
} else {
|
||||
if (anthropicMessage.role === "user") {
|
||||
const { nonToolMessages, toolMessages } = anthropicMessage.content.reduce<{
|
||||
nonToolMessages: (ClineTextContentBlock | ClineImageContentBlock)[]
|
||||
toolMessages: ClineUserToolResultContentBlock[]
|
||||
}>(
|
||||
(acc, part) => {
|
||||
if (part.type === "tool_result") {
|
||||
acc.toolMessages.push(part)
|
||||
} else if (part.type === "text" || part.type === "image") {
|
||||
acc.nonToolMessages.push(part)
|
||||
}
|
||||
return acc
|
||||
},
|
||||
{ nonToolMessages: [], toolMessages: [] },
|
||||
)
|
||||
const { nonToolMessages, toolMessages } =
|
||||
anthropicMessage.content.reduce<{
|
||||
nonToolMessages: (ClineTextContentBlock | ClineImageContentBlock)[];
|
||||
toolMessages: ClineUserToolResultContentBlock[];
|
||||
}>(
|
||||
(acc, part) => {
|
||||
if (part.type === "tool_result") {
|
||||
acc.toolMessages.push(part);
|
||||
} else if (part.type === "text" || part.type === "image") {
|
||||
acc.nonToolMessages.push(part);
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
{ nonToolMessages: [], toolMessages: [] },
|
||||
);
|
||||
|
||||
// Process tool result messages FIRST since they must follow the tool use messages
|
||||
const toolResultImages: string[] = []
|
||||
const toolResultImages: string[] = [];
|
||||
toolMessages.forEach((toolMessage) => {
|
||||
// The Anthropic SDK allows tool results to be a string or an array of text and image blocks, enabling rich and structured content. In contrast, the Ollama SDK only supports tool results as a single string, so we map the Anthropic tool result parts into one concatenated string to maintain compatibility.
|
||||
let content: string
|
||||
let content: string;
|
||||
|
||||
if (typeof toolMessage.content === "string") {
|
||||
content = toolMessage.content
|
||||
content = toolMessage.content;
|
||||
} else {
|
||||
content =
|
||||
toolMessage.content
|
||||
?.map((part) => {
|
||||
if (part.type === "image") {
|
||||
toolResultImages.push(getImageDataUrl(part.source))
|
||||
return "(see following user message for image)"
|
||||
toolResultImages.push(getImageDataUrl(part.source));
|
||||
return "(see following user message for image)";
|
||||
}
|
||||
return part.text
|
||||
return part.text;
|
||||
})
|
||||
.join("\n") ?? ""
|
||||
.join("\n") ?? "";
|
||||
}
|
||||
ollamaMessages.push({
|
||||
role: "user",
|
||||
images: toolResultImages.length > 0 ? toolResultImages : undefined,
|
||||
content: content,
|
||||
})
|
||||
})
|
||||
});
|
||||
});
|
||||
|
||||
// Process non-tool messages
|
||||
if (nonToolMessages.length > 0) {
|
||||
@@ -68,49 +71,50 @@ export function convertToOllamaMessages(anthropicMessages: Omit<ClineStorageMess
|
||||
content: nonToolMessages
|
||||
.map((part) => {
|
||||
if (part.type === "image") {
|
||||
return getImageDataUrl(part.source)
|
||||
return getImageDataUrl(part.source);
|
||||
}
|
||||
return part.text
|
||||
return part.text;
|
||||
})
|
||||
.join("\n"),
|
||||
})
|
||||
});
|
||||
}
|
||||
} else if (anthropicMessage.role === "assistant") {
|
||||
const { nonToolMessages, toolMessages } = anthropicMessage.content.reduce<{
|
||||
nonToolMessages: (ClineTextContentBlock | ClineImageContentBlock)[]
|
||||
toolMessages: ClineAssistantToolUseBlock[]
|
||||
}>(
|
||||
(acc, part) => {
|
||||
if (part.type === "tool_use") {
|
||||
acc.toolMessages.push(part)
|
||||
} else if (part.type === "text" || part.type === "image") {
|
||||
acc.nonToolMessages.push(part)
|
||||
} // assistant cannot send tool_result messages
|
||||
return acc
|
||||
},
|
||||
{ nonToolMessages: [], toolMessages: [] },
|
||||
)
|
||||
const { nonToolMessages, toolMessages } =
|
||||
anthropicMessage.content.reduce<{
|
||||
nonToolMessages: (ClineTextContentBlock | ClineImageContentBlock)[];
|
||||
toolMessages: ClineAssistantToolUseBlock[];
|
||||
}>(
|
||||
(acc, part) => {
|
||||
if (part.type === "tool_use") {
|
||||
acc.toolMessages.push(part);
|
||||
} else if (part.type === "text" || part.type === "image") {
|
||||
acc.nonToolMessages.push(part);
|
||||
} // assistant cannot send tool_result messages
|
||||
return acc;
|
||||
},
|
||||
{ nonToolMessages: [], toolMessages: [] },
|
||||
);
|
||||
|
||||
// Process non-tool messages
|
||||
let content: string = ""
|
||||
let content: string = "";
|
||||
if (nonToolMessages.length > 0) {
|
||||
content = nonToolMessages
|
||||
.map((part) => {
|
||||
if (part.type === "image") {
|
||||
return "" // impossible as the assistant cannot send images
|
||||
return ""; // impossible as the assistant cannot send images
|
||||
}
|
||||
return part.text
|
||||
return part.text;
|
||||
})
|
||||
.join("\n")
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
ollamaMessages.push({
|
||||
role: "assistant",
|
||||
content,
|
||||
})
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ollamaMessages
|
||||
return ollamaMessages;
|
||||
}
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
import { Anthropic } from "@anthropic-ai/sdk"
|
||||
import OpenAI from "openai"
|
||||
import { ApiProvider } from "@/shared/api"
|
||||
import type { Anthropic } from "@anthropic-ai/sdk";
|
||||
import type OpenAI from "openai";
|
||||
import type { ApiProvider } from "@/shared/api";
|
||||
import {
|
||||
ClineAssistantRedactedThinkingBlock,
|
||||
ClineAssistantThinkingBlock,
|
||||
ClineAssistantToolUseBlock,
|
||||
ClineImageContentBlock,
|
||||
ClineTextContentBlock,
|
||||
ClineUserToolResultContentBlock,
|
||||
type ClineAssistantRedactedThinkingBlock,
|
||||
type ClineAssistantThinkingBlock,
|
||||
type ClineAssistantToolUseBlock,
|
||||
type ClineImageContentBlock,
|
||||
type ClineTextContentBlock,
|
||||
type ClineUserToolResultContentBlock,
|
||||
getImageDataUrl,
|
||||
} from "@/shared/messages/content"
|
||||
import { Logger } from "@/shared/services/Logger"
|
||||
} from "@/shared/messages/content";
|
||||
import { Logger } from "@/shared/services/Logger";
|
||||
|
||||
// OpenAI API has a maximum tool call ID length of 40 characters
|
||||
const MAX_TOOL_CALL_ID_LENGTH = 40
|
||||
const MAX_TOOL_CALL_ID_LENGTH = 40;
|
||||
|
||||
/**
|
||||
* Determines if a given tool ID follows the OpenAI Responses API format for tool calls.
|
||||
@@ -23,7 +23,7 @@ const MAX_TOOL_CALL_ID_LENGTH = 40
|
||||
* @returns True if the tool ID matches the OpenAI Responses API format, false otherwise
|
||||
*/
|
||||
function isOpenAIResponseToolId(callId: string): boolean {
|
||||
return callId.startsWith("fc_") && callId.length === 53
|
||||
return callId.startsWith("fc_") && callId.length === 53;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -37,21 +37,24 @@ function isOpenAIResponseToolId(callId: string): boolean {
|
||||
* @param provider - The API provider that the OpenAI formatted messages will be sent to
|
||||
* @returns The transformed ID suitable for OpenAI API
|
||||
*/
|
||||
function transformToolCallIdForNativeApi(toolId: string, provider?: ApiProvider): string {
|
||||
function transformToolCallIdForNativeApi(
|
||||
toolId: string,
|
||||
provider?: ApiProvider,
|
||||
): string {
|
||||
// OpenAI Responses API uses "fc_" prefix with 53 char length
|
||||
// Convert these to "call_" prefix format for Chat Completions API
|
||||
if (isOpenAIResponseToolId(toolId)) {
|
||||
// Use the last 33 chars + "call_" (5 chars) to stay under the 40-char limit.
|
||||
return `call_${toolId.slice(toolId.length - (MAX_TOOL_CALL_ID_LENGTH - 5))}`
|
||||
return `call_${toolId.slice(toolId.length - (MAX_TOOL_CALL_ID_LENGTH - 5))}`;
|
||||
}
|
||||
if (provider !== "openai-native") {
|
||||
return toolId
|
||||
return toolId;
|
||||
}
|
||||
// Ensure ID doesn't exceed max length
|
||||
if (toolId.length > MAX_TOOL_CALL_ID_LENGTH) {
|
||||
return toolId.slice(0, MAX_TOOL_CALL_ID_LENGTH)
|
||||
return toolId.slice(0, MAX_TOOL_CALL_ID_LENGTH);
|
||||
}
|
||||
return toolId
|
||||
return toolId;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -68,14 +71,14 @@ export function convertToOpenAiMessages(
|
||||
anthropicMessages: Anthropic.Messages.MessageParam[],
|
||||
provider?: ApiProvider,
|
||||
): OpenAI.Chat.ChatCompletionMessageParam[] {
|
||||
const openAiMessages: OpenAI.Chat.ChatCompletionMessageParam[] = []
|
||||
const openAiMessages: OpenAI.Chat.ChatCompletionMessageParam[] = [];
|
||||
|
||||
for (const anthropicMessage of anthropicMessages) {
|
||||
if (typeof anthropicMessage.content === "string") {
|
||||
openAiMessages.push({
|
||||
role: anthropicMessage.role,
|
||||
content: anthropicMessage.content,
|
||||
})
|
||||
});
|
||||
} else {
|
||||
// image_url.url is base64 encoded image data
|
||||
// ensure it contains the content-type of the image: data:image/png;base64,
|
||||
@@ -86,52 +89,56 @@ export function convertToOpenAiMessages(
|
||||
{ role: "tool", tool_call_id: "", content: ""}
|
||||
*/
|
||||
if (anthropicMessage.role === "user") {
|
||||
const { nonToolMessages, toolMessages } = anthropicMessage.content.reduce<{
|
||||
nonToolMessages: (ClineTextContentBlock | ClineImageContentBlock)[]
|
||||
toolMessages: ClineUserToolResultContentBlock[]
|
||||
}>(
|
||||
(acc, part) => {
|
||||
if (part.type === "tool_result") {
|
||||
acc.toolMessages.push(part)
|
||||
} else if (part.type === "text" || part.type === "image") {
|
||||
acc.nonToolMessages.push(part)
|
||||
} // user cannot send tool_use messages
|
||||
return acc
|
||||
},
|
||||
{ nonToolMessages: [], toolMessages: [] },
|
||||
)
|
||||
const { nonToolMessages, toolMessages } =
|
||||
anthropicMessage.content.reduce<{
|
||||
nonToolMessages: (ClineTextContentBlock | ClineImageContentBlock)[];
|
||||
toolMessages: ClineUserToolResultContentBlock[];
|
||||
}>(
|
||||
(acc, part) => {
|
||||
if (part.type === "tool_result") {
|
||||
acc.toolMessages.push(part);
|
||||
} else if (part.type === "text" || part.type === "image") {
|
||||
acc.nonToolMessages.push(part);
|
||||
} // user cannot send tool_use messages
|
||||
return acc;
|
||||
},
|
||||
{ nonToolMessages: [], toolMessages: [] },
|
||||
);
|
||||
|
||||
// Process tool result messages FIRST since they must follow the tool use messages
|
||||
const toolResultImages: ClineImageContentBlock[] = []
|
||||
const toolResultImages: ClineImageContentBlock[] = [];
|
||||
toolMessages.forEach((toolMessage) => {
|
||||
// The Anthropic SDK allows tool results to be a string or an array of text and image blocks, enabling rich and structured content. In contrast, the OpenAI SDK only supports tool results as a single string, so we map the Anthropic tool result parts into one concatenated string to maintain compatibility.
|
||||
let content: string
|
||||
let content: string;
|
||||
|
||||
if (typeof toolMessage.content === "string") {
|
||||
content = toolMessage.content
|
||||
content = toolMessage.content;
|
||||
} else if (Array.isArray(toolMessage.content)) {
|
||||
content =
|
||||
toolMessage.content
|
||||
?.map((part) => {
|
||||
if (part.type === "image") {
|
||||
toolResultImages.push(part)
|
||||
return "(see following user message for image)"
|
||||
toolResultImages.push(part);
|
||||
return "(see following user message for image)";
|
||||
}
|
||||
return part.text
|
||||
return part.text;
|
||||
})
|
||||
.join("\n") ?? ""
|
||||
.join("\n") ?? "";
|
||||
} else {
|
||||
// Handle undefined content
|
||||
content = ""
|
||||
content = "";
|
||||
}
|
||||
openAiMessages.push({
|
||||
role: "tool",
|
||||
// The tool_call_id must match the id used in the assistant's tool_calls array.
|
||||
// Use the same transformation logic as tool_calls to ensure IDs match.
|
||||
tool_call_id: transformToolCallIdForNativeApi(toolMessage.tool_use_id, provider),
|
||||
tool_call_id: transformToolCallIdForNativeApi(
|
||||
toolMessage.tool_use_id,
|
||||
provider,
|
||||
),
|
||||
content: content,
|
||||
})
|
||||
})
|
||||
});
|
||||
});
|
||||
|
||||
// If tool results contain images, send as a separate user message
|
||||
// I ran into an issue where if I gave feedback for one of many tool uses, the request would fail.
|
||||
@@ -146,7 +153,7 @@ export function convertToOpenAiMessages(
|
||||
type: "image_url",
|
||||
image_url: { url: getImageDataUrl(part.source) },
|
||||
})),
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
// Process non-tool messages
|
||||
@@ -160,104 +167,115 @@ export function convertToOpenAiMessages(
|
||||
image_url: {
|
||||
url: getImageDataUrl(part.source),
|
||||
},
|
||||
}
|
||||
};
|
||||
}
|
||||
return { type: "text", text: part.text }
|
||||
return { type: "text", text: part.text };
|
||||
}),
|
||||
})
|
||||
});
|
||||
}
|
||||
} else if (anthropicMessage.role === "assistant") {
|
||||
const { nonToolMessages, toolMessages } = anthropicMessage.content.reduce<{
|
||||
nonToolMessages: (
|
||||
| ClineTextContentBlock
|
||||
| ClineImageContentBlock
|
||||
| ClineAssistantThinkingBlock
|
||||
| ClineAssistantRedactedThinkingBlock
|
||||
)[]
|
||||
toolMessages: ClineAssistantToolUseBlock[]
|
||||
}>(
|
||||
(acc, part) => {
|
||||
if (part.type === "tool_use") {
|
||||
acc.toolMessages.push(part)
|
||||
} else if (part.type === "text" || part.type === "image") {
|
||||
acc.nonToolMessages.push(part)
|
||||
} // assistant cannot send tool_result messages
|
||||
return acc
|
||||
},
|
||||
{ nonToolMessages: [], toolMessages: [] },
|
||||
)
|
||||
const { nonToolMessages, toolMessages } =
|
||||
anthropicMessage.content.reduce<{
|
||||
nonToolMessages: (
|
||||
| ClineTextContentBlock
|
||||
| ClineImageContentBlock
|
||||
| ClineAssistantThinkingBlock
|
||||
| ClineAssistantRedactedThinkingBlock
|
||||
)[];
|
||||
toolMessages: ClineAssistantToolUseBlock[];
|
||||
}>(
|
||||
(acc, part) => {
|
||||
if (part.type === "tool_use") {
|
||||
acc.toolMessages.push(part);
|
||||
} else if (part.type === "text" || part.type === "image") {
|
||||
acc.nonToolMessages.push(part);
|
||||
} // assistant cannot send tool_result messages
|
||||
return acc;
|
||||
},
|
||||
{ nonToolMessages: [], toolMessages: [] },
|
||||
);
|
||||
|
||||
// Process non-tool messages
|
||||
let content: string | undefined
|
||||
const reasoningDetails: any[] = []
|
||||
const thinkingBlock = []
|
||||
let content: string | undefined;
|
||||
const reasoningDetails: any[] = [];
|
||||
const thinkingBlock = [];
|
||||
if (nonToolMessages.length > 0) {
|
||||
nonToolMessages.forEach((part) => {
|
||||
const anyPart = part as any
|
||||
const anyPart = part as any;
|
||||
if (part.type === "text" && anyPart.reasoning_details) {
|
||||
if (Array.isArray(anyPart.reasoning_details)) {
|
||||
reasoningDetails.push(...anyPart.reasoning_details)
|
||||
reasoningDetails.push(...anyPart.reasoning_details);
|
||||
} else {
|
||||
reasoningDetails.push(anyPart.reasoning_details)
|
||||
reasoningDetails.push(anyPart.reasoning_details);
|
||||
}
|
||||
}
|
||||
if (part.type === "thinking" && part.thinking) {
|
||||
// Reasoning details should have been moved to the text block
|
||||
thinkingBlock.push(part)
|
||||
thinkingBlock.push(part);
|
||||
}
|
||||
})
|
||||
});
|
||||
content = nonToolMessages
|
||||
.map((part) => {
|
||||
if (part.type === "text" && part.text) {
|
||||
return part.text
|
||||
return part.text;
|
||||
}
|
||||
return ""
|
||||
return "";
|
||||
})
|
||||
.join("\n")
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
// Process tool use messages
|
||||
const tool_calls: OpenAI.Chat.ChatCompletionMessageToolCall[] = toolMessages.map((toolMessage) => {
|
||||
const toolDetails = toolMessage.reasoning_details
|
||||
const toolId = toolMessage.id
|
||||
if (toolDetails) {
|
||||
if (Array.isArray(toolDetails)) {
|
||||
// For Gemini: reasoning details must be linkable back to the tool call.
|
||||
// Sometimes OpenRouter/Gemini returns entries without `id`; those poison the next request.
|
||||
// Keep only entries with an id matching the tool call id.
|
||||
// See: https://github.com/cline/cline/issues/8214
|
||||
const validDetails = toolDetails.filter((detail: any) => detail?.id === toolId)
|
||||
if (validDetails.length > 0) {
|
||||
reasoningDetails.push(...validDetails)
|
||||
}
|
||||
} else {
|
||||
// Single reasoning detail - only include if it has matching id
|
||||
const detail = toolDetails as any
|
||||
if (detail?.id === toolId) {
|
||||
reasoningDetails.push(toolDetails)
|
||||
const tool_calls: OpenAI.Chat.ChatCompletionMessageToolCall[] =
|
||||
toolMessages.map((toolMessage) => {
|
||||
const toolDetails = toolMessage.reasoning_details;
|
||||
const toolId = toolMessage.id;
|
||||
if (toolDetails) {
|
||||
if (Array.isArray(toolDetails)) {
|
||||
// For Gemini: reasoning details must be linkable back to the tool call.
|
||||
// Sometimes OpenRouter/Gemini returns entries without `id`; those poison the next request.
|
||||
// Keep only entries with an id matching the tool call id.
|
||||
// See: https://github.com/cline/cline/issues/8214
|
||||
const validDetails = toolDetails.filter(
|
||||
(detail: any) => detail?.id === toolId,
|
||||
);
|
||||
if (validDetails.length > 0) {
|
||||
reasoningDetails.push(...validDetails);
|
||||
}
|
||||
} else {
|
||||
// Single reasoning detail - only include if it has matching id
|
||||
const detail = toolDetails as any;
|
||||
if (detail?.id === toolId) {
|
||||
reasoningDetails.push(toolDetails);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
// Use the same transformation as tool_call_id to ensure IDs match
|
||||
id: transformToolCallIdForNativeApi(toolId, provider),
|
||||
type: "function",
|
||||
function: {
|
||||
name: toolMessage.name,
|
||||
// json string
|
||||
arguments: JSON.stringify(toolMessage.input),
|
||||
},
|
||||
}
|
||||
})
|
||||
return {
|
||||
// Use the same transformation as tool_call_id to ensure IDs match
|
||||
id: transformToolCallIdForNativeApi(toolId, provider),
|
||||
type: "function",
|
||||
function: {
|
||||
name: toolMessage.name,
|
||||
// json string
|
||||
arguments: JSON.stringify(toolMessage.input),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
// Set content to blank when tool_calls are present but content has no text, per OpenAI API spec
|
||||
const hasToolCalls = tool_calls.length > 0
|
||||
const hasMeaningfulContent = content !== undefined && content.trim() !== ""
|
||||
const finalContent = hasMeaningfulContent ? content : hasToolCalls ? null : undefined
|
||||
const hasToolCalls = tool_calls.length > 0;
|
||||
const hasMeaningfulContent =
|
||||
content !== undefined && content.trim() !== "";
|
||||
const finalContent = hasMeaningfulContent
|
||||
? content
|
||||
: hasToolCalls
|
||||
? null
|
||||
: undefined;
|
||||
|
||||
const consolidatedReasoningDetails =
|
||||
reasoningDetails.length > 0 ? consolidateReasoningDetails(reasoningDetails as any) : []
|
||||
reasoningDetails.length > 0
|
||||
? consolidateReasoningDetails(reasoningDetails as any)
|
||||
: [];
|
||||
|
||||
openAiMessages.push({
|
||||
role: "assistant",
|
||||
@@ -266,86 +284,91 @@ export function convertToOpenAiMessages(
|
||||
tool_calls: tool_calls?.length > 0 ? tool_calls : undefined,
|
||||
// Only include reasoning_details when non-empty; sending [] can trigger provider validation issues.
|
||||
// @ts-expect-error
|
||||
reasoning_details: consolidatedReasoningDetails.length > 0 ? consolidatedReasoningDetails : undefined,
|
||||
})
|
||||
reasoning_details:
|
||||
consolidatedReasoningDetails.length > 0
|
||||
? consolidatedReasoningDetails
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return openAiMessages
|
||||
return openAiMessages;
|
||||
}
|
||||
|
||||
// Type for OpenRouter's reasoning detail elements
|
||||
// https://openrouter.ai/docs/use-cases/reasoning-tokens#streaming-response
|
||||
type ReasoningDetail = {
|
||||
// https://openrouter.ai/docs/use-cases/reasoning-tokens#reasoning-detail-types
|
||||
type: string // "reasoning.summary" | "reasoning.encrypted" | "reasoning.text"
|
||||
text?: string
|
||||
data?: string // Encrypted reasoning data
|
||||
signature?: string | null
|
||||
id?: string | null // Unique identifier for the reasoning detail
|
||||
type: string; // "reasoning.summary" | "reasoning.encrypted" | "reasoning.text"
|
||||
text?: string;
|
||||
data?: string; // Encrypted reasoning data
|
||||
signature?: string | null;
|
||||
id?: string | null; // Unique identifier for the reasoning detail
|
||||
/*
|
||||
The format of the reasoning detail, with possible values:
|
||||
"unknown" - Format is not specified
|
||||
"openai-responses-v1" - OpenAI responses format version 1
|
||||
"anthropic-claude-v1" - Anthropic Claude format version 1 (default)
|
||||
*/
|
||||
format: string //"unknown" | "openai-responses-v1" | "anthropic-claude-v1" | "xai-responses-v1"
|
||||
index?: number // Sequential index of the reasoning detail
|
||||
}
|
||||
format: string; //"unknown" | "openai-responses-v1" | "anthropic-claude-v1" | "xai-responses-v1"
|
||||
index?: number; // Sequential index of the reasoning detail
|
||||
};
|
||||
|
||||
// Helper function to convert reasoning_details array to the format OpenRouter API expects
|
||||
// Takes an array of reasoning detail objects and consolidates them by index
|
||||
function consolidateReasoningDetails(reasoningDetails: ReasoningDetail[]): ReasoningDetail[] {
|
||||
function consolidateReasoningDetails(
|
||||
reasoningDetails: ReasoningDetail[],
|
||||
): ReasoningDetail[] {
|
||||
if (!reasoningDetails || reasoningDetails.length === 0) {
|
||||
return []
|
||||
return [];
|
||||
}
|
||||
|
||||
// Group by index
|
||||
const groupedByIndex = new Map<number, ReasoningDetail[]>()
|
||||
const groupedByIndex = new Map<number, ReasoningDetail[]>();
|
||||
|
||||
for (const detail of reasoningDetails) {
|
||||
// Drop corrupted encrypted reasoning blocks that would otherwise trigger:
|
||||
// "Invalid input: expected string, received undefined" for reasoning_details.*.data
|
||||
// See: https://github.com/cline/cline/issues/8214
|
||||
if (detail.type === "reasoning.encrypted" && !detail.data) continue
|
||||
if (detail.type === "reasoning.encrypted" && !detail.data) continue;
|
||||
|
||||
const index = detail.index ?? 0
|
||||
const index = detail.index ?? 0;
|
||||
if (!groupedByIndex.has(index)) {
|
||||
groupedByIndex.set(index, [])
|
||||
groupedByIndex.set(index, []);
|
||||
}
|
||||
groupedByIndex.get(index)!.push(detail)
|
||||
groupedByIndex.get(index)!.push(detail);
|
||||
}
|
||||
|
||||
// Consolidate each group
|
||||
const consolidated: ReasoningDetail[] = []
|
||||
const consolidated: ReasoningDetail[] = [];
|
||||
|
||||
for (const [index, details] of groupedByIndex.entries()) {
|
||||
// Concatenate all text parts
|
||||
let concatenatedText = ""
|
||||
let signature: string | undefined
|
||||
let id: string | undefined
|
||||
let format = "unknown"
|
||||
let type = "reasoning.text"
|
||||
let concatenatedText = "";
|
||||
let signature: string | undefined;
|
||||
let id: string | undefined;
|
||||
let format = "unknown";
|
||||
let type = "reasoning.text";
|
||||
|
||||
for (const detail of details) {
|
||||
if (detail.text) {
|
||||
concatenatedText += detail.text
|
||||
concatenatedText += detail.text;
|
||||
}
|
||||
// Keep the signature from the last item that has one
|
||||
if (detail.signature) {
|
||||
signature = detail.signature
|
||||
signature = detail.signature;
|
||||
}
|
||||
// Keep the id from the last item that has one
|
||||
if (detail.id) {
|
||||
id = detail.id
|
||||
id = detail.id;
|
||||
}
|
||||
// Keep format and type from any item (they should all be the same)
|
||||
if (detail.format) {
|
||||
format = detail.format
|
||||
format = detail.format;
|
||||
}
|
||||
if (detail.type) {
|
||||
type = detail.type
|
||||
type = detail.type;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -358,12 +381,12 @@ function consolidateReasoningDetails(reasoningDetails: ReasoningDetail[]): Reaso
|
||||
id: id,
|
||||
format: format,
|
||||
index: index,
|
||||
}
|
||||
consolidated.push(consolidatedEntry)
|
||||
};
|
||||
consolidated.push(consolidatedEntry);
|
||||
}
|
||||
|
||||
// For encrypted chunks (data), only keep the last one
|
||||
let lastDataEntry: ReasoningDetail | undefined
|
||||
let lastDataEntry: ReasoningDetail | undefined;
|
||||
for (const detail of details) {
|
||||
if (detail.data) {
|
||||
lastDataEntry = {
|
||||
@@ -373,23 +396,25 @@ function consolidateReasoningDetails(reasoningDetails: ReasoningDetail[]): Reaso
|
||||
id: detail.id,
|
||||
format: detail.format,
|
||||
index: index,
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
if (lastDataEntry) {
|
||||
consolidated.push(lastDataEntry)
|
||||
consolidated.push(lastDataEntry);
|
||||
}
|
||||
}
|
||||
|
||||
return consolidated
|
||||
return consolidated;
|
||||
}
|
||||
|
||||
// Unique name to use to filter out tool call that cannot be parsed correctly
|
||||
const UNIQUE_ERROR_TOOL_NAME = "_cline_error_unknown_function_"
|
||||
const UNIQUE_ERROR_TOOL_NAME = "_cline_error_unknown_function_";
|
||||
|
||||
// Convert OpenAI response to Anthropic format
|
||||
export function convertToAnthropicMessage(completion: OpenAI.Chat.Completions.ChatCompletion): Anthropic.Messages.Message {
|
||||
const openAiMessage = completion.choices[0].message
|
||||
export function convertToAnthropicMessage(
|
||||
completion: OpenAI.Chat.Completions.ChatCompletion,
|
||||
): Anthropic.Messages.Message {
|
||||
const openAiMessage = completion.choices[0].message;
|
||||
const anthropicMessage: Anthropic.Messages.Message = {
|
||||
id: completion.id,
|
||||
type: "message",
|
||||
@@ -405,14 +430,14 @@ export function convertToAnthropicMessage(completion: OpenAI.Chat.Completions.Ch
|
||||
stop_reason: (() => {
|
||||
switch (completion.choices[0].finish_reason) {
|
||||
case "stop":
|
||||
return "end_turn"
|
||||
return "end_turn";
|
||||
case "length":
|
||||
return "max_tokens"
|
||||
return "max_tokens";
|
||||
case "tool_calls":
|
||||
return "tool_use"
|
||||
return "tool_use";
|
||||
case "content_filter": // Anthropic doesn't have an exact equivalent
|
||||
default:
|
||||
return null
|
||||
return null;
|
||||
}
|
||||
})(),
|
||||
stop_sequence: null, // which custom stop_sequence was generated, if any (not applicable if you don't use stop_sequence)
|
||||
@@ -423,36 +448,38 @@ export function convertToAnthropicMessage(completion: OpenAI.Chat.Completions.Ch
|
||||
cache_read_input_tokens: null,
|
||||
server_tool_use: null,
|
||||
},
|
||||
}
|
||||
};
|
||||
try {
|
||||
if (openAiMessage?.tool_calls?.length) {
|
||||
const functionCalls = openAiMessage.tool_calls.filter((tc: any) => tc?.type === "function" && tc.function)
|
||||
const functionCalls = openAiMessage.tool_calls.filter(
|
||||
(tc: any) => tc?.type === "function" && tc.function,
|
||||
);
|
||||
if (functionCalls.length > 0) {
|
||||
anthropicMessage.content.push(
|
||||
...functionCalls.map((toolCall: any): Anthropic.ToolUseBlock => {
|
||||
let parsedInput = {}
|
||||
let parsedInput = {};
|
||||
try {
|
||||
parsedInput = JSON.parse(toolCall.function?.arguments || "{}")
|
||||
parsedInput = JSON.parse(toolCall.function?.arguments || "{}");
|
||||
} catch (error) {
|
||||
Logger.error("Failed to parse tool arguments:", error)
|
||||
Logger.error("Failed to parse tool arguments:", error);
|
||||
}
|
||||
return {
|
||||
type: "tool_use",
|
||||
id: toolCall.id,
|
||||
name: toolCall.function?.name || UNIQUE_ERROR_TOOL_NAME,
|
||||
input: parsedInput,
|
||||
}
|
||||
};
|
||||
}),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return anthropicMessage
|
||||
return anthropicMessage;
|
||||
}
|
||||
} catch (error) {
|
||||
Logger.error("Error converting OpenAI message to Anthropic format:", error)
|
||||
Logger.error("Error converting OpenAI message to Anthropic format:", error);
|
||||
}
|
||||
|
||||
return anthropicMessage
|
||||
return anthropicMessage;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -471,43 +498,47 @@ export function sanitizeGeminiMessages(
|
||||
modelId: string,
|
||||
): OpenAI.Chat.ChatCompletionMessageParam[] {
|
||||
if (!modelId.includes("gemini")) {
|
||||
return messages
|
||||
return messages;
|
||||
}
|
||||
|
||||
const droppedToolCallIds = new Set<string>()
|
||||
const sanitized: OpenAI.Chat.ChatCompletionMessageParam[] = []
|
||||
const droppedToolCallIds = new Set<string>();
|
||||
const sanitized: OpenAI.Chat.ChatCompletionMessageParam[] = [];
|
||||
|
||||
for (const msg of messages) {
|
||||
if (msg.role === "assistant") {
|
||||
const anyMsg = msg as any
|
||||
const toolCalls = anyMsg.tool_calls
|
||||
const anyMsg = msg as any;
|
||||
const toolCalls = anyMsg.tool_calls;
|
||||
if (Array.isArray(toolCalls) && toolCalls.length > 0) {
|
||||
const reasoningDetails = anyMsg.reasoning_details
|
||||
const hasReasoningDetails = Array.isArray(reasoningDetails) && reasoningDetails.length > 0
|
||||
const reasoningDetails = anyMsg.reasoning_details;
|
||||
const hasReasoningDetails =
|
||||
Array.isArray(reasoningDetails) && reasoningDetails.length > 0;
|
||||
if (!hasReasoningDetails) {
|
||||
for (const tc of toolCalls) {
|
||||
if (tc?.id) {
|
||||
droppedToolCallIds.add(tc.id)
|
||||
droppedToolCallIds.add(tc.id);
|
||||
}
|
||||
}
|
||||
// Keep any textual content, but drop the tool_calls themselves.
|
||||
if (anyMsg.content) {
|
||||
sanitized.push({ role: "assistant", content: anyMsg.content } as any)
|
||||
sanitized.push({
|
||||
role: "assistant",
|
||||
content: anyMsg.content,
|
||||
} as any);
|
||||
}
|
||||
continue
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (msg.role === "tool") {
|
||||
const anyMsg = msg as any
|
||||
const anyMsg = msg as any;
|
||||
if (anyMsg.tool_call_id && droppedToolCallIds.has(anyMsg.tool_call_id)) {
|
||||
continue
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
sanitized.push(msg)
|
||||
sanitized.push(msg);
|
||||
}
|
||||
|
||||
return sanitized
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
import { ResponseInput, ResponseInputMessageContentList, ResponseReasoningItem } from "openai/resources/responses/responses"
|
||||
import { ClineStorageMessage, getBase64ImageSource, getImageDataUrl } from "@/shared/messages/content"
|
||||
import type {
|
||||
ResponseInput,
|
||||
ResponseInputMessageContentList,
|
||||
ResponseReasoningItem,
|
||||
} from "openai/resources/responses/responses";
|
||||
import {
|
||||
type ClineStorageMessage,
|
||||
getBase64ImageSource,
|
||||
getImageDataUrl,
|
||||
} from "@/shared/messages/content";
|
||||
|
||||
/**
|
||||
* Converts an array of ClineStorageMessage objects (extension of Anthropic format) to a ResponseInput array to use with OpenAI's Responses API.
|
||||
@@ -75,56 +83,69 @@ export function convertToOpenAIResponsesInput(
|
||||
_messages: ClineStorageMessage[],
|
||||
options?: { usePreviousResponseId?: boolean },
|
||||
): {
|
||||
input: ResponseInput
|
||||
previousResponseId?: string
|
||||
input: ResponseInput;
|
||||
previousResponseId?: string;
|
||||
} {
|
||||
// Chain from the latest stored Responses API assistant message when available.
|
||||
// When chaining, only send new items after that assistant turn.
|
||||
let previousResponseId: string | undefined
|
||||
let messages = _messages
|
||||
let previousResponseId: string | undefined;
|
||||
let messages = _messages;
|
||||
if (options?.usePreviousResponseId) {
|
||||
for (let i = _messages.length - 1; i >= 0; i--) {
|
||||
const msg = _messages[i]
|
||||
const msg = _messages[i];
|
||||
// Must be less than 24 hours old to be considered for chaining as the previous Id is only valid for 24 hours.
|
||||
// Set to 23 hours to account for any potential delays in processing.
|
||||
const isLessThan23HoursOld = msg.ts ? Date.now() - msg.ts < 23 * 60 * 60 * 1000 : false
|
||||
const isLessThan23HoursOld = msg.ts
|
||||
? Date.now() - msg.ts < 23 * 60 * 60 * 1000
|
||||
: false;
|
||||
if (msg.role === "assistant" && msg.id && isLessThan23HoursOld) {
|
||||
previousResponseId = msg.id
|
||||
messages = _messages.slice(i + 1)
|
||||
break
|
||||
previousResponseId = msg.id;
|
||||
messages = _messages.slice(i + 1);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const allItems: any[] = []
|
||||
const toolUseIdToCallId = new Map<string, string>()
|
||||
const allItems: any[] = [];
|
||||
const toolUseIdToCallId = new Map<string, string>();
|
||||
|
||||
for (const m of messages) {
|
||||
if (typeof m.content === "string") {
|
||||
allItems.push({ role: m.role, content: [{ type: "input_text", text: m.content }] })
|
||||
continue
|
||||
allItems.push({
|
||||
role: m.role,
|
||||
content: [{ type: "input_text", text: m.content }],
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (m.role === "assistant") {
|
||||
// For assistant messages, we must ensure reasoning items are IMMEDIATELY followed
|
||||
// by their corresponding message or function_call. Process the entire assistant
|
||||
// turn and ensure proper pairing.
|
||||
const assistantItems: any[] = []
|
||||
const assistantItems: any[] = [];
|
||||
|
||||
for (const part of m.content) {
|
||||
switch (part.type) {
|
||||
case "thinking":
|
||||
case "thinking": {
|
||||
// Only include reasoning item if it has actual content (thinking text or summary)
|
||||
// Empty reasoning items cause API errors: "Item 'rs_...' of type 'reasoning' was provided without its required following item"
|
||||
const hasThinkingContent = part.thinking && part.thinking.trim().length > 0
|
||||
const hasSummaryContent = part.summary && Array.isArray(part.summary) && part.summary.length > 0
|
||||
const hasThinkingContent =
|
||||
part.thinking && part.thinking.trim().length > 0;
|
||||
const hasSummaryContent =
|
||||
part.summary &&
|
||||
Array.isArray(part.summary) &&
|
||||
part.summary.length > 0;
|
||||
|
||||
if (part.call_id && part.call_id.length > 0 && (hasThinkingContent || hasSummaryContent)) {
|
||||
if (
|
||||
part.call_id &&
|
||||
part.call_id.length > 0 &&
|
||||
(hasThinkingContent || hasSummaryContent)
|
||||
) {
|
||||
// Use summary if available, otherwise use thinking text
|
||||
let summary: any[] = []
|
||||
let summary: any[] = [];
|
||||
if (hasSummaryContent) {
|
||||
// part.summary is already in the correct format from OpenAI Responses API
|
||||
summary = part.summary as any[]
|
||||
summary = part.summary as any[];
|
||||
} else if (hasThinkingContent) {
|
||||
// Convert thinking text to summary format
|
||||
summary = [
|
||||
@@ -132,16 +153,17 @@ export function convertToOpenAIResponsesInput(
|
||||
type: "summary_text",
|
||||
text: part.thinking,
|
||||
},
|
||||
]
|
||||
];
|
||||
}
|
||||
|
||||
assistantItems.push({
|
||||
id: part.call_id,
|
||||
type: "reasoning",
|
||||
summary,
|
||||
} as ResponseReasoningItem)
|
||||
} as ResponseReasoningItem);
|
||||
}
|
||||
break
|
||||
break;
|
||||
}
|
||||
case "redacted_thinking":
|
||||
// Include reasoning item with encrypted content if it has a call_id
|
||||
// Even if data is missing, we need to maintain the reasoning-function_call pairing
|
||||
@@ -150,100 +172,115 @@ export function convertToOpenAIResponsesInput(
|
||||
id: part.call_id,
|
||||
type: "reasoning",
|
||||
summary: [],
|
||||
}
|
||||
};
|
||||
// Only include encrypted_content if data exists
|
||||
if (part.data) {
|
||||
reasoningItem.encrypted_content = part.data
|
||||
reasoningItem.encrypted_content = part.data;
|
||||
}
|
||||
assistantItems.push(reasoningItem as ResponseReasoningItem)
|
||||
assistantItems.push(reasoningItem as ResponseReasoningItem);
|
||||
}
|
||||
break
|
||||
case "text":
|
||||
break;
|
||||
case "text": {
|
||||
// Message ID goes at the message level, not in the content
|
||||
// The reasoning item and message can have different IDs - they just need to be adjacent
|
||||
const messageItem: any = {
|
||||
type: "message",
|
||||
role: "assistant",
|
||||
content: [{ type: "output_text", text: part.text }],
|
||||
}
|
||||
};
|
||||
// Set message-level id if available
|
||||
if (part.call_id) {
|
||||
messageItem.id = part.call_id
|
||||
messageItem.id = part.call_id;
|
||||
}
|
||||
assistantItems.push(messageItem)
|
||||
break
|
||||
case "image":
|
||||
assistantItems.push(messageItem);
|
||||
break;
|
||||
}
|
||||
case "image": {
|
||||
// Message ID goes at the message level, not in the content
|
||||
const imageItem: any = {
|
||||
type: "message",
|
||||
role: "assistant",
|
||||
content: [{ type: "output_text", text: `[image:${getBase64ImageSource(part.source).mediaType}]` }],
|
||||
}
|
||||
content: [
|
||||
{
|
||||
type: "output_text",
|
||||
text: `[image:${getBase64ImageSource(part.source).mediaType}]`,
|
||||
},
|
||||
],
|
||||
};
|
||||
// Set message-level id if available (though images typically don't have call_id)
|
||||
if (part.call_id) {
|
||||
imageItem.id = part.call_id
|
||||
imageItem.id = part.call_id;
|
||||
}
|
||||
assistantItems.push(imageItem)
|
||||
break
|
||||
assistantItems.push(imageItem);
|
||||
break;
|
||||
}
|
||||
case "tool_use": {
|
||||
// Function calls use call_id, not related to reasoning item ID
|
||||
const call_id = part.call_id || part.id
|
||||
const call_id = part.call_id || part.id;
|
||||
if (part.call_id) {
|
||||
toolUseIdToCallId.set(part.id, part.call_id)
|
||||
toolUseIdToCallId.set(part.id, part.call_id);
|
||||
}
|
||||
assistantItems.push({
|
||||
type: "function_call",
|
||||
call_id,
|
||||
// MAX 53 characters for OpenAI Responses API tool IDs
|
||||
id: !part.id.startsWith("fc_") ? `fc_${part.id.slice(0, 50)}` : part.id,
|
||||
id: !part.id.startsWith("fc_")
|
||||
? `fc_${part.id.slice(0, 50)}`
|
||||
: part.id,
|
||||
name: part.name,
|
||||
arguments: JSON.stringify(part.input ?? {}),
|
||||
})
|
||||
break
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
allItems.push(...assistantItems)
|
||||
allItems.push(...assistantItems);
|
||||
} else {
|
||||
// User messages - collect all content
|
||||
const messageContent: ResponseInputMessageContentList = []
|
||||
const messageContent: ResponseInputMessageContentList = [];
|
||||
|
||||
for (const part of m.content) {
|
||||
switch (part.type) {
|
||||
case "text":
|
||||
messageContent.push({ type: "input_text", text: part.text })
|
||||
break
|
||||
messageContent.push({ type: "input_text", text: part.text });
|
||||
break;
|
||||
case "image":
|
||||
messageContent.push({
|
||||
type: "input_image",
|
||||
detail: "auto",
|
||||
image_url: getImageDataUrl(part.source),
|
||||
})
|
||||
break
|
||||
});
|
||||
break;
|
||||
case "tool_result": {
|
||||
// Flush any pending message content before adding tool result
|
||||
if (messageContent.length > 0) {
|
||||
allItems.push({ role: m.role, content: [...messageContent] })
|
||||
messageContent.length = 0
|
||||
allItems.push({ role: m.role, content: [...messageContent] });
|
||||
messageContent.length = 0;
|
||||
}
|
||||
const call_id = part.call_id || toolUseIdToCallId.get(part.tool_use_id) || part.tool_use_id
|
||||
const call_id =
|
||||
part.call_id ||
|
||||
toolUseIdToCallId.get(part.tool_use_id) ||
|
||||
part.tool_use_id;
|
||||
allItems.push({
|
||||
type: "function_call_output",
|
||||
call_id,
|
||||
output: typeof part.content === "string" ? part.content : JSON.stringify(part.content),
|
||||
})
|
||||
break
|
||||
output:
|
||||
typeof part.content === "string"
|
||||
? part.content
|
||||
: JSON.stringify(part.content),
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Flush any remaining user message content
|
||||
if (messageContent.length > 0) {
|
||||
allItems.push({ role: m.role, content: [...messageContent] })
|
||||
allItems.push({ role: m.role, content: [...messageContent] });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { input: allItems, previousResponseId }
|
||||
return { input: allItems, previousResponseId };
|
||||
}
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
import { Anthropic } from "@anthropic-ai/sdk"
|
||||
import OpenAI from "openai"
|
||||
import { ClineAssistantThinkingBlock, ClineStorageMessage, getImageDataUrl } from "@/shared/messages/content"
|
||||
import type { Anthropic } from "@anthropic-ai/sdk";
|
||||
import type OpenAI from "openai";
|
||||
import {
|
||||
type ClineAssistantThinkingBlock,
|
||||
type ClineStorageMessage,
|
||||
getImageDataUrl,
|
||||
} from "@/shared/messages/content";
|
||||
|
||||
/**
|
||||
* DeepSeek Reasoner message format with reasoning_content support.
|
||||
*/
|
||||
export type DeepSeekReasonerMessage = OpenAI.Chat.ChatCompletionMessageParam & {
|
||||
reasoning_content?: string
|
||||
}
|
||||
reasoning_content?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Adds reasoning_content to OpenAI messages for DeepSeek Reasoner.
|
||||
@@ -21,43 +25,45 @@ export function addReasoningContent(
|
||||
// Find last user message index (start of current turn)
|
||||
// If no user message exists (lastUserIndex = -1), all messages are in the "current turn",
|
||||
// so reasoning_content will be added to all assistant messages. This is intentional.
|
||||
let lastUserIndex = -1
|
||||
let lastUserIndex = -1;
|
||||
for (let i = openAiMessages.length - 1; i >= 0; i--) {
|
||||
if (openAiMessages[i].role === "user") {
|
||||
lastUserIndex = i
|
||||
break
|
||||
lastUserIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Extract thinking content from original messages, keyed by assistant index
|
||||
const thinkingByIndex = new Map<number, string>()
|
||||
let assistantIdx = 0
|
||||
const thinkingByIndex = new Map<number, string>();
|
||||
let assistantIdx = 0;
|
||||
for (const msg of originalMessages) {
|
||||
if (msg.role === "assistant") {
|
||||
if (Array.isArray(msg.content)) {
|
||||
const thinking = msg.content
|
||||
.filter((p): p is ClineAssistantThinkingBlock => p.type === "thinking")
|
||||
.filter(
|
||||
(p): p is ClineAssistantThinkingBlock => p.type === "thinking",
|
||||
)
|
||||
.map((p) => p.thinking)
|
||||
.join("\n")
|
||||
.join("\n");
|
||||
if (thinking) {
|
||||
thinkingByIndex.set(assistantIdx, thinking)
|
||||
thinkingByIndex.set(assistantIdx, thinking);
|
||||
}
|
||||
}
|
||||
assistantIdx++
|
||||
assistantIdx++;
|
||||
}
|
||||
}
|
||||
|
||||
// Add reasoning_content only to assistant messages in current turn
|
||||
let aiIdx = 0
|
||||
let aiIdx = 0;
|
||||
return openAiMessages.map((msg, i): DeepSeekReasonerMessage => {
|
||||
if (msg.role === "assistant") {
|
||||
const thinking = thinkingByIndex.get(aiIdx++)
|
||||
const thinking = thinkingByIndex.get(aiIdx++);
|
||||
if (thinking && i >= lastUserIndex) {
|
||||
return { ...msg, reasoning_content: thinking }
|
||||
return { ...msg, reasoning_content: thinking };
|
||||
}
|
||||
}
|
||||
return msg
|
||||
})
|
||||
return msg;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -68,84 +74,104 @@ export function addReasoningContent(
|
||||
* @param messages Array of Anthropic messages
|
||||
* @returns Array of OpenAI messages where consecutive messages with the same role are merged together
|
||||
*/
|
||||
export function convertToR1Format(messages: Anthropic.Messages.MessageParam[]): OpenAI.Chat.ChatCompletionMessageParam[] {
|
||||
return messages.reduce<OpenAI.Chat.ChatCompletionMessageParam[]>((merged, message) => {
|
||||
const lastMessage = merged[merged.length - 1]
|
||||
let messageContent: string | (OpenAI.Chat.ChatCompletionContentPartText | OpenAI.Chat.ChatCompletionContentPartImage)[] =
|
||||
""
|
||||
let hasImages = false
|
||||
export function convertToR1Format(
|
||||
messages: Anthropic.Messages.MessageParam[],
|
||||
): OpenAI.Chat.ChatCompletionMessageParam[] {
|
||||
return messages.reduce<OpenAI.Chat.ChatCompletionMessageParam[]>(
|
||||
(merged, message) => {
|
||||
const lastMessage = merged[merged.length - 1];
|
||||
let messageContent:
|
||||
| string
|
||||
| (
|
||||
| OpenAI.Chat.ChatCompletionContentPartText
|
||||
| OpenAI.Chat.ChatCompletionContentPartImage
|
||||
)[] = "";
|
||||
let hasImages = false;
|
||||
|
||||
if (Array.isArray(message.content)) {
|
||||
const textParts: string[] = []
|
||||
const imageParts: OpenAI.Chat.ChatCompletionContentPartImage[] = []
|
||||
if (Array.isArray(message.content)) {
|
||||
const textParts: string[] = [];
|
||||
const imageParts: OpenAI.Chat.ChatCompletionContentPartImage[] = [];
|
||||
|
||||
message.content.forEach((part) => {
|
||||
if (part.type === "text") {
|
||||
textParts.push(part.text)
|
||||
}
|
||||
if (part.type === "image") {
|
||||
hasImages = true
|
||||
imageParts.push({
|
||||
type: "image_url",
|
||||
image_url: { url: getImageDataUrl(part.source) },
|
||||
})
|
||||
}
|
||||
})
|
||||
message.content.forEach((part) => {
|
||||
if (part.type === "text") {
|
||||
textParts.push(part.text);
|
||||
}
|
||||
if (part.type === "image") {
|
||||
hasImages = true;
|
||||
imageParts.push({
|
||||
type: "image_url",
|
||||
image_url: { url: getImageDataUrl(part.source) },
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
if (hasImages) {
|
||||
const parts: (OpenAI.Chat.ChatCompletionContentPartText | OpenAI.Chat.ChatCompletionContentPartImage)[] = []
|
||||
if (textParts.length > 0) {
|
||||
parts.push({ type: "text", text: textParts.join("\n") })
|
||||
}
|
||||
parts.push(...imageParts)
|
||||
messageContent = parts
|
||||
} else {
|
||||
messageContent = textParts.join("\n")
|
||||
}
|
||||
} else {
|
||||
messageContent = message.content
|
||||
}
|
||||
|
||||
// If the last message has the same role, merge the content
|
||||
if (lastMessage?.role === message.role) {
|
||||
if (typeof lastMessage.content === "string" && typeof messageContent === "string") {
|
||||
lastMessage.content += `\n${messageContent}`
|
||||
} else {
|
||||
const lastContent = Array.isArray(lastMessage.content)
|
||||
? lastMessage.content
|
||||
: [{ type: "text" as const, text: lastMessage.content || "" }]
|
||||
|
||||
const newContent = Array.isArray(messageContent)
|
||||
? messageContent
|
||||
: [{ type: "text" as const, text: messageContent }]
|
||||
|
||||
if (message.role === "assistant") {
|
||||
const mergedContent = [
|
||||
...lastContent,
|
||||
...newContent,
|
||||
] as OpenAI.Chat.ChatCompletionAssistantMessageParam["content"]
|
||||
lastMessage.content = mergedContent
|
||||
if (hasImages) {
|
||||
const parts: (
|
||||
| OpenAI.Chat.ChatCompletionContentPartText
|
||||
| OpenAI.Chat.ChatCompletionContentPartImage
|
||||
)[] = [];
|
||||
if (textParts.length > 0) {
|
||||
parts.push({ type: "text", text: textParts.join("\n") });
|
||||
}
|
||||
parts.push(...imageParts);
|
||||
messageContent = parts;
|
||||
} else {
|
||||
const mergedContent = [...lastContent, ...newContent] as OpenAI.Chat.ChatCompletionUserMessageParam["content"]
|
||||
lastMessage.content = mergedContent
|
||||
messageContent = textParts.join("\n");
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Adds new message with the correct type based on role
|
||||
if (message.role === "assistant") {
|
||||
const newMessage: OpenAI.Chat.ChatCompletionAssistantMessageParam = {
|
||||
role: "assistant",
|
||||
content: messageContent as OpenAI.Chat.ChatCompletionAssistantMessageParam["content"],
|
||||
}
|
||||
merged.push(newMessage)
|
||||
} else {
|
||||
const newMessage: OpenAI.Chat.ChatCompletionUserMessageParam = {
|
||||
role: "user",
|
||||
content: messageContent as OpenAI.Chat.ChatCompletionUserMessageParam["content"],
|
||||
}
|
||||
merged.push(newMessage)
|
||||
messageContent = message.content;
|
||||
}
|
||||
}
|
||||
return merged
|
||||
}, [])
|
||||
|
||||
// If the last message has the same role, merge the content
|
||||
if (lastMessage?.role === message.role) {
|
||||
if (
|
||||
typeof lastMessage.content === "string" &&
|
||||
typeof messageContent === "string"
|
||||
) {
|
||||
lastMessage.content += `\n${messageContent}`;
|
||||
} else {
|
||||
const lastContent = Array.isArray(lastMessage.content)
|
||||
? lastMessage.content
|
||||
: [{ type: "text" as const, text: lastMessage.content || "" }];
|
||||
|
||||
const newContent = Array.isArray(messageContent)
|
||||
? messageContent
|
||||
: [{ type: "text" as const, text: messageContent }];
|
||||
|
||||
if (message.role === "assistant") {
|
||||
const mergedContent = [
|
||||
...lastContent,
|
||||
...newContent,
|
||||
] as OpenAI.Chat.ChatCompletionAssistantMessageParam["content"];
|
||||
lastMessage.content = mergedContent;
|
||||
} else {
|
||||
const mergedContent = [
|
||||
...lastContent,
|
||||
...newContent,
|
||||
] as OpenAI.Chat.ChatCompletionUserMessageParam["content"];
|
||||
lastMessage.content = mergedContent;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Adds new message with the correct type based on role
|
||||
if (message.role === "assistant") {
|
||||
const newMessage: OpenAI.Chat.ChatCompletionAssistantMessageParam = {
|
||||
role: "assistant",
|
||||
content:
|
||||
messageContent as OpenAI.Chat.ChatCompletionAssistantMessageParam["content"],
|
||||
};
|
||||
merged.push(newMessage);
|
||||
} else {
|
||||
const newMessage: OpenAI.Chat.ChatCompletionUserMessageParam = {
|
||||
role: "user",
|
||||
content:
|
||||
messageContent as OpenAI.Chat.ChatCompletionUserMessageParam["content"],
|
||||
};
|
||||
merged.push(newMessage);
|
||||
}
|
||||
}
|
||||
return merged;
|
||||
},
|
||||
[],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Anthropic } from "@anthropic-ai/sdk"
|
||||
import * as vscode from "vscode"
|
||||
import { Logger } from "@/shared/services/Logger"
|
||||
import type { Anthropic } from "@anthropic-ai/sdk";
|
||||
import * as vscode from "vscode";
|
||||
import { Logger } from "@/shared/services/Logger";
|
||||
|
||||
/**
|
||||
* Safely converts a value into a plain object.
|
||||
@@ -8,31 +8,31 @@ import { Logger } from "@/shared/services/Logger"
|
||||
export function asObjectSafe(value: any): object {
|
||||
// Handle null/undefined
|
||||
if (!value) {
|
||||
return {}
|
||||
return {};
|
||||
}
|
||||
|
||||
try {
|
||||
// Handle strings that might be JSON
|
||||
if (typeof value === "string") {
|
||||
return JSON.parse(value)
|
||||
return JSON.parse(value);
|
||||
}
|
||||
|
||||
// Handle pre-existing objects
|
||||
if (typeof value === "object") {
|
||||
return Object.assign({}, value)
|
||||
return Object.assign({}, value);
|
||||
}
|
||||
|
||||
return {}
|
||||
return {};
|
||||
} catch (error) {
|
||||
Logger.warn("Cline <Language Model API>: Failed to parse object:", error)
|
||||
return {}
|
||||
Logger.warn("Cline <Language Model API>: Failed to parse object:", error);
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
export function convertToVsCodeLmMessages(
|
||||
anthropicMessages: Anthropic.Messages.MessageParam[],
|
||||
): vscode.LanguageModelChatMessage[] {
|
||||
const vsCodeLmMessages: vscode.LanguageModelChatMessage[] = []
|
||||
const vsCodeLmMessages: vscode.LanguageModelChatMessage[] = [];
|
||||
|
||||
for (const anthropicMessage of anthropicMessages) {
|
||||
// Handle simple string messages
|
||||
@@ -41,27 +41,31 @@ export function convertToVsCodeLmMessages(
|
||||
anthropicMessage.role === "assistant"
|
||||
? vscode.LanguageModelChatMessage.Assistant(anthropicMessage.content)
|
||||
: vscode.LanguageModelChatMessage.User(anthropicMessage.content),
|
||||
)
|
||||
continue
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Handle complex message structures
|
||||
switch (anthropicMessage.role) {
|
||||
case "user": {
|
||||
const { nonToolMessages, toolMessages } = anthropicMessage.content.reduce<{
|
||||
nonToolMessages: (Anthropic.TextBlockParam | Anthropic.ImageBlockParam)[]
|
||||
toolMessages: Anthropic.ToolResultBlockParam[]
|
||||
}>(
|
||||
(acc, part) => {
|
||||
if (part.type === "tool_result") {
|
||||
acc.toolMessages.push(part)
|
||||
} else if (part.type === "text" || part.type === "image") {
|
||||
acc.nonToolMessages.push(part)
|
||||
}
|
||||
return acc
|
||||
},
|
||||
{ nonToolMessages: [], toolMessages: [] },
|
||||
)
|
||||
const { nonToolMessages, toolMessages } =
|
||||
anthropicMessage.content.reduce<{
|
||||
nonToolMessages: (
|
||||
| Anthropic.TextBlockParam
|
||||
| Anthropic.ImageBlockParam
|
||||
)[];
|
||||
toolMessages: Anthropic.ToolResultBlockParam[];
|
||||
}>(
|
||||
(acc, part) => {
|
||||
if (part.type === "tool_result") {
|
||||
acc.toolMessages.push(part);
|
||||
} else if (part.type === "text" || part.type === "image") {
|
||||
acc.nonToolMessages.push(part);
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
{ nonToolMessages: [], toolMessages: [] },
|
||||
);
|
||||
|
||||
// Process tool messages first then non-tool messages
|
||||
const contentParts = [
|
||||
@@ -75,12 +79,15 @@ export function convertToVsCodeLmMessages(
|
||||
if (part.type === "image") {
|
||||
return new vscode.LanguageModelTextPart(
|
||||
`[Image (${part.source?.type || "Unknown source-type"}): ${(part.source?.type === "base64" && part.source.media_type) || "unknown media-type"} not supported by VSCode LM API]`,
|
||||
)
|
||||
);
|
||||
}
|
||||
return new vscode.LanguageModelTextPart(part.text)
|
||||
}) ?? [new vscode.LanguageModelTextPart("")])
|
||||
return new vscode.LanguageModelTextPart(part.text);
|
||||
}) ?? [new vscode.LanguageModelTextPart("")]);
|
||||
|
||||
return new vscode.LanguageModelToolResultPart(toolMessage.tool_use_id, toolContentParts)
|
||||
return new vscode.LanguageModelToolResultPart(
|
||||
toolMessage.tool_use_id,
|
||||
toolContentParts,
|
||||
);
|
||||
}),
|
||||
|
||||
// Convert non-tool messages to TextParts after tool messages
|
||||
@@ -88,32 +95,38 @@ export function convertToVsCodeLmMessages(
|
||||
if (part.type === "image") {
|
||||
return new vscode.LanguageModelTextPart(
|
||||
`[Image (${part.source?.type || "Unknown source-type"}): ${(part.source?.type === "base64" && part.source.media_type) || "unknown media-type"} not supported by VSCode LM API]`,
|
||||
)
|
||||
);
|
||||
}
|
||||
return new vscode.LanguageModelTextPart(part.text)
|
||||
return new vscode.LanguageModelTextPart(part.text);
|
||||
}),
|
||||
]
|
||||
];
|
||||
|
||||
// Add single user message with all content parts
|
||||
vsCodeLmMessages.push(vscode.LanguageModelChatMessage.User(contentParts))
|
||||
break
|
||||
vsCodeLmMessages.push(
|
||||
vscode.LanguageModelChatMessage.User(contentParts),
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
case "assistant": {
|
||||
const { nonToolMessages, toolMessages } = anthropicMessage.content.reduce<{
|
||||
nonToolMessages: (Anthropic.TextBlockParam | Anthropic.ImageBlockParam)[]
|
||||
toolMessages: Anthropic.ToolUseBlockParam[]
|
||||
}>(
|
||||
(acc, part) => {
|
||||
if (part.type === "tool_use") {
|
||||
acc.toolMessages.push(part)
|
||||
} else if (part.type === "text" || part.type === "image") {
|
||||
acc.nonToolMessages.push(part)
|
||||
}
|
||||
return acc
|
||||
},
|
||||
{ nonToolMessages: [], toolMessages: [] },
|
||||
)
|
||||
const { nonToolMessages, toolMessages } =
|
||||
anthropicMessage.content.reduce<{
|
||||
nonToolMessages: (
|
||||
| Anthropic.TextBlockParam
|
||||
| Anthropic.ImageBlockParam
|
||||
)[];
|
||||
toolMessages: Anthropic.ToolUseBlockParam[];
|
||||
}>(
|
||||
(acc, part) => {
|
||||
if (part.type === "tool_use") {
|
||||
acc.toolMessages.push(part);
|
||||
} else if (part.type === "text" || part.type === "image") {
|
||||
acc.nonToolMessages.push(part);
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
{ nonToolMessages: [], toolMessages: [] },
|
||||
);
|
||||
|
||||
// Process tool messages first then non-tool messages
|
||||
const contentParts = [
|
||||
@@ -130,20 +143,24 @@ export function convertToVsCodeLmMessages(
|
||||
// Convert non-tool messages to TextParts after tool messages
|
||||
...nonToolMessages.map((part) => {
|
||||
if (part.type === "image") {
|
||||
return new vscode.LanguageModelTextPart("[Image generation not supported by VSCode LM API]")
|
||||
return new vscode.LanguageModelTextPart(
|
||||
"[Image generation not supported by VSCode LM API]",
|
||||
);
|
||||
}
|
||||
return new vscode.LanguageModelTextPart(part.text)
|
||||
return new vscode.LanguageModelTextPart(part.text);
|
||||
}),
|
||||
]
|
||||
];
|
||||
|
||||
// Add the assistant message to the list of messages
|
||||
vsCodeLmMessages.push(vscode.LanguageModelChatMessage.Assistant(contentParts))
|
||||
break
|
||||
vsCodeLmMessages.push(
|
||||
vscode.LanguageModelChatMessage.Assistant(contentParts),
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return vsCodeLmMessages
|
||||
return vsCodeLmMessages;
|
||||
}
|
||||
|
||||
export function convertToAnthropicRole(
|
||||
@@ -151,18 +168,22 @@ export function convertToAnthropicRole(
|
||||
): Anthropic.Messages.MessageParam["role"] | null {
|
||||
switch (vsCodeLmMessageRole) {
|
||||
case vscode.LanguageModelChatMessageRole.Assistant:
|
||||
return "assistant"
|
||||
return "assistant";
|
||||
case vscode.LanguageModelChatMessageRole.User:
|
||||
return "user"
|
||||
return "user";
|
||||
default:
|
||||
return null
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function convertToAnthropicMessage(vsCodeLmMessage: vscode.LanguageModelChatMessage): Anthropic.Messages.Message {
|
||||
const anthropicRole = convertToAnthropicRole(vsCodeLmMessage.role)
|
||||
export function convertToAnthropicMessage(
|
||||
vsCodeLmMessage: vscode.LanguageModelChatMessage,
|
||||
): Anthropic.Messages.Message {
|
||||
const anthropicRole = convertToAnthropicRole(vsCodeLmMessage.role);
|
||||
if (anthropicRole !== "assistant") {
|
||||
throw new Error("Cline <Language Model API>: Only assistant messages are supported.")
|
||||
throw new Error(
|
||||
"Cline <Language Model API>: Only assistant messages are supported.",
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -177,7 +198,7 @@ export function convertToAnthropicMessage(vsCodeLmMessage: vscode.LanguageModelC
|
||||
type: "text",
|
||||
text: part.value,
|
||||
citations: null,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
if (part instanceof vscode.LanguageModelToolCallPart) {
|
||||
@@ -186,10 +207,10 @@ export function convertToAnthropicMessage(vsCodeLmMessage: vscode.LanguageModelC
|
||||
id: part.callId || crypto.randomUUID(),
|
||||
name: part.name,
|
||||
input: asObjectSafe(part.input),
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return null
|
||||
return null;
|
||||
})
|
||||
.filter((part): part is Anthropic.ContentBlock => part !== null),
|
||||
stop_reason: null,
|
||||
@@ -201,5 +222,5 @@ export function convertToAnthropicMessage(vsCodeLmMessage: vscode.LanguageModelC
|
||||
cache_read_input_tokens: null,
|
||||
server_tool_use: null,
|
||||
},
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,33 +1,33 @@
|
||||
import { findLastIndex } from "@shared/array"
|
||||
import type { ClineMessage } from "@shared/ExtensionMessage"
|
||||
import type { ClineStorageMessage } from "@shared/messages/content"
|
||||
import { Logger } from "@/shared/services/Logger"
|
||||
import type { ContextManager } from "../context/context-management/ContextManager"
|
||||
import type { MessageStateHandler } from "../task/message-state"
|
||||
import type { HookModelInputContext } from "./hook-factory"
|
||||
import { findLastIndex } from "@shared/array";
|
||||
import type { ClineMessage } from "@shared/ExtensionMessage";
|
||||
import type { ClineStorageMessage } from "@shared/messages/content";
|
||||
import { Logger } from "@/shared/services/Logger";
|
||||
import type { ContextManager } from "../context/context-management/ContextManager";
|
||||
import type { MessageStateHandler } from "../task/message-state";
|
||||
import type { HookModelInputContext } from "./hook-factory";
|
||||
|
||||
/**
|
||||
* Active hook execution state
|
||||
* Represents a hook process that is currently running
|
||||
*/
|
||||
export type HookExecution = {
|
||||
hookName: string
|
||||
toolName?: string
|
||||
messageTs: number
|
||||
abortController: AbortController
|
||||
}
|
||||
hookName: string;
|
||||
toolName?: string;
|
||||
messageTs: number;
|
||||
abortController: AbortController;
|
||||
};
|
||||
|
||||
/**
|
||||
* Custom error class for hook cancellation
|
||||
* Used to signal that a hook cancelled an operation
|
||||
*/
|
||||
export class HookCancellationError extends Error {
|
||||
public readonly wasCancelled: boolean
|
||||
public readonly wasCancelled: boolean;
|
||||
|
||||
constructor(wasCancelled: boolean) {
|
||||
super("Hook cancelled the operation")
|
||||
this.name = "HookCancellationError"
|
||||
this.wasCancelled = wasCancelled
|
||||
super("Hook cancelled the operation");
|
||||
this.name = "HookCancellationError";
|
||||
this.wasCancelled = wasCancelled;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,10 +35,10 @@ export class HookCancellationError extends Error {
|
||||
* Token usage information extracted from an API request message
|
||||
*/
|
||||
export interface TokenUsage {
|
||||
tokensIn: number
|
||||
tokensOut: number
|
||||
tokensInCache: number
|
||||
tokensOutCache: number
|
||||
tokensIn: number;
|
||||
tokensOut: number;
|
||||
tokensInCache: number;
|
||||
tokensOutCache: number;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -46,29 +46,34 @@ export interface TokenUsage {
|
||||
* @param message The API request message to parse
|
||||
* @returns Token usage information, or zeros if parsing fails
|
||||
*/
|
||||
export function extractTokenUsageFromMessage(message: ClineMessage | undefined): TokenUsage {
|
||||
export function extractTokenUsageFromMessage(
|
||||
message: ClineMessage | undefined,
|
||||
): TokenUsage {
|
||||
const defaultUsage: TokenUsage = {
|
||||
tokensIn: 0,
|
||||
tokensOut: 0,
|
||||
tokensInCache: 0,
|
||||
tokensOutCache: 0,
|
||||
}
|
||||
};
|
||||
|
||||
if (!message?.text) {
|
||||
return defaultUsage
|
||||
return defaultUsage;
|
||||
}
|
||||
|
||||
try {
|
||||
const apiReqInfo = JSON.parse(message.text)
|
||||
const apiReqInfo = JSON.parse(message.text);
|
||||
return {
|
||||
tokensIn: apiReqInfo.tokensIn || 0,
|
||||
tokensOut: apiReqInfo.tokensOut || 0,
|
||||
tokensInCache: apiReqInfo.cacheWrites || 0,
|
||||
tokensOutCache: apiReqInfo.cacheReads || 0,
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
Logger.error("[PreCompact] Failed to parse API request token usage:", error)
|
||||
return defaultUsage
|
||||
Logger.error(
|
||||
"[PreCompact] Failed to parse API request token usage:",
|
||||
error,
|
||||
);
|
||||
return defaultUsage;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,9 +81,9 @@ export function extractTokenUsageFromMessage(message: ClineMessage | undefined):
|
||||
* Context files written for hook access
|
||||
*/
|
||||
export interface PreCompactContextFiles {
|
||||
contextJsonPath: string
|
||||
contextRawPath: string
|
||||
hookTimestamp: number
|
||||
contextJsonPath: string;
|
||||
contextRawPath: string;
|
||||
hookTimestamp: number;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -91,23 +96,32 @@ export async function writePreCompactContextFiles(
|
||||
taskId: string,
|
||||
currentContext: ClineStorageMessage[],
|
||||
): Promise<PreCompactContextFiles> {
|
||||
const { writeConversationHistoryJson, writeConversationHistoryText } = await import("../storage/disk")
|
||||
const { writeConversationHistoryJson, writeConversationHistoryText } =
|
||||
await import("../storage/disk");
|
||||
|
||||
// Generate single timestamp for both files to ensure they match
|
||||
const hookTimestamp = Date.now()
|
||||
const hookTimestamp = Date.now();
|
||||
|
||||
// Write context files for hook access
|
||||
const contextJsonPath = await writeConversationHistoryJson(taskId, currentContext, hookTimestamp)
|
||||
const contextRawPath = await writeConversationHistoryText(taskId, currentContext, hookTimestamp)
|
||||
const contextJsonPath = await writeConversationHistoryJson(
|
||||
taskId,
|
||||
currentContext,
|
||||
hookTimestamp,
|
||||
);
|
||||
const contextRawPath = await writeConversationHistoryText(
|
||||
taskId,
|
||||
currentContext,
|
||||
hookTimestamp,
|
||||
);
|
||||
|
||||
return { contextJsonPath, contextRawPath, hookTimestamp }
|
||||
return { contextJsonPath, contextRawPath, hookTimestamp };
|
||||
}
|
||||
|
||||
/**
|
||||
* Task state interface for cancellation handling
|
||||
*/
|
||||
export interface TaskStateForCancellation {
|
||||
didFinishAbortingStream: boolean
|
||||
didFinishAbortingStream: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -117,53 +131,61 @@ export interface TaskStateForCancellation {
|
||||
export interface PreCompactHookParams {
|
||||
// Task identification
|
||||
/** Task identifier */
|
||||
taskId: string
|
||||
taskId: string;
|
||||
/** ULID for telemetry */
|
||||
ulid: string
|
||||
ulid: string;
|
||||
/** Active hook model context */
|
||||
modelContext: HookModelInputContext
|
||||
modelContext: HookModelInputContext;
|
||||
|
||||
// Conversation state
|
||||
/** API conversation history */
|
||||
apiConversationHistory: ClineStorageMessage[]
|
||||
apiConversationHistory: ClineStorageMessage[];
|
||||
/** Current deleted range (if any) */
|
||||
conversationHistoryDeletedRange?: [number, number]
|
||||
conversationHistoryDeletedRange?: [number, number];
|
||||
/** Cline messages for extracting token usage */
|
||||
clineMessages: ClineMessage[]
|
||||
clineMessages: ClineMessage[];
|
||||
|
||||
// Services
|
||||
/** Context manager for getting truncated messages */
|
||||
contextManager: ContextManager
|
||||
contextManager: ContextManager;
|
||||
/** Message state handler for accessing conversation data */
|
||||
messageStateHandler: MessageStateHandler
|
||||
messageStateHandler: MessageStateHandler;
|
||||
|
||||
// Compaction metadata
|
||||
/** Compaction strategy to report in hook data */
|
||||
compactionStrategy: string
|
||||
compactionStrategy: string;
|
||||
/** Optional: Pre-calculated deleted range to report */
|
||||
deletedRange?: [number, number]
|
||||
deletedRange?: [number, number];
|
||||
|
||||
// UI callbacks
|
||||
/** Callback to display messages */
|
||||
say: (type: any, text?: string, images?: string[], files?: string[], partial?: boolean) => Promise<number | undefined>
|
||||
say: (
|
||||
type: any,
|
||||
text?: string,
|
||||
images?: string[],
|
||||
files?: string[],
|
||||
partial?: boolean,
|
||||
) => Promise<number | undefined>;
|
||||
/** Callback to save state and post to webview */
|
||||
postStateToWebview: () => Promise<void>
|
||||
postStateToWebview: () => Promise<void>;
|
||||
|
||||
// Hook management callbacks
|
||||
/** Callback to set active hook execution */
|
||||
setActiveHookExecution: (hookExecution: HookExecution | undefined) => Promise<void>
|
||||
setActiveHookExecution: (
|
||||
hookExecution: HookExecution | undefined,
|
||||
) => Promise<void>;
|
||||
/** Callback to clear active hook execution */
|
||||
clearActiveHookExecution: () => Promise<void>
|
||||
clearActiveHookExecution: () => Promise<void>;
|
||||
|
||||
// Cancellation dependencies
|
||||
/** Task state object for setting abort flag */
|
||||
taskState: TaskStateForCancellation
|
||||
taskState: TaskStateForCancellation;
|
||||
/** Callback to cancel the task */
|
||||
cancelTask: () => Promise<void>
|
||||
cancelTask: () => Promise<void>;
|
||||
|
||||
// Configuration
|
||||
/** Whether hooks are enabled */
|
||||
hooksEnabled: boolean
|
||||
hooksEnabled: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -171,7 +193,7 @@ export interface PreCompactHookParams {
|
||||
*/
|
||||
export interface PreCompactHookResult {
|
||||
/** Context modification provided by the hook */
|
||||
contextModification?: string
|
||||
contextModification?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -184,12 +206,14 @@ export interface PreCompactHookResult {
|
||||
* @throws HookCancellationError if the hook cancels the operation
|
||||
* @throws Re-throws other errors after cleanup (caller should handle gracefully)
|
||||
*/
|
||||
export async function executePreCompactHookWithCleanup(params: PreCompactHookParams): Promise<PreCompactHookResult> {
|
||||
const { executeHook } = await import("./hook-executor")
|
||||
const { cleanupConversationHistoryFile } = await import("../storage/disk")
|
||||
export async function executePreCompactHookWithCleanup(
|
||||
params: PreCompactHookParams,
|
||||
): Promise<PreCompactHookResult> {
|
||||
const { executeHook } = await import("./hook-executor");
|
||||
const { cleanupConversationHistoryFile } = await import("../storage/disk");
|
||||
|
||||
let contextJsonPath: string | undefined
|
||||
let contextRawPath: string | undefined
|
||||
let contextJsonPath: string | undefined;
|
||||
let contextRawPath: string | undefined;
|
||||
|
||||
try {
|
||||
// Get current active context (respects previous compactions).
|
||||
@@ -198,25 +222,36 @@ export async function executePreCompactHookWithCleanup(params: PreCompactHookPar
|
||||
const currentContext = params.contextManager.getTruncatedMessages(
|
||||
params.apiConversationHistory,
|
||||
params.conversationHistoryDeletedRange,
|
||||
) as ClineStorageMessage[]
|
||||
) as ClineStorageMessage[];
|
||||
|
||||
// Write context files for hook access
|
||||
const contextFiles = await writePreCompactContextFiles(params.taskId, currentContext)
|
||||
contextJsonPath = contextFiles.contextJsonPath
|
||||
contextRawPath = contextFiles.contextRawPath
|
||||
const contextFiles = await writePreCompactContextFiles(
|
||||
params.taskId,
|
||||
currentContext,
|
||||
);
|
||||
contextJsonPath = contextFiles.contextJsonPath;
|
||||
contextRawPath = contextFiles.contextRawPath;
|
||||
|
||||
// Extract token usage from the most recent API request
|
||||
const previousApiReqIndex = findLastIndex(params.clineMessages, (m) => m.say === "api_req_started")
|
||||
const previousRequest = previousApiReqIndex !== -1 ? params.clineMessages[previousApiReqIndex] : undefined
|
||||
const { tokensIn, tokensOut, tokensInCache, tokensOutCache } = extractTokenUsageFromMessage(previousRequest)
|
||||
const previousApiReqIndex = findLastIndex(
|
||||
params.clineMessages,
|
||||
(m) => m.say === "api_req_started",
|
||||
);
|
||||
const previousRequest =
|
||||
previousApiReqIndex !== -1
|
||||
? params.clineMessages[previousApiReqIndex]
|
||||
: undefined;
|
||||
const { tokensIn, tokensOut, tokensInCache, tokensOutCache } =
|
||||
extractTokenUsageFromMessage(previousRequest);
|
||||
|
||||
// Extract truncation range - use provided range or extract from conversationHistoryDeletedRange
|
||||
let deletedRangeStart = 0
|
||||
let deletedRangeEnd = 0
|
||||
let deletedRangeStart = 0;
|
||||
let deletedRangeEnd = 0;
|
||||
if (params.deletedRange) {
|
||||
;[deletedRangeStart, deletedRangeEnd] = params.deletedRange
|
||||
[deletedRangeStart, deletedRangeEnd] = params.deletedRange;
|
||||
} else if (params.conversationHistoryDeletedRange) {
|
||||
;[deletedRangeStart, deletedRangeEnd] = params.conversationHistoryDeletedRange
|
||||
[deletedRangeStart, deletedRangeEnd] =
|
||||
params.conversationHistoryDeletedRange;
|
||||
}
|
||||
|
||||
// Execute the hook
|
||||
@@ -247,53 +282,62 @@ export async function executePreCompactHookWithCleanup(params: PreCompactHookPar
|
||||
taskId: params.taskId,
|
||||
hooksEnabled: params.hooksEnabled,
|
||||
model: params.modelContext,
|
||||
})
|
||||
});
|
||||
|
||||
// Handle cancellation from hook
|
||||
if (preCompactResult.cancel === true) {
|
||||
// Log cancellation for debugging
|
||||
const cancellationSource = preCompactResult.wasCancelled ? "user" : "PreCompact hook"
|
||||
Logger.log(`[PreCompact] Context compaction cancelled by ${cancellationSource} for task ${params.taskId}`)
|
||||
const cancellationSource = preCompactResult.wasCancelled
|
||||
? "user"
|
||||
: "PreCompact hook";
|
||||
Logger.log(
|
||||
`[PreCompact] Context compaction cancelled by ${cancellationSource} for task ${params.taskId}`,
|
||||
);
|
||||
|
||||
// Internalized cancellation state management (replaces handleCancellation callback)
|
||||
// Always save state before cancelling, regardless of cancellation source
|
||||
params.taskState.didFinishAbortingStream = true
|
||||
await params.messageStateHandler.saveClineMessagesAndUpdateHistory()
|
||||
params.taskState.didFinishAbortingStream = true;
|
||||
await params.messageStateHandler.saveClineMessagesAndUpdateHistory();
|
||||
await params.messageStateHandler.overwriteApiConversationHistory(
|
||||
params.messageStateHandler.getApiConversationHistory(),
|
||||
)
|
||||
await params.postStateToWebview()
|
||||
);
|
||||
await params.postStateToWebview();
|
||||
|
||||
// Trigger full cancellation flow
|
||||
await params.cancelTask()
|
||||
await params.cancelTask();
|
||||
|
||||
// Throw error to signal cancellation to caller
|
||||
throw new HookCancellationError(preCompactResult.wasCancelled)
|
||||
throw new HookCancellationError(preCompactResult.wasCancelled);
|
||||
}
|
||||
|
||||
// Hook completed successfully - log if context modification provided
|
||||
if (preCompactResult.contextModification) {
|
||||
Logger.log(`[PreCompact] Hook provided context modification for task ${params.taskId}`)
|
||||
Logger.log(
|
||||
`[PreCompact] Hook provided context modification for task ${params.taskId}`,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
contextModification: preCompactResult.contextModification,
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
// Re-throw error for caller to handle
|
||||
throw error
|
||||
throw error;
|
||||
} finally {
|
||||
// Clean up temporary files - always executed regardless of success or error
|
||||
// Wrap in try-catch to prevent cleanup failures from masking original errors
|
||||
try {
|
||||
if (contextJsonPath) {
|
||||
await cleanupConversationHistoryFile(contextJsonPath)
|
||||
await cleanupConversationHistoryFile(contextJsonPath);
|
||||
}
|
||||
if (contextRawPath) {
|
||||
await cleanupConversationHistoryFile(contextRawPath)
|
||||
await cleanupConversationHistoryFile(contextRawPath);
|
||||
}
|
||||
} catch (cleanupError) {
|
||||
Logger.error("[PreCompact] Failed to cleanup context files:", cleanupError)
|
||||
Logger.error(
|
||||
"[PreCompact] Failed to cleanup context files:",
|
||||
cleanupError,
|
||||
);
|
||||
// Don't throw - cleanup failure shouldn't mask original error
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,23 +1,26 @@
|
||||
import { Anthropic } from "@anthropic-ai/sdk"
|
||||
import { EnvironmentMetadataEntry, TaskMetadata } from "@core/context/context-tracking/ContextTrackerTypes"
|
||||
import { execa } from "@packages/execa"
|
||||
import { ClineMessage } from "@shared/ExtensionMessage"
|
||||
import { HistoryItem } from "@shared/HistoryItem"
|
||||
import { RemoteConfig } from "@shared/remote-config/schema"
|
||||
import { GlobalState, Settings } from "@shared/storage/state-keys"
|
||||
import { fileExistsAtPath, isDirectory } from "@utils/fs"
|
||||
import fs from "fs/promises"
|
||||
import os from "os"
|
||||
import * as path from "path"
|
||||
import { HostProvider } from "@/hosts/host-provider"
|
||||
import { ExtensionRegistryInfo } from "@/registry"
|
||||
import { telemetryService } from "@/services/telemetry"
|
||||
import { McpMarketplaceCatalog } from "@/shared/mcp"
|
||||
import { ClineStorageMessage } from "@/shared/messages/content"
|
||||
import { Logger } from "@/shared/services/Logger"
|
||||
import { syncWorker } from "@/shared/services/worker/sync"
|
||||
import { reconstructTaskHistory } from "../commands/reconstructTaskHistory"
|
||||
import { StateManager } from "./StateManager"
|
||||
import type { Anthropic } from "@anthropic-ai/sdk";
|
||||
import type {
|
||||
EnvironmentMetadataEntry,
|
||||
TaskMetadata,
|
||||
} from "@core/context/context-tracking/ContextTrackerTypes";
|
||||
import { execa } from "@packages/execa";
|
||||
import type { ClineMessage } from "@shared/ExtensionMessage";
|
||||
import type { HistoryItem } from "@shared/HistoryItem";
|
||||
import type { RemoteConfig } from "@shared/remote-config/schema";
|
||||
import type { GlobalState, Settings } from "@shared/storage/state-keys";
|
||||
import { fileExistsAtPath, isDirectory } from "@utils/fs";
|
||||
import fs from "fs/promises";
|
||||
import os from "os";
|
||||
import * as path from "path";
|
||||
import { HostProvider } from "@/hosts/host-provider";
|
||||
import { ExtensionRegistryInfo } from "@/registry";
|
||||
import { telemetryService } from "@/services/telemetry";
|
||||
import type { McpMarketplaceCatalog } from "@/shared/mcp";
|
||||
import type { ClineStorageMessage } from "@/shared/messages/content";
|
||||
import { Logger } from "@/shared/services/Logger";
|
||||
import { syncWorker } from "@/shared/services/worker/sync";
|
||||
import { reconstructTaskHistory } from "../commands/reconstructTaskHistory";
|
||||
import { StateManager } from "./StateManager";
|
||||
|
||||
/**
|
||||
* Atomically write data to a file using temp file + rename pattern.
|
||||
@@ -29,16 +32,16 @@ import { StateManager } from "./StateManager"
|
||||
* @param data - The data to write
|
||||
*/
|
||||
async function atomicWriteFile(filePath: string, data: string): Promise<void> {
|
||||
const tmpPath = `${filePath}.tmp.${Date.now()}.${Math.random().toString(36).substring(7)}.json`
|
||||
const tmpPath = `${filePath}.tmp.${Date.now()}.${Math.random().toString(36).substring(7)}.json`;
|
||||
try {
|
||||
// Write to temporary file first
|
||||
await fs.writeFile(tmpPath, data, "utf8")
|
||||
await fs.writeFile(tmpPath, data, "utf8");
|
||||
// Rename temp file to target (atomic in most cases)
|
||||
await fs.rename(tmpPath, filePath)
|
||||
await fs.rename(tmpPath, filePath);
|
||||
} catch (error) {
|
||||
// Clean up temp file if it exists
|
||||
fs.unlink(tmpPath).catch(() => {})
|
||||
throw error
|
||||
fs.unlink(tmpPath).catch(() => {});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,7 +71,7 @@ export const GlobalFileNames = {
|
||||
taskMetadata: "task_metadata.json",
|
||||
mcpMarketplaceCatalog: "mcp_marketplace_catalog.json",
|
||||
remoteConfig: (orgId: string) => `remote_config_${orgId}.json`,
|
||||
}
|
||||
};
|
||||
|
||||
export async function getDocumentsPath(): Promise<string> {
|
||||
if (process.platform === "win32") {
|
||||
@@ -77,33 +80,37 @@ export async function getDocumentsPath(): Promise<string> {
|
||||
"-NoProfile", // Ignore user's PowerShell profile(s)
|
||||
"-Command",
|
||||
"[System.Environment]::GetFolderPath([System.Environment+SpecialFolder]::MyDocuments)",
|
||||
])
|
||||
const trimmedPath = docsPath.trim()
|
||||
]);
|
||||
const trimmedPath = docsPath.trim();
|
||||
if (trimmedPath) {
|
||||
return trimmedPath
|
||||
return trimmedPath;
|
||||
}
|
||||
} catch (_err) {
|
||||
Logger.error("Failed to retrieve Windows Documents path. Falling back to homedir/Documents.")
|
||||
Logger.error(
|
||||
"Failed to retrieve Windows Documents path. Falling back to homedir/Documents.",
|
||||
);
|
||||
}
|
||||
} else if (process.platform === "linux") {
|
||||
try {
|
||||
// First check if xdg-user-dir exists
|
||||
await execa("which", ["xdg-user-dir"])
|
||||
await execa("which", ["xdg-user-dir"]);
|
||||
|
||||
// If it exists, try to get XDG documents path
|
||||
const { stdout } = await execa("xdg-user-dir", ["DOCUMENTS"])
|
||||
const trimmedPath = stdout.trim()
|
||||
const { stdout } = await execa("xdg-user-dir", ["DOCUMENTS"]);
|
||||
const trimmedPath = stdout.trim();
|
||||
if (trimmedPath) {
|
||||
return trimmedPath
|
||||
return trimmedPath;
|
||||
}
|
||||
} catch {
|
||||
// Log error but continue to fallback
|
||||
Logger.error("Failed to retrieve XDG Documents path. Falling back to homedir/Documents.")
|
||||
Logger.error(
|
||||
"Failed to retrieve XDG Documents path. Falling back to homedir/Documents.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Default fallback for all platforms
|
||||
return path.join(os.homedir(), "Documents")
|
||||
return path.join(os.homedir(), "Documents");
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -116,66 +123,68 @@ export async function getDocumentsPath(): Promise<string> {
|
||||
* This is intended to eventually replace ~/Documents/Cline as the global config location.
|
||||
*/
|
||||
export function getClineHomePath(): string {
|
||||
return path.join(os.homedir(), ".cline")
|
||||
return path.join(os.homedir(), ".cline");
|
||||
}
|
||||
|
||||
export async function ensureTaskDirectoryExists(taskId: string): Promise<string> {
|
||||
return getGlobalStorageDir("tasks", taskId)
|
||||
export async function ensureTaskDirectoryExists(
|
||||
taskId: string,
|
||||
): Promise<string> {
|
||||
return getGlobalStorageDir("tasks", taskId);
|
||||
}
|
||||
|
||||
export async function ensureRulesDirectoryExists(): Promise<string> {
|
||||
const userDocumentsPath = await getDocumentsPath()
|
||||
const clineRulesDir = path.join(userDocumentsPath, "Cline", "Rules")
|
||||
const userDocumentsPath = await getDocumentsPath();
|
||||
const clineRulesDir = path.join(userDocumentsPath, "Cline", "Rules");
|
||||
try {
|
||||
await fs.mkdir(clineRulesDir, { recursive: true })
|
||||
await fs.mkdir(clineRulesDir, { recursive: true });
|
||||
} catch (_error) {
|
||||
return path.join(os.homedir(), "Documents", "Cline", "Rules") // in case creating a directory in documents fails for whatever reason (e.g. permissions) - this is fine because we will fail gracefully with a path that does not exist
|
||||
return path.join(os.homedir(), "Documents", "Cline", "Rules"); // in case creating a directory in documents fails for whatever reason (e.g. permissions) - this is fine because we will fail gracefully with a path that does not exist
|
||||
}
|
||||
return clineRulesDir
|
||||
return clineRulesDir;
|
||||
}
|
||||
|
||||
export async function ensureWorkflowsDirectoryExists(): Promise<string> {
|
||||
const userDocumentsPath = await getDocumentsPath()
|
||||
const clineWorkflowsDir = path.join(userDocumentsPath, "Cline", "Workflows")
|
||||
const userDocumentsPath = await getDocumentsPath();
|
||||
const clineWorkflowsDir = path.join(userDocumentsPath, "Cline", "Workflows");
|
||||
try {
|
||||
await fs.mkdir(clineWorkflowsDir, { recursive: true })
|
||||
await fs.mkdir(clineWorkflowsDir, { recursive: true });
|
||||
} catch (_error) {
|
||||
return path.join(os.homedir(), "Documents", "Cline", "Workflows") // in case creating a directory in documents fails for whatever reason (e.g. permissions) - this is fine because we will fail gracefully with a path that does not exist
|
||||
return path.join(os.homedir(), "Documents", "Cline", "Workflows"); // in case creating a directory in documents fails for whatever reason (e.g. permissions) - this is fine because we will fail gracefully with a path that does not exist
|
||||
}
|
||||
return clineWorkflowsDir
|
||||
return clineWorkflowsDir;
|
||||
}
|
||||
|
||||
export async function ensureMcpServersDirectoryExists(): Promise<string> {
|
||||
const userDocumentsPath = await getDocumentsPath()
|
||||
const mcpServersDir = path.join(userDocumentsPath, "Cline", "MCP")
|
||||
const userDocumentsPath = await getDocumentsPath();
|
||||
const mcpServersDir = path.join(userDocumentsPath, "Cline", "MCP");
|
||||
try {
|
||||
await fs.mkdir(mcpServersDir, { recursive: true })
|
||||
await fs.mkdir(mcpServersDir, { recursive: true });
|
||||
} catch (_error) {
|
||||
return path.join(os.homedir(), "Documents", "Cline", "MCP") // in case creating a directory in documents fails for whatever reason (e.g. permissions) - this is fine since this path is only ever used in the system prompt
|
||||
return path.join(os.homedir(), "Documents", "Cline", "MCP"); // in case creating a directory in documents fails for whatever reason (e.g. permissions) - this is fine since this path is only ever used in the system prompt
|
||||
}
|
||||
return mcpServersDir
|
||||
return mcpServersDir;
|
||||
}
|
||||
|
||||
export async function ensureHooksDirectoryExists(): Promise<string> {
|
||||
const userDocumentsPath = await getDocumentsPath()
|
||||
const clineHooksDir = path.join(userDocumentsPath, "Cline", "Hooks")
|
||||
const userDocumentsPath = await getDocumentsPath();
|
||||
const clineHooksDir = path.join(userDocumentsPath, "Cline", "Hooks");
|
||||
try {
|
||||
await fs.mkdir(clineHooksDir, { recursive: true })
|
||||
await fs.mkdir(clineHooksDir, { recursive: true });
|
||||
} catch (_error) {
|
||||
return path.join(os.homedir(), "Documents", "Cline", "Hooks") // in case creating a directory in documents fails for whatever reason (e.g. permissions) - this is fine because we will fail gracefully with a path that does not exist
|
||||
return path.join(os.homedir(), "Documents", "Cline", "Hooks"); // in case creating a directory in documents fails for whatever reason (e.g. permissions) - this is fine because we will fail gracefully with a path that does not exist
|
||||
}
|
||||
return clineHooksDir
|
||||
return clineHooksDir;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the global skills directory path (~/.cline/skills) without creating it.
|
||||
*/
|
||||
function getClineSkillsDirectoryPath(): string {
|
||||
return path.join(getClineHomePath(), "skills")
|
||||
return path.join(getClineHomePath(), "skills");
|
||||
}
|
||||
|
||||
function getAgentSkillsDirectoryPath(): string {
|
||||
return path.join(os.homedir(), ".agents", "skills")
|
||||
return path.join(os.homedir(), ".agents", "skills");
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -183,41 +192,55 @@ function getAgentSkillsDirectoryPath(): string {
|
||||
* Creates the directory if it doesn't exist.
|
||||
* This is the opinionated location for new global skills.
|
||||
*/
|
||||
export async function ensureAgentSkillsDirectoryExists(options: { isGlobal: boolean; workspacePath?: string }): Promise<string> {
|
||||
export async function ensureAgentSkillsDirectoryExists(options: {
|
||||
isGlobal: boolean;
|
||||
workspacePath?: string;
|
||||
}): Promise<string> {
|
||||
const agentSkillsDir = options.isGlobal
|
||||
? getAgentSkillsDirectoryPath()
|
||||
: path.join(options.workspacePath ?? "", GlobalFileNames.agentsSkillsDir)
|
||||
: path.join(options.workspacePath ?? "", GlobalFileNames.agentsSkillsDir);
|
||||
try {
|
||||
await fs.mkdir(agentSkillsDir, { recursive: true })
|
||||
await fs.mkdir(agentSkillsDir, { recursive: true });
|
||||
} catch (_error) {
|
||||
// Fallback - return the path even if mkdir fails, we'll fail gracefully later
|
||||
return agentSkillsDir
|
||||
return agentSkillsDir;
|
||||
}
|
||||
return agentSkillsDir
|
||||
return agentSkillsDir;
|
||||
}
|
||||
|
||||
export type SkillsScanDirectory = {
|
||||
path: string
|
||||
source: "project" | "global"
|
||||
}
|
||||
path: string;
|
||||
source: "project" | "global";
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns the list of skills directories to scan without creating them.
|
||||
* Order is project directories first, then global directories.
|
||||
*/
|
||||
export function getSkillsDirectoriesForScan(cwd: string): SkillsScanDirectory[] {
|
||||
export function getSkillsDirectoriesForScan(
|
||||
cwd: string,
|
||||
): SkillsScanDirectory[] {
|
||||
return [
|
||||
{ path: path.join(cwd, GlobalFileNames.clineruleSkillsDir), source: "project" },
|
||||
{
|
||||
path: path.join(cwd, GlobalFileNames.clineruleSkillsDir),
|
||||
source: "project",
|
||||
},
|
||||
{ path: path.join(cwd, GlobalFileNames.clineSkillsDir), source: "project" },
|
||||
{ path: path.join(cwd, GlobalFileNames.claudeSkillsDir), source: "project" },
|
||||
{ path: path.join(cwd, GlobalFileNames.agentsSkillsDir), source: "project" },
|
||||
{
|
||||
path: path.join(cwd, GlobalFileNames.claudeSkillsDir),
|
||||
source: "project",
|
||||
},
|
||||
{
|
||||
path: path.join(cwd, GlobalFileNames.agentsSkillsDir),
|
||||
source: "project",
|
||||
},
|
||||
{ path: getClineSkillsDirectoryPath(), source: "global" },
|
||||
{ path: getAgentSkillsDirectoryPath(), source: "global" },
|
||||
]
|
||||
];
|
||||
}
|
||||
|
||||
export async function ensureSettingsDirectoryExists(): Promise<string> {
|
||||
return getGlobalStorageDir("settings")
|
||||
return getGlobalStorageDir("settings");
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -225,63 +248,93 @@ export async function ensureSettingsDirectoryExists(): Promise<string> {
|
||||
* @param settingsDirectoryPath Path to the settings directory
|
||||
* @returns Path to the MCP settings file
|
||||
*/
|
||||
export async function getMcpSettingsFilePath(settingsDirectoryPath: string): Promise<string> {
|
||||
const mcpSettingsFilePath = path.join(settingsDirectoryPath, GlobalFileNames.mcpSettings)
|
||||
const fileExists = await fileExistsAtPath(mcpSettingsFilePath)
|
||||
export async function getMcpSettingsFilePath(
|
||||
settingsDirectoryPath: string,
|
||||
): Promise<string> {
|
||||
const mcpSettingsFilePath = path.join(
|
||||
settingsDirectoryPath,
|
||||
GlobalFileNames.mcpSettings,
|
||||
);
|
||||
const fileExists = await fileExistsAtPath(mcpSettingsFilePath);
|
||||
if (!fileExists) {
|
||||
await fs.writeFile(mcpSettingsFilePath, JSON.stringify({ mcpServers: {} }, null, 2))
|
||||
await fs.writeFile(
|
||||
mcpSettingsFilePath,
|
||||
JSON.stringify({ mcpServers: {} }, null, 2),
|
||||
);
|
||||
}
|
||||
return mcpSettingsFilePath
|
||||
return mcpSettingsFilePath;
|
||||
}
|
||||
|
||||
export async function getSavedApiConversationHistory(taskId: string): Promise<ClineStorageMessage[]> {
|
||||
const filePath = path.join(await ensureTaskDirectoryExists(taskId), GlobalFileNames.apiConversationHistory)
|
||||
const fileExists = await fileExistsAtPath(filePath)
|
||||
export async function getSavedApiConversationHistory(
|
||||
taskId: string,
|
||||
): Promise<ClineStorageMessage[]> {
|
||||
const filePath = path.join(
|
||||
await ensureTaskDirectoryExists(taskId),
|
||||
GlobalFileNames.apiConversationHistory,
|
||||
);
|
||||
const fileExists = await fileExistsAtPath(filePath);
|
||||
if (fileExists) {
|
||||
return JSON.parse(await fs.readFile(filePath, "utf8"))
|
||||
return JSON.parse(await fs.readFile(filePath, "utf8"));
|
||||
}
|
||||
return []
|
||||
return [];
|
||||
}
|
||||
|
||||
export async function saveApiConversationHistory(taskId: string, apiConversationHistory: Anthropic.MessageParam[]) {
|
||||
export async function saveApiConversationHistory(
|
||||
taskId: string,
|
||||
apiConversationHistory: Anthropic.MessageParam[],
|
||||
) {
|
||||
try {
|
||||
if (apiConversationHistory.length > 0) {
|
||||
const fileName = GlobalFileNames.apiConversationHistory
|
||||
const data = JSON.stringify(apiConversationHistory)
|
||||
const fileName = GlobalFileNames.apiConversationHistory;
|
||||
const data = JSON.stringify(apiConversationHistory);
|
||||
// Queue for remote sync without blocking
|
||||
syncWorker().enqueue(taskId, fileName, data)
|
||||
syncWorker().enqueue(taskId, fileName, data);
|
||||
// Store locally
|
||||
const filePath = path.join(await ensureTaskDirectoryExists(taskId), fileName)
|
||||
await atomicWriteFile(filePath, data)
|
||||
const filePath = path.join(
|
||||
await ensureTaskDirectoryExists(taskId),
|
||||
fileName,
|
||||
);
|
||||
await atomicWriteFile(filePath, data);
|
||||
}
|
||||
} catch (error) {
|
||||
// in the off chance this fails, we don't want to stop the task
|
||||
Logger.error("Failed to save API conversation history:", error)
|
||||
Logger.error("Failed to save API conversation history:", error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function getSavedClineMessages(taskId: string): Promise<ClineMessage[]> {
|
||||
const filePath = path.join(await ensureTaskDirectoryExists(taskId), GlobalFileNames.uiMessages)
|
||||
export async function getSavedClineMessages(
|
||||
taskId: string,
|
||||
): Promise<ClineMessage[]> {
|
||||
const filePath = path.join(
|
||||
await ensureTaskDirectoryExists(taskId),
|
||||
GlobalFileNames.uiMessages,
|
||||
);
|
||||
if (await fileExistsAtPath(filePath)) {
|
||||
return JSON.parse(await fs.readFile(filePath, "utf8"))
|
||||
return JSON.parse(await fs.readFile(filePath, "utf8"));
|
||||
}
|
||||
// check old location
|
||||
const oldPath = path.join(await ensureTaskDirectoryExists(taskId), "claude_messages.json")
|
||||
const oldPath = path.join(
|
||||
await ensureTaskDirectoryExists(taskId),
|
||||
"claude_messages.json",
|
||||
);
|
||||
if (await fileExistsAtPath(oldPath)) {
|
||||
const data = JSON.parse(await fs.readFile(oldPath, "utf8"))
|
||||
await fs.unlink(oldPath) // remove old file
|
||||
return data
|
||||
const data = JSON.parse(await fs.readFile(oldPath, "utf8"));
|
||||
await fs.unlink(oldPath); // remove old file
|
||||
return data;
|
||||
}
|
||||
return []
|
||||
return [];
|
||||
}
|
||||
|
||||
export async function saveClineMessages(taskId: string, uiMessages: ClineMessage[]) {
|
||||
export async function saveClineMessages(
|
||||
taskId: string,
|
||||
uiMessages: ClineMessage[],
|
||||
) {
|
||||
try {
|
||||
const taskDir = await ensureTaskDirectoryExists(taskId)
|
||||
const filePath = path.join(taskDir, GlobalFileNames.uiMessages)
|
||||
await atomicWriteFile(filePath, JSON.stringify(uiMessages))
|
||||
const taskDir = await ensureTaskDirectoryExists(taskId);
|
||||
const filePath = path.join(taskDir, GlobalFileNames.uiMessages);
|
||||
await atomicWriteFile(filePath, JSON.stringify(uiMessages));
|
||||
} catch (error) {
|
||||
Logger.error("Failed to save ui messages:", error)
|
||||
Logger.error("Failed to save ui messages:", error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -290,9 +343,11 @@ export async function saveClineMessages(taskId: string, uiMessages: ClineMessage
|
||||
* This information is used for debugging and task portability.
|
||||
* Returns metadata without timestamp - timestamp is added by EnvironmentContextTracker.
|
||||
*/
|
||||
export async function collectEnvironmentMetadata(): Promise<Omit<EnvironmentMetadataEntry, "ts">> {
|
||||
export async function collectEnvironmentMetadata(): Promise<
|
||||
Omit<EnvironmentMetadataEntry, "ts">
|
||||
> {
|
||||
try {
|
||||
const hostVersion = await HostProvider.env.getHostVersion({})
|
||||
const hostVersion = await HostProvider.env.getHostVersion({});
|
||||
|
||||
return {
|
||||
os_name: os.platform(),
|
||||
@@ -301,9 +356,9 @@ export async function collectEnvironmentMetadata(): Promise<Omit<EnvironmentMeta
|
||||
host_name: hostVersion.platform || "Unknown",
|
||||
host_version: hostVersion.version || "Unknown",
|
||||
cline_version: ExtensionRegistryInfo.version,
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
Logger.error("Failed to collect environment metadata:", error)
|
||||
Logger.error("Failed to collect environment metadata:", error);
|
||||
// Return fallback values if collection fails
|
||||
return {
|
||||
os_name: os.platform(),
|
||||
@@ -312,191 +367,245 @@ export async function collectEnvironmentMetadata(): Promise<Omit<EnvironmentMeta
|
||||
host_name: "Unknown",
|
||||
host_version: "Unknown",
|
||||
cline_version: "Unknown",
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function getTaskMetadata(taskId: string): Promise<TaskMetadata> {
|
||||
const filePath = path.join(await ensureTaskDirectoryExists(taskId), GlobalFileNames.taskMetadata)
|
||||
const filePath = path.join(
|
||||
await ensureTaskDirectoryExists(taskId),
|
||||
GlobalFileNames.taskMetadata,
|
||||
);
|
||||
try {
|
||||
if (await fileExistsAtPath(filePath)) {
|
||||
return JSON.parse(await fs.readFile(filePath, "utf8"))
|
||||
return JSON.parse(await fs.readFile(filePath, "utf8"));
|
||||
}
|
||||
} catch (error) {
|
||||
Logger.error("Failed to read task metadata:", error)
|
||||
Logger.error("Failed to read task metadata:", error);
|
||||
}
|
||||
return { files_in_context: [], model_usage: [], environment_history: [] }
|
||||
return { files_in_context: [], model_usage: [], environment_history: [] };
|
||||
}
|
||||
|
||||
export async function saveTaskMetadata(taskId: string, metadata: TaskMetadata) {
|
||||
try {
|
||||
const taskDir = await ensureTaskDirectoryExists(taskId)
|
||||
const filePath = path.join(taskDir, GlobalFileNames.taskMetadata)
|
||||
await fs.writeFile(filePath, JSON.stringify(metadata, null, 2))
|
||||
const taskDir = await ensureTaskDirectoryExists(taskId);
|
||||
const filePath = path.join(taskDir, GlobalFileNames.taskMetadata);
|
||||
await fs.writeFile(filePath, JSON.stringify(metadata, null, 2));
|
||||
} catch (error) {
|
||||
Logger.error("Failed to save task metadata:", error)
|
||||
Logger.error("Failed to save task metadata:", error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function ensureStateDirectoryExists(): Promise<string> {
|
||||
return getGlobalStorageDir("state")
|
||||
return getGlobalStorageDir("state");
|
||||
}
|
||||
|
||||
export async function ensureCacheDirectoryExists(): Promise<string> {
|
||||
return getGlobalStorageDir("cache")
|
||||
return getGlobalStorageDir("cache");
|
||||
}
|
||||
|
||||
export async function readMcpMarketplaceCatalogFromCache(): Promise<McpMarketplaceCatalog | undefined> {
|
||||
export async function readMcpMarketplaceCatalogFromCache(): Promise<
|
||||
McpMarketplaceCatalog | undefined
|
||||
> {
|
||||
try {
|
||||
const mcpMarketplaceCatalogFilePath = path.join(await ensureCacheDirectoryExists(), GlobalFileNames.mcpMarketplaceCatalog)
|
||||
const fileExists = await fileExistsAtPath(mcpMarketplaceCatalogFilePath)
|
||||
const mcpMarketplaceCatalogFilePath = path.join(
|
||||
await ensureCacheDirectoryExists(),
|
||||
GlobalFileNames.mcpMarketplaceCatalog,
|
||||
);
|
||||
const fileExists = await fileExistsAtPath(mcpMarketplaceCatalogFilePath);
|
||||
if (fileExists) {
|
||||
const fileContents = await fs.readFile(mcpMarketplaceCatalogFilePath, "utf8")
|
||||
return JSON.parse(fileContents)
|
||||
const fileContents = await fs.readFile(
|
||||
mcpMarketplaceCatalogFilePath,
|
||||
"utf8",
|
||||
);
|
||||
return JSON.parse(fileContents);
|
||||
}
|
||||
return undefined
|
||||
return undefined;
|
||||
} catch (error) {
|
||||
Logger.error("Failed to read MCP marketplace catalog from cache:", error)
|
||||
return undefined
|
||||
Logger.error("Failed to read MCP marketplace catalog from cache:", error);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export async function writeMcpMarketplaceCatalogToCache(catalog: McpMarketplaceCatalog): Promise<void> {
|
||||
export async function writeMcpMarketplaceCatalogToCache(
|
||||
catalog: McpMarketplaceCatalog,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const mcpMarketplaceCatalogFilePath = path.join(await ensureCacheDirectoryExists(), GlobalFileNames.mcpMarketplaceCatalog)
|
||||
await fs.writeFile(mcpMarketplaceCatalogFilePath, JSON.stringify(catalog))
|
||||
const mcpMarketplaceCatalogFilePath = path.join(
|
||||
await ensureCacheDirectoryExists(),
|
||||
GlobalFileNames.mcpMarketplaceCatalog,
|
||||
);
|
||||
await fs.writeFile(mcpMarketplaceCatalogFilePath, JSON.stringify(catalog));
|
||||
} catch (error) {
|
||||
Logger.error("Failed to write MCP marketplace catalog to cache:", error)
|
||||
Logger.error("Failed to write MCP marketplace catalog to cache:", error);
|
||||
}
|
||||
}
|
||||
|
||||
async function getGlobalStorageDir(...subdirs: string[]) {
|
||||
const fullPath = path.resolve(HostProvider.get().globalStorageFsPath, ...subdirs)
|
||||
await fs.mkdir(fullPath, { recursive: true })
|
||||
return fullPath
|
||||
const fullPath = path.resolve(
|
||||
HostProvider.get().globalStorageFsPath,
|
||||
...subdirs,
|
||||
);
|
||||
await fs.mkdir(fullPath, { recursive: true });
|
||||
return fullPath;
|
||||
}
|
||||
|
||||
export async function getTaskHistoryStateFilePath(): Promise<string> {
|
||||
return path.join(await ensureStateDirectoryExists(), "taskHistory.json")
|
||||
return path.join(await ensureStateDirectoryExists(), "taskHistory.json");
|
||||
}
|
||||
|
||||
export async function taskHistoryStateFileExists(): Promise<boolean> {
|
||||
const filePath = await getTaskHistoryStateFilePath()
|
||||
return fileExistsAtPath(filePath)
|
||||
const filePath = await getTaskHistoryStateFilePath();
|
||||
return fileExistsAtPath(filePath);
|
||||
}
|
||||
|
||||
export async function readTaskHistoryFromState(): Promise<HistoryItem[]> {
|
||||
try {
|
||||
const filePath = await getTaskHistoryStateFilePath()
|
||||
const filePath = await getTaskHistoryStateFilePath();
|
||||
if (!(await fileExistsAtPath(filePath))) {
|
||||
return []
|
||||
return [];
|
||||
}
|
||||
|
||||
const contents = await fs.readFile(filePath, "utf8")
|
||||
const contents = await fs.readFile(filePath, "utf8");
|
||||
|
||||
try {
|
||||
return JSON.parse(contents)
|
||||
return JSON.parse(contents);
|
||||
} catch (parseError) {
|
||||
telemetryService.captureExtensionStorageError(parseError, "parseError_attemptingRecovery")
|
||||
telemetryService.captureExtensionStorageError(
|
||||
parseError,
|
||||
"parseError_attemptingRecovery",
|
||||
);
|
||||
|
||||
const result = await reconstructTaskHistory(false)
|
||||
const result = await reconstructTaskHistory(false);
|
||||
if (result && result.reconstructedTasks > 0) {
|
||||
// Read the reconstructed file
|
||||
const newContents = await fs.readFile(filePath, "utf8")
|
||||
return JSON.parse(newContents)
|
||||
const newContents = await fs.readFile(filePath, "utf8");
|
||||
return JSON.parse(newContents);
|
||||
}
|
||||
|
||||
// Recovery failed, all we can do is return an empty array or throw an error, thus preventing the app from starting up
|
||||
// This will wipe out the taskHistory
|
||||
return []
|
||||
return [];
|
||||
}
|
||||
} catch (error) {
|
||||
// Filesystem or other errors - throw them for the caller to handle
|
||||
telemetryService.captureExtensionStorageError(error, "readTaskHistoryFromState")
|
||||
throw error
|
||||
telemetryService.captureExtensionStorageError(
|
||||
error,
|
||||
"readTaskHistoryFromState",
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function writeTaskHistoryToState(items: HistoryItem[]): Promise<void> {
|
||||
export async function writeTaskHistoryToState(
|
||||
items: HistoryItem[],
|
||||
): Promise<void> {
|
||||
try {
|
||||
const filePath = await getTaskHistoryStateFilePath()
|
||||
await atomicWriteFile(filePath, JSON.stringify(items))
|
||||
const filePath = await getTaskHistoryStateFilePath();
|
||||
await atomicWriteFile(filePath, JSON.stringify(items));
|
||||
} catch (error) {
|
||||
Logger.error("[Disk] Failed to write task history:", error)
|
||||
throw error
|
||||
Logger.error("[Disk] Failed to write task history:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function readTaskSettingsFromStorage(taskId: string): Promise<Partial<GlobalState>> {
|
||||
export async function readTaskSettingsFromStorage(
|
||||
taskId: string,
|
||||
): Promise<Partial<GlobalState>> {
|
||||
try {
|
||||
const taskDirectoryFilePath = await ensureTaskDirectoryExists(taskId)
|
||||
const settingsFilePath = path.join(taskDirectoryFilePath, "settings.json")
|
||||
const taskDirectoryFilePath = await ensureTaskDirectoryExists(taskId);
|
||||
const settingsFilePath = path.join(taskDirectoryFilePath, "settings.json");
|
||||
|
||||
if (await fileExistsAtPath(settingsFilePath)) {
|
||||
const settingsContent = await fs.readFile(settingsFilePath, "utf8")
|
||||
return JSON.parse(settingsContent)
|
||||
const settingsContent = await fs.readFile(settingsFilePath, "utf8");
|
||||
return JSON.parse(settingsContent);
|
||||
}
|
||||
|
||||
// Return empty object if settings file doesn't exist (new task)
|
||||
return {}
|
||||
return {};
|
||||
} catch (error) {
|
||||
Logger.error("[Disk] Failed to read task settings:", error)
|
||||
throw error
|
||||
Logger.error("[Disk] Failed to read task settings:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function writeTaskSettingsToStorage(taskId: string, settings: Partial<Settings>) {
|
||||
export async function writeTaskSettingsToStorage(
|
||||
taskId: string,
|
||||
settings: Partial<Settings>,
|
||||
) {
|
||||
try {
|
||||
const taskDirectoryFilePath = await ensureTaskDirectoryExists(taskId)
|
||||
const settingsFilePath = path.join(taskDirectoryFilePath, "settings.json")
|
||||
const taskDirectoryFilePath = await ensureTaskDirectoryExists(taskId);
|
||||
const settingsFilePath = path.join(taskDirectoryFilePath, "settings.json");
|
||||
|
||||
let existingSettings = {}
|
||||
let existingSettings = {};
|
||||
if (await fileExistsAtPath(settingsFilePath)) {
|
||||
const existingSettingsContent = await fs.readFile(settingsFilePath, "utf8")
|
||||
existingSettings = JSON.parse(existingSettingsContent)
|
||||
const existingSettingsContent = await fs.readFile(
|
||||
settingsFilePath,
|
||||
"utf8",
|
||||
);
|
||||
existingSettings = JSON.parse(existingSettingsContent);
|
||||
}
|
||||
|
||||
const updatedSettings = { ...existingSettings, ...settings }
|
||||
await fs.writeFile(settingsFilePath, JSON.stringify(updatedSettings, null, 2))
|
||||
const updatedSettings = { ...existingSettings, ...settings };
|
||||
await fs.writeFile(
|
||||
settingsFilePath,
|
||||
JSON.stringify(updatedSettings, null, 2),
|
||||
);
|
||||
} catch (error) {
|
||||
Logger.error("[Disk] Failed to write task settings:", error)
|
||||
throw error
|
||||
Logger.error("[Disk] Failed to write task settings:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function readRemoteConfigFromCache(organizationId: string): Promise<RemoteConfig | undefined> {
|
||||
export async function readRemoteConfigFromCache(
|
||||
organizationId: string,
|
||||
): Promise<RemoteConfig | undefined> {
|
||||
try {
|
||||
const remoteConfigFilePath = path.join(await ensureCacheDirectoryExists(), GlobalFileNames.remoteConfig(organizationId))
|
||||
const fileExists = await fileExistsAtPath(remoteConfigFilePath)
|
||||
const remoteConfigFilePath = path.join(
|
||||
await ensureCacheDirectoryExists(),
|
||||
GlobalFileNames.remoteConfig(organizationId),
|
||||
);
|
||||
const fileExists = await fileExistsAtPath(remoteConfigFilePath);
|
||||
if (fileExists) {
|
||||
const fileContents = await fs.readFile(remoteConfigFilePath, "utf8")
|
||||
return JSON.parse(fileContents)
|
||||
const fileContents = await fs.readFile(remoteConfigFilePath, "utf8");
|
||||
return JSON.parse(fileContents);
|
||||
}
|
||||
return undefined
|
||||
return undefined;
|
||||
} catch (error) {
|
||||
Logger.error("Failed to read remote config from cache:", error)
|
||||
return undefined
|
||||
Logger.error("Failed to read remote config from cache:", error);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export async function writeRemoteConfigToCache(organizationId: string, config: RemoteConfig): Promise<void> {
|
||||
export async function writeRemoteConfigToCache(
|
||||
organizationId: string,
|
||||
config: RemoteConfig,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const remoteConfigFilePath = path.join(await ensureCacheDirectoryExists(), GlobalFileNames.remoteConfig(organizationId))
|
||||
await fs.writeFile(remoteConfigFilePath, JSON.stringify(config))
|
||||
const remoteConfigFilePath = path.join(
|
||||
await ensureCacheDirectoryExists(),
|
||||
GlobalFileNames.remoteConfig(organizationId),
|
||||
);
|
||||
await fs.writeFile(remoteConfigFilePath, JSON.stringify(config));
|
||||
} catch (error) {
|
||||
Logger.error("Failed to write remote config to cache:", error)
|
||||
Logger.error("Failed to write remote config to cache:", error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteRemoteConfigFromCache(organizationId: string): Promise<void> {
|
||||
export async function deleteRemoteConfigFromCache(
|
||||
organizationId: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const remoteConfigFilePath = path.join(await ensureCacheDirectoryExists(), GlobalFileNames.remoteConfig(organizationId))
|
||||
const fileExists = await fileExistsAtPath(remoteConfigFilePath)
|
||||
const remoteConfigFilePath = path.join(
|
||||
await ensureCacheDirectoryExists(),
|
||||
GlobalFileNames.remoteConfig(organizationId),
|
||||
);
|
||||
const fileExists = await fileExistsAtPath(remoteConfigFilePath);
|
||||
if (fileExists) {
|
||||
await fs.unlink(remoteConfigFilePath)
|
||||
await fs.unlink(remoteConfigFilePath);
|
||||
}
|
||||
} catch (error) {
|
||||
Logger.error("Failed to delete remote config from cache:", error)
|
||||
Logger.error("Failed to delete remote config from cache:", error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -505,11 +614,11 @@ export async function deleteRemoteConfigFromCache(organizationId: string): Promi
|
||||
* Returns undefined if the directory doesn't exist.
|
||||
*/
|
||||
export async function getGlobalHooksDir(): Promise<string | undefined> {
|
||||
const globalHooksDir = await ensureHooksDirectoryExists()
|
||||
return (await isDirectory(globalHooksDir)) ? globalHooksDir : undefined
|
||||
const globalHooksDir = await ensureHooksDirectoryExists();
|
||||
return (await isDirectory(globalHooksDir)) ? globalHooksDir : undefined;
|
||||
}
|
||||
|
||||
let runtimeHooksDir: string | undefined
|
||||
let runtimeHooksDir: string | undefined;
|
||||
|
||||
/**
|
||||
* Sets a runtime hooks directory, typically passed via the --hooks-dir CLI flag.
|
||||
@@ -517,7 +626,7 @@ let runtimeHooksDir: string | undefined
|
||||
* when discovering hooks.
|
||||
*/
|
||||
export function setRuntimeHooksDir(dir: string | undefined): void {
|
||||
runtimeHooksDir = dir
|
||||
runtimeHooksDir = dir;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -532,24 +641,24 @@ export function setRuntimeHooksDir(dir: string | undefined): void {
|
||||
* multi-root workspace may have multiple hooks directories.
|
||||
*/
|
||||
export async function getAllHooksDirs(): Promise<string[]> {
|
||||
const hooksDirs: string[] = []
|
||||
const hooksDirs: string[] = [];
|
||||
|
||||
// Add runtime hooks directory (set by --hooks-dir CLI flag)
|
||||
if (runtimeHooksDir && (await isDirectory(runtimeHooksDir))) {
|
||||
hooksDirs.push(runtimeHooksDir)
|
||||
hooksDirs.push(runtimeHooksDir);
|
||||
}
|
||||
|
||||
// Add global hooks directory (if it exists)
|
||||
const globalHooksDir = await getGlobalHooksDir()
|
||||
const globalHooksDir = await getGlobalHooksDir();
|
||||
if (globalHooksDir) {
|
||||
hooksDirs.push(globalHooksDir)
|
||||
hooksDirs.push(globalHooksDir);
|
||||
}
|
||||
|
||||
// Add workspace hooks directories
|
||||
const workspaceHooksDirs = await getWorkspaceHooksDirs()
|
||||
hooksDirs.push(...workspaceHooksDirs)
|
||||
const workspaceHooksDirs = await getWorkspaceHooksDirs();
|
||||
hooksDirs.push(...workspaceHooksDirs);
|
||||
|
||||
return hooksDirs
|
||||
return hooksDirs;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -561,17 +670,20 @@ export async function getWorkspaceHooksDirs(): Promise<string[]> {
|
||||
const workspaceRootPaths =
|
||||
StateManager.get()
|
||||
.getGlobalStateKey("workspaceRoots")
|
||||
?.map((root) => root.path) || []
|
||||
?.map((root) => root.path) || [];
|
||||
|
||||
return (
|
||||
await Promise.all(
|
||||
workspaceRootPaths.map(async (workspaceRootPath) => {
|
||||
// Look for a .clinerules/hooks folder in this workspace root.
|
||||
const candidate = path.join(workspaceRootPath, GlobalFileNames.hooksDir)
|
||||
return (await isDirectory(candidate)) ? candidate : undefined
|
||||
const candidate = path.join(
|
||||
workspaceRootPath,
|
||||
GlobalFileNames.hooksDir,
|
||||
);
|
||||
return (await isDirectory(candidate)) ? candidate : undefined;
|
||||
}),
|
||||
)
|
||||
).filter((path): path is string => Boolean(path))
|
||||
).filter((path): path is string => Boolean(path));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -589,17 +701,20 @@ export async function writeConversationHistoryJson(
|
||||
apiConversationHistory: Anthropic.MessageParam[],
|
||||
timestamp?: number,
|
||||
): Promise<string> {
|
||||
const taskDir = await ensureTaskDirectoryExists(taskId)
|
||||
const fileTimestamp = timestamp ?? Date.now()
|
||||
const tempFileName = `conversation_history_${fileTimestamp}.json`
|
||||
const tempFilePath = path.join(taskDir, tempFileName)
|
||||
const taskDir = await ensureTaskDirectoryExists(taskId);
|
||||
const fileTimestamp = timestamp ?? Date.now();
|
||||
const tempFileName = `conversation_history_${fileTimestamp}.json`;
|
||||
const tempFilePath = path.join(taskDir, tempFileName);
|
||||
|
||||
try {
|
||||
await atomicWriteFile(tempFilePath, JSON.stringify(apiConversationHistory, null, 2))
|
||||
return tempFilePath
|
||||
await atomicWriteFile(
|
||||
tempFilePath,
|
||||
JSON.stringify(apiConversationHistory, null, 2),
|
||||
);
|
||||
return tempFilePath;
|
||||
} catch (error) {
|
||||
Logger.error("Failed to write conversation history JSON for hook:", error)
|
||||
throw error
|
||||
Logger.error("Failed to write conversation history JSON for hook:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -609,14 +724,20 @@ export async function writeConversationHistoryJson(
|
||||
*
|
||||
* @param filePath The path to the temporary file to delete
|
||||
*/
|
||||
export async function cleanupConversationHistoryFile(filePath: string): Promise<void> {
|
||||
export async function cleanupConversationHistoryFile(
|
||||
filePath: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
if (await fileExistsAtPath(filePath)) {
|
||||
await fs.unlink(filePath)
|
||||
await fs.unlink(filePath);
|
||||
}
|
||||
} catch (error) {
|
||||
// Silently handle errors - this is cleanup, not critical
|
||||
Logger.debug("Failed to cleanup conversation history file:", filePath, error)
|
||||
Logger.debug(
|
||||
"Failed to cleanup conversation history file:",
|
||||
filePath,
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -635,59 +756,59 @@ export async function writeConversationHistoryText(
|
||||
conversationHistory: Anthropic.MessageParam[],
|
||||
timestamp?: number,
|
||||
): Promise<string> {
|
||||
const taskDir = await ensureTaskDirectoryExists(taskId)
|
||||
const fileTimestamp = timestamp ?? Date.now()
|
||||
const tempFileName = `conversation_history_${fileTimestamp}.txt`
|
||||
const tempFilePath = path.join(taskDir, tempFileName)
|
||||
const taskDir = await ensureTaskDirectoryExists(taskId);
|
||||
const fileTimestamp = timestamp ?? Date.now();
|
||||
const tempFileName = `conversation_history_${fileTimestamp}.txt`;
|
||||
const tempFilePath = path.join(taskDir, tempFileName);
|
||||
|
||||
try {
|
||||
// Build the formatted conversation history (excluding system prompt)
|
||||
let fullContext = "=== CONVERSATION HISTORY ===\n\n"
|
||||
let fullContext = "=== CONVERSATION HISTORY ===\n\n";
|
||||
|
||||
// Format each message in the conversation
|
||||
for (let i = 0; i < conversationHistory.length; i++) {
|
||||
const message = conversationHistory[i]
|
||||
fullContext += `--- Message ${i + 1} (${message.role.toUpperCase()}) ---\n`
|
||||
const message = conversationHistory[i];
|
||||
fullContext += `--- Message ${i + 1} (${message.role.toUpperCase()}) ---\n`;
|
||||
|
||||
// Handle content which can be a string or array
|
||||
if (typeof message.content === "string") {
|
||||
fullContext += message.content
|
||||
fullContext += message.content;
|
||||
} else if (Array.isArray(message.content)) {
|
||||
for (const block of message.content) {
|
||||
if (block.type === "text") {
|
||||
fullContext += block.text
|
||||
fullContext += block.text;
|
||||
} else if (block.type === "image") {
|
||||
fullContext += `[IMAGE: ${block.source?.type || "unknown"}]`
|
||||
fullContext += `[IMAGE: ${block.source?.type || "unknown"}]`;
|
||||
} else if (block.type === "tool_use") {
|
||||
fullContext += `[TOOL USE: ${block.name}]\n`
|
||||
fullContext += `Input: ${JSON.stringify(block.input, null, 2)}`
|
||||
fullContext += `[TOOL USE: ${block.name}]\n`;
|
||||
fullContext += `Input: ${JSON.stringify(block.input, null, 2)}`;
|
||||
} else if (block.type === "tool_result") {
|
||||
fullContext += `[TOOL RESULT: ${block.tool_use_id}]\n`
|
||||
fullContext += `[TOOL RESULT: ${block.tool_use_id}]\n`;
|
||||
if (typeof block.content === "string") {
|
||||
fullContext += block.content
|
||||
fullContext += block.content;
|
||||
} else if (Array.isArray(block.content)) {
|
||||
for (const resultBlock of block.content) {
|
||||
if (resultBlock.type === "text") {
|
||||
fullContext += resultBlock.text
|
||||
fullContext += resultBlock.text;
|
||||
} else if (resultBlock.type === "image") {
|
||||
fullContext += `[IMAGE]`
|
||||
fullContext += `[IMAGE]`;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
fullContext += "\n\n"
|
||||
fullContext += "\n\n";
|
||||
}
|
||||
}
|
||||
|
||||
fullContext += "\n"
|
||||
fullContext += "\n";
|
||||
}
|
||||
|
||||
fullContext += "=== END OF CONTEXT ===\n"
|
||||
fullContext += "=== END OF CONTEXT ===\n";
|
||||
|
||||
await atomicWriteFile(tempFilePath, fullContext)
|
||||
return tempFilePath
|
||||
await atomicWriteFile(tempFilePath, fullContext);
|
||||
return tempFilePath;
|
||||
} catch (error) {
|
||||
Logger.error("Failed to write conversation history text for hook:", error)
|
||||
throw error
|
||||
Logger.error("Failed to write conversation history text for hook:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
+1981
-1321
File diff suppressed because it is too large
Load Diff
@@ -1,33 +1,37 @@
|
||||
import type { Anthropic } from "@anthropic-ai/sdk"
|
||||
import type { Anthropic } from "@anthropic-ai/sdk";
|
||||
|
||||
/**
|
||||
* Filters out image blocks from messages since Claude Code doesn't support images.
|
||||
* Replaces image blocks with text placeholders similar to how VSCode LM provider handles it.
|
||||
*/
|
||||
export function filterMessagesForClaudeCode(messages: Anthropic.Messages.MessageParam[]): Anthropic.Messages.MessageParam[] {
|
||||
export function filterMessagesForClaudeCode(
|
||||
messages: Anthropic.Messages.MessageParam[],
|
||||
): Anthropic.Messages.MessageParam[] {
|
||||
return messages.map((message) => {
|
||||
// Handle simple string messages
|
||||
if (typeof message.content === "string") {
|
||||
return message
|
||||
return message;
|
||||
}
|
||||
|
||||
// Handle complex message structures
|
||||
const filteredContent = message.content.map((block) => {
|
||||
if (block.type === "image") {
|
||||
// Replace image blocks with text placeholders
|
||||
const sourceType = block.source?.type || "unknown"
|
||||
const mediaType = (block.source?.type === "base64" && block.source.media_type) || "unknown"
|
||||
const sourceType = block.source?.type || "unknown";
|
||||
const mediaType =
|
||||
(block.source?.type === "base64" && block.source.media_type) ||
|
||||
"unknown";
|
||||
return {
|
||||
type: "text" as const,
|
||||
text: `[Image (${sourceType}): ${mediaType} not supported by Claude Code]`,
|
||||
}
|
||||
};
|
||||
}
|
||||
return block
|
||||
})
|
||||
return block;
|
||||
});
|
||||
|
||||
return {
|
||||
...message,
|
||||
content: filteredContent,
|
||||
}
|
||||
})
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@@ -5107,17 +5107,17 @@ export const mainlandZAiModels = {
|
||||
export type FireworksModelId = keyof typeof fireworksModels
|
||||
export const fireworksDefaultModelId: FireworksModelId = "accounts/fireworks/models/kimi-k2p6"
|
||||
export const fireworksModels = {
|
||||
"accounts/fireworks/models/kimi-k2p5": {
|
||||
maxTokens: 256000,
|
||||
contextWindow: 256000,
|
||||
"accounts/fireworks/models/kimi-k2p7-code": {
|
||||
maxTokens: 262000,
|
||||
contextWindow: 262000,
|
||||
supportsImages: true,
|
||||
supportsPromptCache: true,
|
||||
inputPrice: 0.6,
|
||||
outputPrice: 3,
|
||||
inputPrice: 0.95,
|
||||
outputPrice: 4,
|
||||
cacheWritesPrice: 0,
|
||||
cacheReadsPrice: 0.1,
|
||||
cacheReadsPrice: 0.19,
|
||||
description:
|
||||
"Moonshot's flagship open agentic model. Kimi K2.5 unifies vision and text, thinking and non-thinking modes, and single-agent and multi-agent execution.",
|
||||
"Moonshot's latest open coding model. Kimi K2.7 Code unifies vision and text, thinking and non-thinking modes, and single-agent and multi-agent execution.",
|
||||
},
|
||||
"accounts/fireworks/models/kimi-k2p6": {
|
||||
maxTokens: 262000,
|
||||
@@ -5143,6 +5143,18 @@ export const fireworksModels = {
|
||||
description:
|
||||
"Kimi K2.6 Turbo router for high-performance agentic workloads with vision and text reasoning.",
|
||||
},
|
||||
"accounts/fireworks/routers/kimi-k2p7-code-fast": {
|
||||
maxTokens: 262000,
|
||||
contextWindow: 262000,
|
||||
supportsImages: true,
|
||||
supportsPromptCache: true,
|
||||
inputPrice: 1.9,
|
||||
outputPrice: 8,
|
||||
cacheWritesPrice: 0,
|
||||
cacheReadsPrice: 0.38,
|
||||
description:
|
||||
"Kimi K2.7 Code Fast router for high-performance coding workloads with vision and text reasoning.",
|
||||
},
|
||||
"accounts/fireworks/models/deepseek-v4-flash": {
|
||||
maxTokens: 384000,
|
||||
contextWindow: 1000000,
|
||||
@@ -5189,16 +5201,16 @@ export const fireworksModels = {
|
||||
cacheReadsPrice: 0.52,
|
||||
description: "GLM 5.1 Fast router for high-throughput coding, reasoning, and agentic workflows.",
|
||||
},
|
||||
"accounts/fireworks/models/minimax-m2p5": {
|
||||
maxTokens: 196608,
|
||||
contextWindow: 196608,
|
||||
supportsImages: false,
|
||||
"accounts/fireworks/models/minimax-m3": {
|
||||
maxTokens: 512000,
|
||||
contextWindow: 512000,
|
||||
supportsImages: true,
|
||||
supportsPromptCache: true,
|
||||
inputPrice: 0.3,
|
||||
outputPrice: 1.2,
|
||||
cacheWritesPrice: 0,
|
||||
cacheReadsPrice: 0.03,
|
||||
description: "MiniMax M2.5 is built for state-of-the-art coding, agentic tool use.",
|
||||
cacheReadsPrice: 0.06,
|
||||
description: "MiniMax M3 is built for state-of-the-art coding, agentic tool use, and long-context multimodal tasks.",
|
||||
},
|
||||
"accounts/fireworks/models/minimax-m2p7": {
|
||||
maxTokens: 196608,
|
||||
@@ -5211,16 +5223,16 @@ export const fireworksModels = {
|
||||
cacheReadsPrice: 0.06,
|
||||
description: "MiniMax M2.7 is tuned for strong real-world performance across coding, agent-driven, and workflow-heavy tasks.",
|
||||
},
|
||||
"accounts/fireworks/models/qwen3p6-plus": {
|
||||
maxTokens: 65536,
|
||||
"accounts/fireworks/models/qwen3p7-plus": {
|
||||
maxTokens: 262144,
|
||||
contextWindow: 262144,
|
||||
supportsImages: true,
|
||||
supportsPromptCache: true,
|
||||
inputPrice: 0.5,
|
||||
outputPrice: 3,
|
||||
inputPrice: 0.4,
|
||||
outputPrice: 1.6,
|
||||
cacheWritesPrice: 0,
|
||||
cacheReadsPrice: 0.1,
|
||||
description: "Qwen 3.6 Plus with strong multimodal reasoning, long context support, and function calling.",
|
||||
cacheReadsPrice: 0.08,
|
||||
description: "Qwen 3.7 Plus with strong multimodal reasoning, long context support, and function calling.",
|
||||
},
|
||||
"accounts/fireworks/models/gpt-oss-120b": {
|
||||
maxTokens: 32768,
|
||||
|
||||
@@ -1,68 +1,84 @@
|
||||
import { Anthropic } from "@anthropic-ai/sdk"
|
||||
import { ClineMessageMetricsInfo, ClineMessageModelInfo } from "./metrics"
|
||||
import type { Anthropic } from "@anthropic-ai/sdk";
|
||||
import type { ClineMessageMetricsInfo, ClineMessageModelInfo } from "./metrics";
|
||||
|
||||
export type ClinePromptInputContent = string
|
||||
export type ClinePromptInputContent = string;
|
||||
|
||||
export type ClineMessageRole = "user" | "assistant"
|
||||
export type ClineMessageRole = "user" | "assistant";
|
||||
|
||||
export interface ClineReasoningDetailParam {
|
||||
type: "reasoning.text" | string
|
||||
text: string
|
||||
signature: string
|
||||
format: "anthropic-claude-v1" | string
|
||||
index: number
|
||||
type: "reasoning.text" | string;
|
||||
text: string;
|
||||
signature: string;
|
||||
format: "anthropic-claude-v1" | string;
|
||||
index: number;
|
||||
}
|
||||
|
||||
interface ClineSharedMessageParam {
|
||||
// The id of the response that the block belongs to
|
||||
call_id?: string
|
||||
call_id?: string;
|
||||
}
|
||||
|
||||
export const REASONING_DETAILS_PROVIDERS = ["cline", "openrouter"]
|
||||
export const REASONING_DETAILS_PROVIDERS = ["cline", "openrouter"];
|
||||
|
||||
/**
|
||||
* An extension of Anthropic.MessageParam that includes Cline-specific fields: reasoning_details.
|
||||
* This ensures backward compatibility where the messages were stored in Anthropic format with additional
|
||||
* fields unknown to Anthropic SDK.
|
||||
*/
|
||||
export interface ClineTextContentBlock extends Anthropic.TextBlockParam, ClineSharedMessageParam {
|
||||
export interface ClineTextContentBlock
|
||||
extends Anthropic.TextBlockParam,
|
||||
ClineSharedMessageParam {
|
||||
// reasoning_details only exists for providers listed in REASONING_DETAILS_PROVIDERS
|
||||
reasoning_details?: ClineReasoningDetailParam[]
|
||||
reasoning_details?: ClineReasoningDetailParam[];
|
||||
// Thought Signature associates with Gemini
|
||||
signature?: string
|
||||
signature?: string;
|
||||
}
|
||||
|
||||
export interface ClineImageContentBlock extends Anthropic.ImageBlockParam, ClineSharedMessageParam {}
|
||||
export interface ClineImageContentBlock
|
||||
extends Anthropic.ImageBlockParam,
|
||||
ClineSharedMessageParam {}
|
||||
|
||||
export interface ClineDocumentContentBlock extends Anthropic.DocumentBlockParam, ClineSharedMessageParam {}
|
||||
export interface ClineDocumentContentBlock
|
||||
extends Anthropic.DocumentBlockParam,
|
||||
ClineSharedMessageParam {}
|
||||
|
||||
export interface ClineUserToolResultContentBlock extends Anthropic.ToolResultBlockParam, ClineSharedMessageParam {}
|
||||
export interface ClineUserToolResultContentBlock
|
||||
extends Anthropic.ToolResultBlockParam,
|
||||
ClineSharedMessageParam {}
|
||||
|
||||
/**
|
||||
* Assistant only content types
|
||||
*/
|
||||
export interface ClineAssistantToolUseBlock extends Anthropic.ToolUseBlockParam, ClineSharedMessageParam {
|
||||
export interface ClineAssistantToolUseBlock
|
||||
extends Anthropic.ToolUseBlockParam,
|
||||
ClineSharedMessageParam {
|
||||
// reasoning_details only exists for providers listed in REASONING_DETAILS_PROVIDERS
|
||||
reasoning_details?: unknown[] | ClineReasoningDetailParam[]
|
||||
reasoning_details?: unknown[] | ClineReasoningDetailParam[];
|
||||
// Thought Signature associates with Gemini
|
||||
signature?: string
|
||||
signature?: string;
|
||||
}
|
||||
|
||||
export interface ClineAssistantThinkingBlock extends Anthropic.ThinkingBlock, ClineSharedMessageParam {
|
||||
export interface ClineAssistantThinkingBlock
|
||||
extends Anthropic.ThinkingBlock,
|
||||
ClineSharedMessageParam {
|
||||
// The summary items returned by OpenAI response API
|
||||
// The reasoning details that will be moved to the text block when finalized
|
||||
summary?: unknown[] | ClineReasoningDetailParam[]
|
||||
summary?: unknown[] | ClineReasoningDetailParam[];
|
||||
}
|
||||
|
||||
export interface ClineAssistantRedactedThinkingBlock extends Anthropic.RedactedThinkingBlockParam, ClineSharedMessageParam {}
|
||||
export interface ClineAssistantRedactedThinkingBlock
|
||||
extends Anthropic.RedactedThinkingBlockParam,
|
||||
ClineSharedMessageParam {}
|
||||
|
||||
export type ClineToolResponseContent = ClinePromptInputContent | Array<ClineTextContentBlock | ClineImageContentBlock>
|
||||
export type ClineToolResponseContent =
|
||||
| ClinePromptInputContent
|
||||
| Array<ClineTextContentBlock | ClineImageContentBlock>;
|
||||
|
||||
export type ClineUserContent =
|
||||
| ClineTextContentBlock
|
||||
| ClineImageContentBlock
|
||||
| ClineDocumentContentBlock
|
||||
| ClineUserToolResultContentBlock
|
||||
| ClineUserToolResultContentBlock;
|
||||
|
||||
export type ClineAssistantContent =
|
||||
| ClineTextContentBlock
|
||||
@@ -70,9 +86,9 @@ export type ClineAssistantContent =
|
||||
| ClineDocumentContentBlock
|
||||
| ClineAssistantToolUseBlock
|
||||
| ClineAssistantThinkingBlock
|
||||
| ClineAssistantRedactedThinkingBlock
|
||||
| ClineAssistantRedactedThinkingBlock;
|
||||
|
||||
export type ClineContent = ClineUserContent | ClineAssistantContent
|
||||
export type ClineContent = ClineUserContent | ClineAssistantContent;
|
||||
|
||||
/**
|
||||
* An extension of Anthropic.MessageParam that includes Cline-specific fields.
|
||||
@@ -84,24 +100,24 @@ export interface ClineStorageMessage extends Anthropic.MessageParam {
|
||||
/**
|
||||
* Response ID associated with this message
|
||||
*/
|
||||
id?: string
|
||||
role: ClineMessageRole
|
||||
content: ClinePromptInputContent | ClineContent[]
|
||||
id?: string;
|
||||
role: ClineMessageRole;
|
||||
content: ClinePromptInputContent | ClineContent[];
|
||||
/**
|
||||
* NOTE: model information used when generating this message.
|
||||
* Internal use for message conversion only.
|
||||
* MUST be removed before sending message to any LLM provider.
|
||||
*/
|
||||
modelInfo?: ClineMessageModelInfo
|
||||
modelInfo?: ClineMessageModelInfo;
|
||||
/**
|
||||
* LLM operational and performance metrics for this message
|
||||
* Includes token counts, costs.
|
||||
*/
|
||||
metrics?: ClineMessageMetricsInfo
|
||||
metrics?: ClineMessageMetricsInfo;
|
||||
/**
|
||||
* Timestamp of when the message was created
|
||||
*/
|
||||
ts?: number
|
||||
ts?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -112,23 +128,25 @@ export function convertClineStorageToAnthropicMessage(
|
||||
clineMessage: ClineStorageMessage,
|
||||
provider = "anthropic",
|
||||
): Anthropic.MessageParam {
|
||||
const { role, content } = clineMessage
|
||||
const { role, content } = clineMessage;
|
||||
|
||||
// Handle string content - fast path
|
||||
if (typeof content === "string") {
|
||||
return { role, content }
|
||||
return { role, content };
|
||||
}
|
||||
|
||||
// Removes thinking block that has no signature (invalid thinking block that's incompatible with Anthropic API)
|
||||
const filteredContent = content.filter((b) => b.type !== "thinking" || !!b.signature)
|
||||
const filteredContent = content.filter(
|
||||
(b) => b.type !== "thinking" || !!b.signature,
|
||||
);
|
||||
|
||||
// Handle array content - strip Cline-specific fields for non-reasoning_details providers
|
||||
const shouldCleanContent = !REASONING_DETAILS_PROVIDERS.includes(provider)
|
||||
const shouldCleanContent = !REASONING_DETAILS_PROVIDERS.includes(provider);
|
||||
const cleanedContent = shouldCleanContent
|
||||
? filteredContent.map(cleanContentBlock)
|
||||
: (filteredContent as Anthropic.MessageParam["content"])
|
||||
: (filteredContent as Anthropic.MessageParam["content"]);
|
||||
|
||||
return { role, content: cleanedContent }
|
||||
return { role, content: cleanedContent };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -137,19 +155,23 @@ export function convertClineStorageToAnthropicMessage(
|
||||
* narrows to the base64 variant for the transform layer. URL sources are not produced by Cline,
|
||||
* so they degrade to empty values rather than throwing.
|
||||
*/
|
||||
export function getBase64ImageSource(source: Anthropic.ImageBlockParam["source"]): { mediaType: string; data: string } {
|
||||
export function getBase64ImageSource(
|
||||
source: Anthropic.ImageBlockParam["source"],
|
||||
): { mediaType: string; data: string } {
|
||||
if (source.type === "base64") {
|
||||
return { mediaType: source.media_type, data: source.data }
|
||||
return { mediaType: source.media_type, data: source.data };
|
||||
}
|
||||
return { mediaType: "", data: "" }
|
||||
return { mediaType: "", data: "" };
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a base64 data URL from an image block's source. See getBase64ImageSource.
|
||||
*/
|
||||
export function getImageDataUrl(source: Anthropic.ImageBlockParam["source"]): string {
|
||||
const { mediaType, data } = getBase64ImageSource(source)
|
||||
return `data:${mediaType};base64,${data}`
|
||||
export function getImageDataUrl(
|
||||
source: Anthropic.ImageBlockParam["source"],
|
||||
): string {
|
||||
const { mediaType, data } = getBase64ImageSource(source);
|
||||
return `data:${mediaType};base64,${data}`;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -161,19 +183,19 @@ export function cleanContentBlock(block: ClineContent): Anthropic.ContentBlock {
|
||||
"reasoning_details" in block ||
|
||||
"call_id" in block ||
|
||||
"summary" in block ||
|
||||
(block.type !== "thinking" && "signature" in block)
|
||||
(block.type !== "thinking" && "signature" in block);
|
||||
|
||||
if (!hasClineFields) {
|
||||
return block as Anthropic.ContentBlock
|
||||
return block as Anthropic.ContentBlock;
|
||||
}
|
||||
|
||||
// Removes Cline-specific fields & the signature field that's added for Gemini.
|
||||
const { reasoning_details, call_id, summary, ...rest } = block as any
|
||||
const { reasoning_details, call_id, summary, ...rest } = block as any;
|
||||
|
||||
// Remove signature from non-thinking blocks that were added for Gemini
|
||||
if (block.type !== "thinking" && rest.signature) {
|
||||
rest.signature = undefined
|
||||
rest.signature = undefined;
|
||||
}
|
||||
|
||||
return rest satisfies Anthropic.ContentBlock
|
||||
return rest satisfies Anthropic.ContentBlock;
|
||||
}
|
||||
|
||||
@@ -98,7 +98,7 @@ export function getReadablePath(cwd: string, relPath?: string): string {
|
||||
if (isLocatedInPath(cwd, absolutePath)) {
|
||||
return normalizedRelPath.toPosix()
|
||||
}
|
||||
// we are outside the cwd, so show the absolute path (useful for when cline passes in '../../' for example)
|
||||
// we are outside the cwd, so show the absolute path (useful for when Cline passes in '../../' for example)
|
||||
return absolutePath.toPosix()
|
||||
}
|
||||
|
||||
|
||||
@@ -5,19 +5,16 @@ import { type ReactNode, useEffect, useState } from "react"
|
||||
import { useExtensionState } from "./context/ExtensionStateContext"
|
||||
|
||||
export function CustomPostHogProvider({ children }: { children: ReactNode }) {
|
||||
const { distinctId, version, userInfo, environment } = useExtensionState()
|
||||
const { distinctId, version, userInfo, environment, telemetrySetting } = useExtensionState()
|
||||
|
||||
// Skip PostHog entirely in self-hosted mode or when environment is unknown (safety fallback)
|
||||
const isSelfHostedOrUnknown = !environment || environment === "selfHosted"
|
||||
|
||||
// NOTE: This is a hack to stop recording webview click events temporarily.
|
||||
// Remove this to re-enable.
|
||||
// const isTelemetryEnabled = telemetrySetting !== "disabled";
|
||||
const isTelemetryEnabled = false
|
||||
const isTelemetryEnabled = telemetrySetting !== "disabled"
|
||||
const [isActive, setIsActive] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (isSelfHostedOrUnknown || isActive || !isTelemetryEnabled || !posthogConfig.apiKey) {
|
||||
if (isSelfHostedOrUnknown || isActive || !posthogConfig.apiKey) {
|
||||
return
|
||||
}
|
||||
// At this point, we know apiKey is defined due to the check above
|
||||
@@ -27,7 +24,7 @@ export function CustomPostHogProvider({ children }: { children: ReactNode }) {
|
||||
ui_host: posthogConfig.uiHost,
|
||||
disable_session_recording: true,
|
||||
capture_pageview: false,
|
||||
capture_dead_clicks: true,
|
||||
capture_dead_clicks: false,
|
||||
// Feature flags should work regardless of telemetry opt-out
|
||||
advanced_disable_decide: false,
|
||||
// Autocapture should respect telemetry settings
|
||||
@@ -37,7 +34,7 @@ export function CustomPostHogProvider({ children }: { children: ReactNode }) {
|
||||
}, [isSelfHostedOrUnknown])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isTelemetryEnabled || !isActive || !distinctId || !version) {
|
||||
if (!isActive || !distinctId || !version) {
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ import ContextWindow from "./ContextWindow"
|
||||
import { FocusChain } from "./FocusChain"
|
||||
import { highlightText } from "./Highlights"
|
||||
|
||||
const IS_DEV = process.env.IS_DEV === '"true"'
|
||||
const IS_DEV = process.env.IS_DEV === "true"
|
||||
interface TaskHeaderProps {
|
||||
task: ClineMessage
|
||||
tokensIn: number
|
||||
|
||||
@@ -41,13 +41,15 @@ export const isChrome = userAgent.indexOf("Chrome") >= 0
|
||||
|
||||
export const isSafari = !isChrome && userAgent.indexOf("Safari") >= 0
|
||||
|
||||
declare const __NODE_PLATFORM__: string
|
||||
|
||||
/**
|
||||
* Gets the current platform: 'windows', 'mac', or 'linux'
|
||||
* Defaults to 'linux' if platform cannot be determined
|
||||
*/
|
||||
export function getCurrentPlatform() {
|
||||
// Fallback to linux if platform is not available
|
||||
switch (process?.platform) {
|
||||
switch (__NODE_PLATFORM__) {
|
||||
case "win32":
|
||||
return "windows"
|
||||
case "darwin":
|
||||
|
||||
@@ -116,19 +116,23 @@ export default defineConfig({
|
||||
},
|
||||
define: {
|
||||
__PLATFORM__: JSON.stringify(platform),
|
||||
process: JSON.stringify({
|
||||
platform: JSON.stringify(process?.platform),
|
||||
env: {
|
||||
NODE_ENV: JSON.stringify(process?.env?.IS_DEV ? "development" : "production"),
|
||||
CLINE_ENVIRONMENT: JSON.stringify(process?.env?.CLINE_ENVIRONMENT ?? "production"),
|
||||
IS_DEV: JSON.stringify(process?.env?.IS_DEV),
|
||||
IS_TEST: JSON.stringify(process?.env?.IS_TEST),
|
||||
CI: JSON.stringify(process?.env?.CI),
|
||||
// PostHog environment variables
|
||||
TELEMETRY_SERVICE_API_KEY: JSON.stringify(process?.env?.TELEMETRY_SERVICE_API_KEY),
|
||||
ERROR_SERVICE_API_KEY: JSON.stringify(process?.env?.ERROR_SERVICE_API_KEY),
|
||||
},
|
||||
}),
|
||||
__NODE_PLATFORM__: JSON.stringify(process.platform),
|
||||
"process.env.CLINE_ENVIRONMENT": JSON.stringify(process.env.CLINE_ENVIRONMENT ?? "production"),
|
||||
"process.env.IS_DEV": JSON.stringify(process.env.IS_DEV),
|
||||
"process.env.IS_TEST": JSON.stringify(process.env.IS_TEST),
|
||||
"process.env.CI": JSON.stringify(process.env.CI),
|
||||
// PostHog environment variables
|
||||
"process.env.TELEMETRY_SERVICE_API_KEY": JSON.stringify(process.env.TELEMETRY_SERVICE_API_KEY),
|
||||
"process.env.ERROR_SERVICE_API_KEY": JSON.stringify(process.env.ERROR_SERVICE_API_KEY),
|
||||
"process.env.ENABLE_ERROR_AUTOCAPTURE": JSON.stringify(process.env.ENABLE_ERROR_AUTOCAPTURE),
|
||||
// OpenTelemetry environment variables
|
||||
"process.env.OTEL_TELEMETRY_ENABLED": JSON.stringify(process.env.OTEL_TELEMETRY_ENABLED),
|
||||
"process.env.OTEL_METRICS_EXPORTER": JSON.stringify(process.env.OTEL_METRICS_EXPORTER),
|
||||
"process.env.OTEL_LOGS_EXPORTER": JSON.stringify(process.env.OTEL_LOGS_EXPORTER),
|
||||
"process.env.OTEL_EXPORTER_OTLP_PROTOCOL": JSON.stringify(process.env.OTEL_EXPORTER_OTLP_PROTOCOL),
|
||||
"process.env.OTEL_EXPORTER_OTLP_ENDPOINT": JSON.stringify(process.env.OTEL_EXPORTER_OTLP_ENDPOINT),
|
||||
"process.env.OTEL_EXPORTER_OTLP_HEADERS": JSON.stringify(process.env.OTEL_EXPORTER_OTLP_HEADERS),
|
||||
"process.env.OTEL_METRIC_EXPORT_INTERVAL": JSON.stringify(process.env.OTEL_METRIC_EXPORT_INTERVAL),
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
|
||||
@@ -1,5 +1,26 @@
|
||||
# Cline SDK Changelog
|
||||
|
||||
## 0.0.49
|
||||
|
||||
- Reverted ClinePass recommended-models support, removing the `clinePass` field from the recommended models data
|
||||
|
||||
## 0.0.48
|
||||
|
||||
- Added ClinePass support and ClinePass models
|
||||
- Added MCP server support to plugins
|
||||
- Updated the recommended/fixed model list
|
||||
- Encouraged parallel tool calls for faster task execution
|
||||
- Capped tool output ingestion for bash commands and file reads to keep large output within context limits
|
||||
- Added a bounded media budget for provider requests, plus generic provider-request capture
|
||||
- Allowed ranged reads on large files
|
||||
- Fixed apply_patch to fail when a hunk is skipped instead of silently dropping it
|
||||
- Fixed run_commands to return captured stdout on failure and to coalesce split heredocs
|
||||
- Fixed search tools to treat zero results as a successful result
|
||||
- Fixed search output cap and bash executor follow-up issues
|
||||
- Fixed disabled-reasoning handling for StepFun flash
|
||||
- Fixed the Hugging Face URL
|
||||
- Fixed Cline OAuth token formatting in provider config
|
||||
|
||||
## 0.0.47
|
||||
|
||||
- Added support for overriding the API base URL
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@cline/agents",
|
||||
"version": "0.0.47",
|
||||
"version": "0.0.49",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/cline/cline",
|
||||
|
||||
@@ -442,7 +442,7 @@ describe("AgentRuntime", () => {
|
||||
it("preserves structured multimodal tool results for the next model request", async () => {
|
||||
const structuredOutput = [
|
||||
{ type: "text", text: "Successfully read image" },
|
||||
{ type: "image", data: "BASE64DATA", mediaType: "image/jpeg" },
|
||||
{ type: "image", data: "QkFTRTY0REFUQQ==", mediaType: "image/jpeg" },
|
||||
];
|
||||
const model = new ScriptedModel([
|
||||
() => [
|
||||
@@ -960,6 +960,47 @@ describe("AgentRuntime", () => {
|
||||
expect(model.requests).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("merges beforeModel options metadata into the model request", async () => {
|
||||
const model = new ScriptedModel([
|
||||
(request) => {
|
||||
expect(request.options?.metadata).toMatchObject({
|
||||
sessionId: "session-1",
|
||||
runId: "run-1",
|
||||
iteration: 1,
|
||||
});
|
||||
return [
|
||||
{ type: "text-delta", text: "done" },
|
||||
{ type: "finish", reason: "stop" },
|
||||
];
|
||||
},
|
||||
]);
|
||||
const runtime = new AgentRuntime({
|
||||
model,
|
||||
modelOptions: { metadata: { existing: true } },
|
||||
hooks: {
|
||||
beforeModel: () => ({
|
||||
options: {
|
||||
metadata: {
|
||||
sessionId: "session-1",
|
||||
runId: "run-1",
|
||||
iteration: 1,
|
||||
},
|
||||
},
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
await runtime.run("capture metadata");
|
||||
|
||||
expect(model.requests).toHaveLength(1);
|
||||
expect(model.requests[0]?.options?.metadata).toMatchObject({
|
||||
existing: true,
|
||||
sessionId: "session-1",
|
||||
runId: "run-1",
|
||||
iteration: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves the existing system prompt when prepareTurn returns only messages", async () => {
|
||||
const compactedMessage: AgentMessage = {
|
||||
id: "msg_compacted",
|
||||
|
||||
@@ -23,7 +23,11 @@ import type {
|
||||
ToolApprovalResult,
|
||||
ToolPolicy,
|
||||
} from "@cline/shared";
|
||||
import { captureSdkError, estimateTokens } from "@cline/shared";
|
||||
import {
|
||||
captureSdkError,
|
||||
estimateTokens,
|
||||
mergeModelOptions,
|
||||
} from "@cline/shared";
|
||||
import { nanoid } from "nanoid";
|
||||
|
||||
// Local `createUID` helper. The clinee source imports this from
|
||||
@@ -778,7 +782,7 @@ export class AgentRuntime {
|
||||
if (result?.options) {
|
||||
request = {
|
||||
...request,
|
||||
options: { ...(request.options ?? {}), ...result.options },
|
||||
options: mergeModelOptions(request.options, result.options),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,6 +50,11 @@ const builds: Parameters<typeof Bun.build>[0][] = [
|
||||
outdir: "./dist/services/telemetry",
|
||||
...buildConfig,
|
||||
},
|
||||
{
|
||||
entrypoints: ["./src/services/feature-flags/posthog.ts"],
|
||||
outdir: "./dist/services/feature-flags",
|
||||
...buildConfig,
|
||||
},
|
||||
// The plugin sandbox bootstrap runs in an isolated child process via
|
||||
// SubprocessSandbox and must be emitted as a separate executable entrypoint.
|
||||
{
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@cline/core",
|
||||
"description": "Cline Core SDK for Node Runtime",
|
||||
"version": "0.0.47",
|
||||
"version": "0.0.49",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/cline/cline",
|
||||
@@ -30,6 +30,10 @@
|
||||
"./telemetry": {
|
||||
"types": "./dist/services/telemetry/index.d.ts",
|
||||
"import": "./dist/services/telemetry/index.js"
|
||||
},
|
||||
"./services/feature-flags/posthog": {
|
||||
"types": "./dist/services/feature-flags/posthog.d.ts",
|
||||
"import": "./dist/services/feature-flags/posthog.js"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
@@ -67,8 +71,17 @@
|
||||
"yaml": "^2.8.2",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"posthog-node": "^5.8.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"posthog-node": {
|
||||
"optional": true
|
||||
}
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/ws": "^8.18.1"
|
||||
"@types/ws": "^8.18.1",
|
||||
"posthog-node": "^5.8.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22"
|
||||
|
||||
@@ -332,31 +332,6 @@ describe("ClineCore", () => {
|
||||
expect(coreTelemetry.capture).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("wraps an injected feature flags provider", async () => {
|
||||
const host = {
|
||||
runtimeAddress: undefined,
|
||||
startSession: vi.fn(),
|
||||
runTurn: vi.fn(),
|
||||
getAccumulatedUsage: vi.fn(),
|
||||
abort: vi.fn(),
|
||||
stopSession: vi.fn(),
|
||||
dispose: vi.fn(),
|
||||
getSession: vi.fn(async () => undefined),
|
||||
listSessions: vi.fn(),
|
||||
deleteSession: vi.fn(),
|
||||
readSessionMessages: vi.fn(),
|
||||
subscribe: vi.fn(() => () => {}),
|
||||
updateSessionModel: vi.fn(),
|
||||
};
|
||||
createRuntimeHostMock.mockResolvedValue(host);
|
||||
const provider = new NoOpFeatureFlagsProvider();
|
||||
|
||||
const core = await ClineCore.create({ featureFlags: provider });
|
||||
|
||||
expect(core.featureFlags.getProvider()).toBe(provider);
|
||||
await core.dispose();
|
||||
});
|
||||
|
||||
it("uses a no-op feature flags provider by default", async () => {
|
||||
const host = {
|
||||
runtimeAddress: undefined,
|
||||
|
||||
@@ -200,15 +200,17 @@ export class ClineCore {
|
||||
const normalizedOptions = { ...options, capabilities, distinctId };
|
||||
const host = await createRuntimeHost(normalizedOptions);
|
||||
const automationOptions = normalizeAutomationOptions(options.automation);
|
||||
const featureFlags = new FeatureFlagsService({
|
||||
provider: options.featureFlags ?? new NoOpFeatureFlagsProvider(),
|
||||
telemetry: options.telemetry,
|
||||
logger: options.logger,
|
||||
context: {
|
||||
distinctId,
|
||||
clientName: options.clientName,
|
||||
},
|
||||
});
|
||||
const featureFlags =
|
||||
options.featureFlags ||
|
||||
new FeatureFlagsService({
|
||||
provider: new NoOpFeatureFlagsProvider(),
|
||||
telemetry: options.telemetry,
|
||||
logger: options.logger,
|
||||
context: {
|
||||
distinctId,
|
||||
clientName: options.clientName,
|
||||
},
|
||||
});
|
||||
const core = new ClineCore(
|
||||
host,
|
||||
options.clientName,
|
||||
@@ -396,11 +398,6 @@ export class ClineCore {
|
||||
await this.automationService?.dispose();
|
||||
await this.host.dispose(...args);
|
||||
} finally {
|
||||
await this.featureFlags.dispose().catch((error) => {
|
||||
this.logger?.error?.("Error disposing feature flags provider", {
|
||||
error,
|
||||
});
|
||||
});
|
||||
this.unsubscribeBootstrapCleanup();
|
||||
const sessionIds = [...this.activeSessionBootstraps.keys()];
|
||||
await Promise.allSettled(
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
getProviderAuthStorageId,
|
||||
isOAuthProvider,
|
||||
loginAndSaveProviderOAuthCredentials,
|
||||
resolveProviderApiKeyFromSettings,
|
||||
} from "./provider-auth-registry";
|
||||
|
||||
const { loginClineOAuth } = vi.hoisted(() => ({
|
||||
@@ -34,6 +35,7 @@ describe("provider auth registry", () => {
|
||||
|
||||
it("returns handlers for managed OAuth providers only", () => {
|
||||
expect(getProviderAuthHandler("cline")?.providerId).toBe("cline");
|
||||
expect(getProviderAuthHandler("cline-pass")?.providerId).toBe("cline-pass");
|
||||
expect(getProviderAuthHandler("oca")?.providerId).toBe("oca");
|
||||
expect(getProviderAuthHandler("openai-codex")?.providerId).toBe(
|
||||
"openai-codex",
|
||||
@@ -44,6 +46,7 @@ describe("provider auth registry", () => {
|
||||
|
||||
it("returns storage provider IDs from handlers", () => {
|
||||
expect(getProviderAuthStorageId("cline")).toBe("cline");
|
||||
expect(getProviderAuthStorageId("cline-pass")).toBe("cline");
|
||||
expect(getProviderAuthStorageId("oca")).toBe("oca");
|
||||
expect(getProviderAuthStorageId("openai-codex")).toBe("openai-codex");
|
||||
expect(getProviderAuthStorageId("openai-codex-cli")).toBeUndefined();
|
||||
@@ -53,17 +56,78 @@ describe("provider auth registry", () => {
|
||||
expect(formatProviderOAuthApiKey("cline", { access: "abc" })).toBe(
|
||||
"workos:abc",
|
||||
);
|
||||
expect(formatProviderOAuthApiKey("cline-pass", { access: "abc" })).toBe(
|
||||
"workos:abc",
|
||||
);
|
||||
expect(formatProviderOAuthApiKey("cline", { access: "workos:abc" })).toBe(
|
||||
"workos:abc",
|
||||
);
|
||||
expect(
|
||||
getPersistedProviderApiKey("cline", {
|
||||
getPersistedProviderApiKey("cline-pass", {
|
||||
provider: "cline",
|
||||
auth: { accessToken: "abc" },
|
||||
}),
|
||||
).toBe("workos:abc");
|
||||
});
|
||||
|
||||
it("login/save for ClinePass stores credentials under Cline storage", async () => {
|
||||
loginClineOAuth.mockResolvedValueOnce({
|
||||
access: "new-access",
|
||||
refresh: "new-refresh",
|
||||
expires: 4_000_000_000_000,
|
||||
accountId: "acct-new",
|
||||
});
|
||||
const getProviderSettings = vi.fn().mockReturnValue({
|
||||
provider: "cline",
|
||||
apiKey: "manual-key",
|
||||
});
|
||||
const saveProviderSettings = vi.fn();
|
||||
const manager = {
|
||||
getProviderSettings,
|
||||
saveProviderSettings,
|
||||
} as never;
|
||||
|
||||
const saved = await loginAndSaveProviderOAuthCredentials(
|
||||
manager,
|
||||
"cline-pass",
|
||||
{
|
||||
callbacks: {
|
||||
onAuth: vi.fn(),
|
||||
onPrompt: vi.fn(async () => ""),
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(getProviderSettings).toHaveBeenCalledWith("cline");
|
||||
expect(saved).toMatchObject({
|
||||
provider: "cline",
|
||||
apiKey: "manual-key",
|
||||
auth: {
|
||||
accessToken: "workos:new-access",
|
||||
refreshToken: "new-refresh",
|
||||
accountId: "acct-new",
|
||||
expiresAt: 4_000_000_000_000,
|
||||
},
|
||||
});
|
||||
expect(saveProviderSettings).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ provider: "cline" }),
|
||||
{ tokenSource: "oauth" },
|
||||
);
|
||||
});
|
||||
|
||||
it("ClinePass resolves API keys from Cline storage", () => {
|
||||
const getProviderSettings = vi.fn().mockReturnValue({
|
||||
provider: "cline",
|
||||
auth: { accessToken: "abc" },
|
||||
});
|
||||
const manager = { getProviderSettings } as never;
|
||||
|
||||
expect(resolveProviderApiKeyFromSettings(manager, "cline-pass")).toBe(
|
||||
"workos:abc",
|
||||
);
|
||||
expect(getProviderSettings).toHaveBeenCalledWith("cline");
|
||||
});
|
||||
|
||||
it("login/save stores credentials under handler storageProviderId", async () => {
|
||||
loginClineOAuth.mockResolvedValueOnce({
|
||||
access: "new-access",
|
||||
|
||||
@@ -197,9 +197,13 @@ function createOAuthHandler(input: {
|
||||
};
|
||||
}
|
||||
|
||||
const providerAuthHandlers = [
|
||||
createOAuthHandler({
|
||||
providerId: "cline",
|
||||
function createClineAuthHandler(input: {
|
||||
providerId: string;
|
||||
storageProviderId?: string;
|
||||
}): ProviderAuthHandler {
|
||||
return createOAuthHandler({
|
||||
providerId: input.providerId,
|
||||
storageProviderId: input.storageProviderId,
|
||||
formatAccessToken: formatClineApiKey,
|
||||
normalizeStoredAccessToken: stripClineApiKeyPrefix,
|
||||
login: ({ settings, callbacks, telemetry }) =>
|
||||
@@ -220,6 +224,14 @@ const providerAuthHandlers = [
|
||||
},
|
||||
{ forceRefresh },
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
const providerAuthHandlers = [
|
||||
createClineAuthHandler({ providerId: "cline" }),
|
||||
createClineAuthHandler({
|
||||
providerId: "cline-pass",
|
||||
storageProviderId: "cline",
|
||||
}),
|
||||
createOAuthHandler({
|
||||
providerId: "oca",
|
||||
|
||||
@@ -3,7 +3,6 @@ import type {
|
||||
AgentConfig,
|
||||
AutomationEventEnvelope,
|
||||
BasicLogger,
|
||||
IFeatureFlagsProvider,
|
||||
ITelemetryService,
|
||||
} from "@cline/shared";
|
||||
import type { CronEventSuppression } from "../cron/events/cron-event-ingress";
|
||||
@@ -22,6 +21,7 @@ import type {
|
||||
StartSessionInput,
|
||||
StartSessionResult,
|
||||
} from "../runtime/host/runtime-host";
|
||||
import type { FeatureFlagsService } from "../services/feature-flags";
|
||||
import type { CoreSessionConfig } from "../types/config";
|
||||
import type { SessionMessagesArtifactUploader } from "../types/session";
|
||||
|
||||
@@ -207,11 +207,10 @@ export interface ClineCoreOptions {
|
||||
*/
|
||||
telemetry?: ITelemetryService;
|
||||
/**
|
||||
* Feature flags provider for this ClineCore instance. Core wraps the provider
|
||||
* in a cached FeatureFlagsService and exposes it as `cline.featureFlags`.
|
||||
* Feature flags service for this ClineCore instance.
|
||||
* If omitted, Core uses a no-op provider with default flag values.
|
||||
*/
|
||||
featureFlags?: IFeatureFlagsProvider;
|
||||
featureFlags?: FeatureFlagsService;
|
||||
/**
|
||||
* Optional structured logger for core-side operational diagnostics such as
|
||||
* runtime-host selection and fallback decisions.
|
||||
|
||||
@@ -25,6 +25,11 @@ export type {
|
||||
McpOAuthProviderContext,
|
||||
} from "./oauth";
|
||||
export { authorizeMcpServerOAuth } from "./oauth";
|
||||
export type { PluginMcpServerResolution } from "./plugin-server-registration";
|
||||
export {
|
||||
normalizePluginMcpServerRegistration,
|
||||
resolvePluginMcpServerRegistrations,
|
||||
} from "./plugin-server-registration";
|
||||
export type {
|
||||
CreateDisabledMcpToolPoliciesOptions,
|
||||
CreateDisabledMcpToolPolicyOptions,
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { normalizePluginMcpServerRegistration } from "./plugin-server-registration";
|
||||
|
||||
describe("plugin MCP server registration", () => {
|
||||
it("normalizes streamable HTTP plugin MCP servers", () => {
|
||||
const result = normalizePluginMcpServerRegistration({
|
||||
name: "remote",
|
||||
transport: {
|
||||
type: "streamableHttp",
|
||||
url: "https://example.com/mcp",
|
||||
headers: {
|
||||
Authorization: "Bearer token",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.loadError).toBeUndefined();
|
||||
expect(result.registration).toEqual({
|
||||
name: "remote",
|
||||
transport: {
|
||||
type: "streamableHttp",
|
||||
url: "https://example.com/mcp",
|
||||
headers: {
|
||||
Authorization: "Bearer token",
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("normalizes SSE plugin MCP servers", () => {
|
||||
const result = normalizePluginMcpServerRegistration({
|
||||
name: "remote-sse",
|
||||
transport: {
|
||||
type: "sse",
|
||||
url: "https://example.com/sse",
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.loadError).toBeUndefined();
|
||||
expect(result.registration).toEqual({
|
||||
name: "remote-sse",
|
||||
transport: {
|
||||
type: "sse",
|
||||
url: "https://example.com/sse",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps top-level env scoped to stdio transports", () => {
|
||||
const result = normalizePluginMcpServerRegistration({
|
||||
name: "remote",
|
||||
transport: {
|
||||
type: "streamableHttp",
|
||||
url: "https://example.com/mcp",
|
||||
},
|
||||
env: {
|
||||
TOKEN: {
|
||||
fromEnv: "TOKEN",
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.registration).toBeUndefined();
|
||||
expect(result.loadError).toContain(
|
||||
"top-level env is only supported for stdio MCP transports",
|
||||
);
|
||||
});
|
||||
|
||||
it("reports missing stdio command before missing required env", () => {
|
||||
const result = normalizePluginMcpServerRegistration({
|
||||
name: "local",
|
||||
transport: {
|
||||
type: "stdio",
|
||||
command: "",
|
||||
},
|
||||
env: {
|
||||
MISSING_TOKEN: {
|
||||
fromEnv: "CLINE_TEST_MISSING_PLUGIN_MCP_TOKEN",
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.registration).toBeUndefined();
|
||||
expect(result.loadError).toBe("stdio MCP transport requires command");
|
||||
});
|
||||
|
||||
it("normalizes whitespace-only server names to empty-name errors", () => {
|
||||
const result = normalizePluginMcpServerRegistration({
|
||||
name: " ",
|
||||
transport: {
|
||||
type: "stdio",
|
||||
command: "node",
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.registration).toBeUndefined();
|
||||
expect(result).toEqual({
|
||||
name: "",
|
||||
loadError: "empty MCP server name",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,260 @@
|
||||
import type {
|
||||
AgentExtensionMcpEnvValue,
|
||||
AgentExtensionMcpServer,
|
||||
} from "@cline/shared";
|
||||
import type { McpServerRegistration } from "./types";
|
||||
|
||||
export interface PluginMcpServerResolution<TOwner> {
|
||||
owner: TOwner;
|
||||
name: string;
|
||||
registration?: McpServerRegistration;
|
||||
loadError?: string;
|
||||
}
|
||||
|
||||
type ResolvedPluginMcpEnv =
|
||||
| {
|
||||
ok: true;
|
||||
env?: Record<string, string>;
|
||||
}
|
||||
| {
|
||||
ok: false;
|
||||
reason: string;
|
||||
};
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function isStringRecord(value: unknown): value is Record<string, string> {
|
||||
return (
|
||||
isRecord(value) &&
|
||||
Object.values(value).every((entry) => typeof entry === "string")
|
||||
);
|
||||
}
|
||||
|
||||
function isStringArray(value: unknown): value is string[] {
|
||||
return (
|
||||
Array.isArray(value) && value.every((entry) => typeof entry === "string")
|
||||
);
|
||||
}
|
||||
|
||||
function isPluginMcpEnvValue(
|
||||
value: unknown,
|
||||
): value is AgentExtensionMcpEnvValue {
|
||||
if (!isRecord(value)) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
(value.fromEnv === undefined || typeof value.fromEnv === "string") &&
|
||||
(value.value === undefined || typeof value.value === "string") &&
|
||||
(value.required === undefined || typeof value.required === "boolean")
|
||||
);
|
||||
}
|
||||
|
||||
function resolvePluginMcpEnv(
|
||||
server: AgentExtensionMcpServer,
|
||||
): ResolvedPluginMcpEnv {
|
||||
const entries = server.env ? Object.entries(server.env) : [];
|
||||
if (entries.length === 0) {
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
const env: Record<string, string> = {};
|
||||
for (const [targetName, value] of entries) {
|
||||
if (typeof value === "string") {
|
||||
env[targetName] = value;
|
||||
continue;
|
||||
}
|
||||
|
||||
const sourceName = value.fromEnv?.trim() || targetName;
|
||||
const sourceValue = process.env[sourceName];
|
||||
if (typeof sourceValue === "string" && sourceValue.length > 0) {
|
||||
env[targetName] = sourceValue;
|
||||
continue;
|
||||
}
|
||||
if (typeof value.value === "string") {
|
||||
env[targetName] = value.value;
|
||||
continue;
|
||||
}
|
||||
if (value.required === true) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: `required environment variable "${sourceName}" is not set`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return { ok: true, env: Object.keys(env).length > 0 ? env : undefined };
|
||||
}
|
||||
|
||||
export function normalizePluginMcpServerRegistration(
|
||||
server: AgentExtensionMcpServer,
|
||||
): {
|
||||
name: string;
|
||||
registration?: McpServerRegistration;
|
||||
loadError?: string;
|
||||
} {
|
||||
if (!isRecord(server)) {
|
||||
return { name: "", loadError: "invalid MCP server registration" };
|
||||
}
|
||||
const name = typeof server.name === "string" ? server.name.trim() : "";
|
||||
if (!name) {
|
||||
return {
|
||||
name,
|
||||
loadError: "empty MCP server name",
|
||||
};
|
||||
}
|
||||
|
||||
const envValue = server.env;
|
||||
const env = envValue === undefined ? undefined : envValue;
|
||||
if (env !== undefined) {
|
||||
if (!isRecord(env)) {
|
||||
return { name, loadError: "invalid env" };
|
||||
}
|
||||
for (const [key, value] of Object.entries(env)) {
|
||||
if (typeof value !== "string" && !isPluginMcpEnvValue(value)) {
|
||||
return { name, loadError: `invalid env "${key}"` };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const transport = server.transport;
|
||||
if (!isRecord(transport)) {
|
||||
return { name, loadError: "invalid MCP transport" };
|
||||
}
|
||||
const type = transport.type;
|
||||
if (type !== "stdio" && type !== "sse" && type !== "streamableHttp") {
|
||||
return { name, loadError: "invalid MCP transport type" };
|
||||
}
|
||||
if (type !== "stdio" && env !== undefined) {
|
||||
return {
|
||||
name,
|
||||
loadError: "top-level env is only supported for stdio MCP transports",
|
||||
};
|
||||
}
|
||||
|
||||
const metadata = isRecord(server.metadata) ? server.metadata : undefined;
|
||||
if (type === "stdio") {
|
||||
const command = transport.command;
|
||||
if (typeof command !== "string" || !command.trim()) {
|
||||
return { name, loadError: "stdio MCP transport requires command" };
|
||||
}
|
||||
const args = transport.args;
|
||||
if (args !== undefined && !isStringArray(args)) {
|
||||
return { name, loadError: "stdio MCP transport args must be strings" };
|
||||
}
|
||||
const cwd = transport.cwd;
|
||||
if (cwd !== undefined && typeof cwd !== "string") {
|
||||
return { name, loadError: "stdio MCP transport cwd must be a string" };
|
||||
}
|
||||
const transportEnv = transport.env;
|
||||
if (transportEnv !== undefined && !isStringRecord(transportEnv)) {
|
||||
return { name, loadError: "stdio MCP transport env must be strings" };
|
||||
}
|
||||
|
||||
const resolvedEnv = resolvePluginMcpEnv({
|
||||
name,
|
||||
transport: {
|
||||
type: "stdio",
|
||||
command,
|
||||
args,
|
||||
cwd,
|
||||
env: transportEnv,
|
||||
},
|
||||
env,
|
||||
metadata,
|
||||
});
|
||||
if (!resolvedEnv.ok) {
|
||||
return { name, loadError: resolvedEnv.reason };
|
||||
}
|
||||
|
||||
const resolvedTransportEnv =
|
||||
transportEnv || resolvedEnv.env
|
||||
? {
|
||||
...(transportEnv ?? {}),
|
||||
...(resolvedEnv.env ?? {}),
|
||||
}
|
||||
: undefined;
|
||||
return {
|
||||
name,
|
||||
registration: {
|
||||
name,
|
||||
transport: {
|
||||
type: "stdio",
|
||||
command,
|
||||
args,
|
||||
cwd,
|
||||
env: resolvedTransportEnv,
|
||||
},
|
||||
metadata,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof transport.url !== "string" || !transport.url.trim()) {
|
||||
return { name, loadError: `${type} MCP transport requires url` };
|
||||
}
|
||||
const headers = transport.headers;
|
||||
if (headers !== undefined && !isStringRecord(headers)) {
|
||||
return { name, loadError: `${type} MCP transport headers must be strings` };
|
||||
}
|
||||
|
||||
return {
|
||||
name,
|
||||
registration: {
|
||||
name,
|
||||
transport:
|
||||
type === "sse"
|
||||
? {
|
||||
type: "sse",
|
||||
url: transport.url,
|
||||
headers,
|
||||
}
|
||||
: {
|
||||
type: "streamableHttp",
|
||||
url: transport.url,
|
||||
headers,
|
||||
},
|
||||
metadata,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function resolvePluginMcpServerRegistrations<TOwner>(
|
||||
servers: readonly {
|
||||
server: AgentExtensionMcpServer;
|
||||
owner: TOwner;
|
||||
ownerLabel?: string;
|
||||
}[],
|
||||
): PluginMcpServerResolution<TOwner>[] {
|
||||
const firstOwnerByName = new Map<string, string | undefined>();
|
||||
return servers.map(({ server, owner, ownerLabel }) => {
|
||||
const normalized = normalizePluginMcpServerRegistration(server);
|
||||
if (!normalized.registration) {
|
||||
return {
|
||||
owner,
|
||||
name: normalized.name,
|
||||
loadError: normalized.loadError ?? "invalid MCP server registration",
|
||||
};
|
||||
}
|
||||
|
||||
const firstOwner = firstOwnerByName.get(normalized.registration.name);
|
||||
if (firstOwnerByName.has(normalized.registration.name)) {
|
||||
const ownerText = firstOwner
|
||||
? ` already registered by ${firstOwner}`
|
||||
: "";
|
||||
return {
|
||||
owner,
|
||||
name: normalized.registration.name,
|
||||
loadError: `duplicate MCP server name "${normalized.registration.name}"${ownerText}`,
|
||||
};
|
||||
}
|
||||
|
||||
firstOwnerByName.set(normalized.registration.name, ownerLabel);
|
||||
return {
|
||||
owner,
|
||||
name: normalized.registration.name,
|
||||
registration: normalized.registration,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -11,6 +11,7 @@
|
||||
*/
|
||||
|
||||
import {
|
||||
type AgentExtensionMcpServer,
|
||||
type AutomationEventEnvelope,
|
||||
normalizePluginManifest,
|
||||
type PluginManifest,
|
||||
@@ -85,6 +86,7 @@ interface PluginApi {
|
||||
registerMessageBuilder(builder: PluginMessageBuilder): void;
|
||||
registerProvider(provider: PluginProvider): void;
|
||||
registerAutomationEventType(eventType: PluginAutomationEventType): void;
|
||||
registerMcpServer(server: AgentExtensionMcpServer): void;
|
||||
}
|
||||
|
||||
interface PluginSetupCtx {
|
||||
@@ -153,6 +155,7 @@ interface PluginDescriptor {
|
||||
messageBuilders: ContributionDescriptor[];
|
||||
providers: ContributionDescriptor[];
|
||||
automationEventTypes: AutomationEventTypeDescriptor[];
|
||||
mcpServers: AgentExtensionMcpServer[];
|
||||
shortcuts?: ContributionDescriptor[];
|
||||
flags?: ContributionDescriptor[];
|
||||
};
|
||||
@@ -437,6 +440,7 @@ async function loadPluginDescriptor(args: {
|
||||
messageBuilders: [],
|
||||
providers: [],
|
||||
automationEventTypes: [],
|
||||
mcpServers: [],
|
||||
shortcuts: [],
|
||||
flags: [],
|
||||
};
|
||||
@@ -509,6 +513,9 @@ async function loadPluginDescriptor(args: {
|
||||
...normalizeAutomationEventType(eventType),
|
||||
});
|
||||
},
|
||||
registerMcpServer: (server) => {
|
||||
contributions.mcpServers.push(server);
|
||||
},
|
||||
};
|
||||
|
||||
if (typeof plugin.setup === "function") {
|
||||
|
||||
@@ -35,6 +35,7 @@ function createApiCapture() {
|
||||
registerProvider: () => {},
|
||||
registerAutomationEventType: (eventType: unknown) =>
|
||||
automationEventTypes.push(eventType),
|
||||
registerMcpServer: () => {},
|
||||
};
|
||||
return { tools, rules, messageBuilders, automationEventTypes, api };
|
||||
}
|
||||
|
||||
@@ -4,8 +4,9 @@ import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import type {
|
||||
AgentConfig,
|
||||
AgentExtensionCommandResult,
|
||||
AgentExtensionAutomationEventType,
|
||||
AgentExtensionCommandResult,
|
||||
AgentExtensionMcpServer,
|
||||
AgentExtensionRule,
|
||||
AgentRuntimeHooks,
|
||||
AgentTool,
|
||||
@@ -96,6 +97,7 @@ type SandboxedPluginDescriptor = {
|
||||
messageBuilders: SandboxedContributionDescriptor[];
|
||||
providers: SandboxedContributionDescriptor[];
|
||||
automationEventTypes: SandboxedAutomationEventTypeDescriptor[];
|
||||
mcpServers: AgentExtensionMcpServer[];
|
||||
shortcuts?: SandboxedContributionDescriptor[];
|
||||
flags?: SandboxedContributionDescriptor[];
|
||||
};
|
||||
@@ -118,6 +120,7 @@ function normalizeDescriptor(
|
||||
providers: descriptor.contributions?.providers ?? [],
|
||||
automationEventTypes:
|
||||
descriptor.contributions?.automationEventTypes ?? [],
|
||||
mcpServers: descriptor.contributions?.mcpServers ?? [],
|
||||
shortcuts: descriptor.contributions?.shortcuts ?? [],
|
||||
flags: descriptor.contributions?.flags ?? [],
|
||||
},
|
||||
@@ -531,6 +534,10 @@ function registerSimpleContributions(
|
||||
metadata: eventType.metadata,
|
||||
});
|
||||
}
|
||||
|
||||
for (const mcpServer of descriptor.contributions?.mcpServers ?? []) {
|
||||
api.registerMcpServer(mcpServer);
|
||||
}
|
||||
}
|
||||
|
||||
function registerMessageBuilders(
|
||||
|
||||
@@ -8,9 +8,11 @@ import {
|
||||
createBashTool,
|
||||
createDefaultTools,
|
||||
createReadFilesTool,
|
||||
createSearchTool,
|
||||
createSkillsTool,
|
||||
createWindowsShellTool,
|
||||
} from "./definitions";
|
||||
import { CommandExitError } from "./executors/bash";
|
||||
import { RUN_COMMAND_QUERY_PREVIEW_LIMIT, TimeoutError } from "./helpers";
|
||||
import { INPUT_ARG_CHAR_LIMIT } from "./schemas";
|
||||
import type { SkillsExecutorWithMetadata } from "./types";
|
||||
@@ -348,6 +350,57 @@ describe("default submit_and_exit tool", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("default search_codebase tool", () => {
|
||||
it("treats a valid search with zero matches as success", async () => {
|
||||
const noResults =
|
||||
"No results found for pattern: missingSymbol\nSearched 3 files.";
|
||||
const execute = vi.fn(async () => noResults);
|
||||
const tool = createSearchTool(execute);
|
||||
|
||||
const result = await tool.execute(
|
||||
{ queries: ["missingSymbol"] },
|
||||
{
|
||||
agentId: "agent-1",
|
||||
conversationId: "conv-1",
|
||||
iteration: 1,
|
||||
},
|
||||
);
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
query: "missingSymbol",
|
||||
result: noResults,
|
||||
success: true,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("treats executor errors as failures", async () => {
|
||||
const execute = vi.fn(async () => {
|
||||
throw new Error("bad regex");
|
||||
});
|
||||
const tool = createSearchTool(execute);
|
||||
|
||||
const result = await tool.execute(
|
||||
{ queries: ["("] },
|
||||
{
|
||||
agentId: "agent-1",
|
||||
conversationId: "conv-1",
|
||||
iteration: 1,
|
||||
},
|
||||
);
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
query: "(",
|
||||
result: "",
|
||||
error: "Search failed: bad regex",
|
||||
success: false,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("default apply_patch tool", () => {
|
||||
it("is included only when enabled with an applyPatch executor", () => {
|
||||
const toolsWithoutExecutor = createDefaultTools({
|
||||
@@ -619,6 +672,303 @@ describe("default run_commands tool", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("returns captured output for non-zero command exits", async () => {
|
||||
const execute = vi.fn(async () => {
|
||||
throw new CommandExitError(
|
||||
1,
|
||||
"[Command exited with code 1]\nfailed assertion details",
|
||||
);
|
||||
});
|
||||
const tool = createBashTool(execute);
|
||||
|
||||
const result = await tool.execute(
|
||||
{ commands: ["bun test"] },
|
||||
{
|
||||
agentId: "agent-1",
|
||||
conversationId: "conv-1",
|
||||
iteration: 1,
|
||||
},
|
||||
);
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
query: "bun test",
|
||||
result: "[Command exited with code 1]\nfailed assertion details",
|
||||
error: "Command exited with code 1",
|
||||
success: false,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("coalesces split heredoc command arrays before execution", async () => {
|
||||
const execute = vi.fn(
|
||||
async (command: string | { command: string }) =>
|
||||
`ran:${typeof command === "string" ? command : command.command}`,
|
||||
);
|
||||
const tool = createBashTool(execute);
|
||||
|
||||
const result = await tool.execute(
|
||||
{
|
||||
commands: [
|
||||
"cd /app && python3 << 'PYEOF'",
|
||||
"import csv",
|
||||
"print('ok')",
|
||||
"PYEOF",
|
||||
],
|
||||
},
|
||||
{
|
||||
sessionId: "session-split-heredoc",
|
||||
agentId: "agent-1",
|
||||
conversationId: "conv-1",
|
||||
iteration: 1,
|
||||
},
|
||||
);
|
||||
|
||||
const expectedCommand =
|
||||
"cd /app && python3 << 'PYEOF'\nimport csv\nprint('ok')\nPYEOF";
|
||||
expect(execute).toHaveBeenCalledTimes(1);
|
||||
expect(execute).toHaveBeenCalledWith(
|
||||
expectedCommand,
|
||||
process.cwd(),
|
||||
expect.objectContaining({ sessionId: "session-split-heredoc" }),
|
||||
);
|
||||
expect(result).toEqual([
|
||||
expect.objectContaining({
|
||||
query: expect.stringContaining("cd /app && python3"),
|
||||
result: `ran:${expectedCommand}`,
|
||||
success: true,
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("coalesces split heredocs while preserving surrounding command order", async () => {
|
||||
const execute = vi.fn(
|
||||
async (command: string | { command: string }) =>
|
||||
`ran:${typeof command === "string" ? command : command.command}`,
|
||||
);
|
||||
const tool = createBashTool(execute);
|
||||
|
||||
const result = await tool.execute(
|
||||
{
|
||||
commands: [
|
||||
"pwd",
|
||||
"python3 << 'PYEOF'",
|
||||
"print('ok')",
|
||||
"PYEOF",
|
||||
"ls /app",
|
||||
],
|
||||
},
|
||||
{
|
||||
sessionId: "session-surrounding-heredoc",
|
||||
agentId: "agent-1",
|
||||
conversationId: "conv-1",
|
||||
iteration: 1,
|
||||
},
|
||||
);
|
||||
|
||||
const expectedCommand = "python3 << 'PYEOF'\nprint('ok')\nPYEOF";
|
||||
expect(execute).toHaveBeenCalledTimes(3);
|
||||
expect(execute).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
"pwd",
|
||||
process.cwd(),
|
||||
expect.objectContaining({ sessionId: "session-surrounding-heredoc" }),
|
||||
);
|
||||
expect(execute).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expectedCommand,
|
||||
process.cwd(),
|
||||
expect.objectContaining({ sessionId: "session-surrounding-heredoc" }),
|
||||
);
|
||||
expect(execute).toHaveBeenNthCalledWith(
|
||||
3,
|
||||
"ls /app",
|
||||
process.cwd(),
|
||||
expect.objectContaining({ sessionId: "session-surrounding-heredoc" }),
|
||||
);
|
||||
expect(result).toEqual([
|
||||
expect.objectContaining({ query: "pwd", result: "ran:pwd" }),
|
||||
expect.objectContaining({
|
||||
query: expectedCommand,
|
||||
result: `ran:${expectedCommand}`,
|
||||
}),
|
||||
expect.objectContaining({ query: "ls /app", result: "ran:ls /app" }),
|
||||
]);
|
||||
});
|
||||
|
||||
it("coalesces split tab-stripping heredocs", async () => {
|
||||
const execute = vi.fn(
|
||||
async (command: string | { command: string }) =>
|
||||
`ran:${typeof command === "string" ? command : command.command}`,
|
||||
);
|
||||
const tool = createBashTool(execute);
|
||||
|
||||
const result = await tool.execute(
|
||||
{
|
||||
commands: ["cat <<- EOF", "\tindented body", "EOF"],
|
||||
},
|
||||
{
|
||||
sessionId: "session-tab-stripping-heredoc",
|
||||
agentId: "agent-1",
|
||||
conversationId: "conv-1",
|
||||
iteration: 1,
|
||||
},
|
||||
);
|
||||
|
||||
const expectedCommand = "cat <<- EOF\n\tindented body\nEOF";
|
||||
expect(execute).toHaveBeenCalledTimes(1);
|
||||
expect(execute).toHaveBeenCalledWith(
|
||||
expectedCommand,
|
||||
process.cwd(),
|
||||
expect.objectContaining({
|
||||
sessionId: "session-tab-stripping-heredoc",
|
||||
}),
|
||||
);
|
||||
expect(result).toEqual([
|
||||
expect.objectContaining({
|
||||
query: expectedCommand,
|
||||
result: `ran:${expectedCommand}`,
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not coalesce independent command arrays", async () => {
|
||||
const execute = vi.fn(
|
||||
async (command: string | { command: string }) =>
|
||||
`ran:${typeof command === "string" ? command : command.command}`,
|
||||
);
|
||||
const tool = createBashTool(execute);
|
||||
|
||||
const result = await tool.execute(
|
||||
{ commands: ["pwd", "ls /app"] },
|
||||
{
|
||||
sessionId: "session-independent-commands",
|
||||
agentId: "agent-1",
|
||||
conversationId: "conv-1",
|
||||
iteration: 1,
|
||||
},
|
||||
);
|
||||
|
||||
expect(execute).toHaveBeenCalledTimes(2);
|
||||
expect(result).toEqual([
|
||||
expect.objectContaining({ query: "pwd", result: "ran:pwd" }),
|
||||
expect.objectContaining({ query: "ls /app", result: "ran:ls /app" }),
|
||||
]);
|
||||
});
|
||||
|
||||
it("coalesces consecutive split heredoc command arrays independently", async () => {
|
||||
const execute = vi.fn(
|
||||
async (command: string | { command: string }) =>
|
||||
`ran:${typeof command === "string" ? command : command.command}`,
|
||||
);
|
||||
const tool = createBashTool(execute);
|
||||
|
||||
const result = await tool.execute(
|
||||
{
|
||||
commands: [
|
||||
"cat << 'FOO'",
|
||||
"foo body",
|
||||
"FOO",
|
||||
"cat << 'BAR'",
|
||||
"bar body",
|
||||
"BAR",
|
||||
],
|
||||
},
|
||||
{
|
||||
sessionId: "session-consecutive-heredocs",
|
||||
agentId: "agent-1",
|
||||
conversationId: "conv-1",
|
||||
iteration: 1,
|
||||
},
|
||||
);
|
||||
|
||||
const expectedFirstCommand = "cat << 'FOO'\nfoo body\nFOO";
|
||||
const expectedSecondCommand = "cat << 'BAR'\nbar body\nBAR";
|
||||
expect(execute).toHaveBeenCalledTimes(2);
|
||||
expect(execute).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expectedFirstCommand,
|
||||
process.cwd(),
|
||||
expect.objectContaining({ sessionId: "session-consecutive-heredocs" }),
|
||||
);
|
||||
expect(execute).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expectedSecondCommand,
|
||||
process.cwd(),
|
||||
expect.objectContaining({ sessionId: "session-consecutive-heredocs" }),
|
||||
);
|
||||
expect(result).toEqual([
|
||||
expect.objectContaining({
|
||||
query: "cat << 'FOO'\nfoo body\nFOO",
|
||||
result: `ran:${expectedFirstCommand}`,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
query: "cat << 'BAR'\nbar body\nBAR",
|
||||
result: `ran:${expectedSecondCommand}`,
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not treat here-strings as split heredocs", async () => {
|
||||
const execute = vi.fn(
|
||||
async (command: string | { command: string }) =>
|
||||
`ran:${typeof command === "string" ? command : command.command}`,
|
||||
);
|
||||
const tool = createBashTool(execute);
|
||||
|
||||
const result = await tool.execute(
|
||||
{ commands: ['wc -c <<< "hello"', "hello"] },
|
||||
{
|
||||
sessionId: "session-here-string",
|
||||
agentId: "agent-1",
|
||||
conversationId: "conv-1",
|
||||
iteration: 1,
|
||||
},
|
||||
);
|
||||
|
||||
expect(execute).toHaveBeenCalledTimes(2);
|
||||
expect(result).toEqual([
|
||||
expect.objectContaining({
|
||||
query: 'wc -c <<< "hello"',
|
||||
result: 'ran:wc -c <<< "hello"',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
query: "hello",
|
||||
result: "ran:hello",
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not coalesce unterminated heredoc command arrays", async () => {
|
||||
const execute = vi.fn(
|
||||
async (command: string | { command: string }) =>
|
||||
`ran:${typeof command === "string" ? command : command.command}`,
|
||||
);
|
||||
const tool = createBashTool(execute);
|
||||
|
||||
const result = await tool.execute(
|
||||
{ commands: ["python3 << 'PYEOF'", "print('ok')"] },
|
||||
{
|
||||
sessionId: "session-unterminated-heredoc",
|
||||
agentId: "agent-1",
|
||||
conversationId: "conv-1",
|
||||
iteration: 1,
|
||||
},
|
||||
);
|
||||
|
||||
expect(execute).toHaveBeenCalledTimes(2);
|
||||
expect(result).toEqual([
|
||||
expect.objectContaining({
|
||||
query: "python3 << 'PYEOF'",
|
||||
result: "ran:python3 << 'PYEOF'",
|
||||
}),
|
||||
expect.objectContaining({
|
||||
query: "print('ok')",
|
||||
result: "ran:print('ok')",
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("truncates long command echoes in tool results without affecting execution", async () => {
|
||||
const execute = vi.fn(
|
||||
async (command: string | { command: string }) =>
|
||||
@@ -1169,13 +1519,13 @@ describe("zod schema conversion", () => {
|
||||
end_line: {
|
||||
anyOf: [{ type: "integer" }, { type: "null" }],
|
||||
description:
|
||||
"Optional one-based ending line number to read through; use null or omit for the end of the file",
|
||||
"Optional one-based ending line number to read through; use null or omit to read to the end of the file or the read cap, whichever comes first",
|
||||
},
|
||||
},
|
||||
required: ["path"],
|
||||
},
|
||||
description:
|
||||
"Array of file read requests. Omit start_line/end_line or set them to null to return the full file content boundaries; provide integers to return only that inclusive one-based line range. Prefer this tool over running terminal command to get file content for better performance and reliability.",
|
||||
"Array of file read requests. Omit start_line/end_line or set them to null to read from the start; provide integers to return only that inclusive one-based line range. Reads are capped, so page through long files with start_line/end_line. Prefer this tool over running terminal command to get file content for better performance and reliability.",
|
||||
});
|
||||
expect(inputSchema.required).toEqual(["files"]);
|
||||
});
|
||||
|
||||
@@ -13,6 +13,13 @@ import {
|
||||
} from "@cline/shared";
|
||||
import { captureRunCommandsTimeout } from "../../services/telemetry/core-events";
|
||||
import { getToolContextTelemetry } from "../../services/telemetry/tool-context";
|
||||
import { CommandExitError } from "./executors/bash";
|
||||
import {
|
||||
MAX_COMMAND_OUTPUT_CHARS,
|
||||
MAX_READ_LINES,
|
||||
MAX_READ_OUTPUT_CHARS,
|
||||
MAX_SEARCH_OUTPUT_CHARS,
|
||||
} from "./executors/output-limits";
|
||||
import {
|
||||
formatError,
|
||||
formatReadFileQuery,
|
||||
@@ -104,6 +111,43 @@ function captureRunCommandsTimeoutFromContext(
|
||||
});
|
||||
}
|
||||
|
||||
function getHeredocDelimiter(command: string): string | undefined {
|
||||
const match = command.match(
|
||||
/(?<![<])<<-?\s*(?:"([^"]+)"|'([^']+)'|([A-Za-z0-9_./-]+))/,
|
||||
);
|
||||
return match?.[1] ?? match?.[2] ?? match?.[3];
|
||||
}
|
||||
|
||||
function coalesceSplitHeredocCommands(commands: string[]): string[] {
|
||||
const coalesced: string[] = [];
|
||||
for (let index = 0; index < commands.length; index += 1) {
|
||||
const command = commands[index];
|
||||
const delimiter = getHeredocDelimiter(command);
|
||||
if (!delimiter) {
|
||||
coalesced.push(command);
|
||||
continue;
|
||||
}
|
||||
|
||||
const endIndex = commands.findIndex(
|
||||
(nextCommand, nextIndex) =>
|
||||
nextIndex > index && nextCommand.trim() === delimiter,
|
||||
);
|
||||
if (endIndex === -1) {
|
||||
coalesced.push(command);
|
||||
continue;
|
||||
}
|
||||
|
||||
const parts = [command];
|
||||
while (index < endIndex) {
|
||||
index += 1;
|
||||
const nextCommand = commands[index];
|
||||
parts.push(nextCommand);
|
||||
}
|
||||
coalesced.push(parts.join("\n"));
|
||||
}
|
||||
return coalesced;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// AgentTool Factory Functions
|
||||
// =============================================================================
|
||||
@@ -122,7 +166,9 @@ export function createReadFilesTool(
|
||||
return createTool<ReadFilesInput, ToolOperationResult[]>({
|
||||
name: "read_files",
|
||||
description:
|
||||
"Read the full content of text or image files at the provided absolute paths, or return only an inclusive one-based line range when start_line/end_line are provided. " +
|
||||
"Read the content of text or image files at the provided absolute paths, or return only an inclusive one-based line range when start_line/end_line are provided. " +
|
||||
"When you already know multiple files you need, read them together in one call, and call this tool in the same response as other independent tool calls. " +
|
||||
`Each read returns at most ${MAX_READ_LINES} lines / ~${Math.round(MAX_READ_OUTPUT_CHARS / 1024)}k characters; longer files report their total line count, page through them with start_line/end_line. ` +
|
||||
"Binary files that are not image and large files are not supported. " +
|
||||
"Returns file contents or error messages for each path. ",
|
||||
inputSchema: zodToJsonSchema(ReadFilesInputSchema),
|
||||
@@ -215,8 +261,9 @@ export function createSearchTool(
|
||||
name: "search_codebase",
|
||||
description:
|
||||
"Perform regex pattern searches across the codebase. " +
|
||||
"Supports multiple parallel searches. " +
|
||||
"Use for finding code patterns, function definitions, class names, imports, etc.",
|
||||
"Supports multiple parallel searches. When several search patterns could be useful and do not depend on each other, run them together in one call, and call this tool in the same response as other independent tool calls. " +
|
||||
"Use for finding code patterns, function definitions, class names, imports, etc. " +
|
||||
`Output beyond ~${Math.round(MAX_SEARCH_OUTPUT_CHARS / 1000)}k characters per query is middle-truncated; narrow patterns beat broad ones.`,
|
||||
inputSchema: zodToJsonSchema(SearchCodebaseInputSchema),
|
||||
timeoutMs: timeoutMs * 2,
|
||||
retryable: true,
|
||||
@@ -240,13 +287,10 @@ export function createSearchTool(
|
||||
timeoutMs,
|
||||
`Search timed out after ${timeoutMs}ms`,
|
||||
);
|
||||
// Check if results contain matches
|
||||
const hasResults =
|
||||
results.length > 0 && !results.includes("No results found");
|
||||
return {
|
||||
query,
|
||||
result: results,
|
||||
success: hasResults,
|
||||
success: true,
|
||||
};
|
||||
} catch (error) {
|
||||
const msg = formatError(error);
|
||||
@@ -284,7 +328,8 @@ export function createBashTool(
|
||||
description:
|
||||
"Run shell commands from the root of the workspace. " +
|
||||
"Use for listing files, checking git status, running builds, executing tests, etc. " +
|
||||
"Commands should be properly shell-escaped and targeted to avoid error or timeout. " +
|
||||
"Commands should be properly shell-escaped and targeted to avoid error or timeout. Include multiple commands only when they are independent complete shell commands and safe to run concurrently; multiline scripts and heredocs must be a single command string. " +
|
||||
`Output beyond ~${Math.round(MAX_COMMAND_OUTPUT_CHARS / 1000)}k characters is middle-truncated (start and end preserved); pipe through grep/head/tail when you need specific sections of large output. ` +
|
||||
"For long-running commands, run them in background and redirect output to a tmp file that you can read from later.",
|
||||
inputSchema: zodToJsonSchema(RunCommandsInputSchema),
|
||||
timeoutMs: timeoutMs * 2,
|
||||
@@ -306,6 +351,7 @@ export function createBashTool(
|
||||
} else {
|
||||
commands = [validate.cmd];
|
||||
}
|
||||
commands = coalesceSplitHeredocCommands(commands);
|
||||
|
||||
return Promise.all(
|
||||
commands.map(async (command: string): Promise<ToolOperationResult> => {
|
||||
@@ -331,6 +377,14 @@ export function createBashTool(
|
||||
durationMs: Date.now() - startedAt,
|
||||
});
|
||||
}
|
||||
if (error instanceof CommandExitError) {
|
||||
return {
|
||||
query,
|
||||
result: error.output,
|
||||
error: error.message,
|
||||
success: false,
|
||||
};
|
||||
}
|
||||
const msg = formatError(error);
|
||||
return {
|
||||
query,
|
||||
@@ -366,7 +420,8 @@ export function createWindowsShellTool(
|
||||
description:
|
||||
"Run shell commands from the root of the workspacein Windows environment. " +
|
||||
"Use for listing files, checking git status, running builds, executing tests, etc. " +
|
||||
"Prefer structured { command, args } entries for portability; plain string commands should be properly shell-escaped.",
|
||||
`Output beyond ~${Math.round(MAX_COMMAND_OUTPUT_CHARS / 1000)}k characters is middle-truncated (start and end preserved); filter output when you need specific sections. ` +
|
||||
"Prefer structured { command, args } entries for portability; plain string commands should be properly shell-escaped. Include multiple commands when they are independent and safe to run concurrently.",
|
||||
inputSchema: zodToJsonSchema(StructuredCommandsInputSchema),
|
||||
timeoutMs: timeoutMs * 2,
|
||||
retryable: false, // Shell commands often have side effects
|
||||
@@ -398,6 +453,14 @@ export function createWindowsShellTool(
|
||||
durationMs: Date.now() - startedAt,
|
||||
});
|
||||
}
|
||||
if (error instanceof CommandExitError) {
|
||||
return {
|
||||
query,
|
||||
result: error.output,
|
||||
error: error.message,
|
||||
success: false,
|
||||
};
|
||||
}
|
||||
const msg = formatError(error);
|
||||
return {
|
||||
query,
|
||||
@@ -428,7 +491,7 @@ export function createWebFetchTool(
|
||||
description:
|
||||
"Fetch content from URLs and analyze them using the provided prompts. " +
|
||||
"Use for retrieving documentation, API references, or any web content. " +
|
||||
"Each request includes a URL and a prompt describing what information to extract.",
|
||||
"Each request includes a URL and a prompt describing what information to extract. Fetch independent URLs together in one call, and call this tool in the same response as other independent tool calls.",
|
||||
inputSchema: zodToJsonSchema(FetchWebContentInputSchema),
|
||||
timeoutMs: timeoutMs * 2,
|
||||
retryable: true,
|
||||
|
||||
@@ -144,4 +144,30 @@ describe("createApplyPatchExecutor", () => {
|
||||
),
|
||||
).rejects.toThrow("Invalid patch text - incomplete sentinels");
|
||||
});
|
||||
|
||||
it("rejects a patch when a hunk context does not match", async () => {
|
||||
const filePath = path.join(tempDir, "note.txt");
|
||||
const original = ["alpha", "beta", "gamma"].join("\n");
|
||||
await fs.writeFile(filePath, original, "utf-8");
|
||||
const execute = createApplyPatchExecutor();
|
||||
|
||||
await expect(
|
||||
execute(
|
||||
{
|
||||
input: [
|
||||
"*** Update File: note.txt",
|
||||
"@@",
|
||||
" unrelated heading",
|
||||
" missing middle",
|
||||
"+replacement",
|
||||
" absent footer",
|
||||
].join("\n"),
|
||||
},
|
||||
tempDir,
|
||||
{} as never,
|
||||
),
|
||||
).rejects.toThrow(/note\.txt: hunk 1: Could not find matching context/);
|
||||
|
||||
await expect(fs.readFile(filePath, "utf-8")).resolves.toBe(original);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
PatchActionType,
|
||||
type PatchChunk,
|
||||
PatchParser,
|
||||
type PatchWarning,
|
||||
} from "./apply-patch-parser";
|
||||
|
||||
interface FileChange {
|
||||
@@ -252,6 +253,25 @@ function patchToChanges(
|
||||
return changes;
|
||||
}
|
||||
|
||||
function formatSkippedHunkFailure(warnings: readonly PatchWarning[]): string {
|
||||
const lines = [
|
||||
`Patch could not be applied because ${warnings.length} hunk${warnings.length === 1 ? "" : "s"} did not match the current file content.`,
|
||||
];
|
||||
|
||||
for (const warning of warnings) {
|
||||
const hunkNumber =
|
||||
warning.chunkIndex === undefined
|
||||
? "unknown"
|
||||
: String(warning.chunkIndex + 1);
|
||||
lines.push(`${warning.path}: hunk ${hunkNumber}: ${warning.message}`);
|
||||
if (warning.context) {
|
||||
lines.push(`Context:\n${warning.context}`);
|
||||
}
|
||||
}
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
async function applyChanges(
|
||||
changes: Record<string, FileChange>,
|
||||
cwd: string,
|
||||
@@ -326,6 +346,10 @@ export function createApplyPatchExecutor(
|
||||
);
|
||||
const parser = new PatchParser(normalizedInput.lines, currentFiles);
|
||||
const { patch, fuzz } = parser.parse();
|
||||
if (patch.warnings && patch.warnings.length > 0) {
|
||||
throw new DiffError(formatSkippedHunkFailure(patch.warnings));
|
||||
}
|
||||
|
||||
const changes = patchToChanges(patch, currentFiles);
|
||||
const touched = await applyChanges(changes, cwd, encoding, restrictToCwd);
|
||||
|
||||
@@ -338,11 +362,6 @@ export function createApplyPatchExecutor(
|
||||
if (fuzz > 0) {
|
||||
responseLines.push(`Note: Patch applied with fuzz factor ${fuzz}`);
|
||||
}
|
||||
if (patch.warnings && patch.warnings.length > 0) {
|
||||
for (const warning of patch.warnings) {
|
||||
responseLines.push(`Warning (${warning.path}): ${warning.message}`);
|
||||
}
|
||||
}
|
||||
return responseLines.join("\n");
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { AgentToolContext } from "@cline/shared";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createBashExecutor } from "./bash";
|
||||
import { CommandExitError, createBashExecutor } from "./bash";
|
||||
|
||||
const ctx: AgentToolContext = {
|
||||
agentId: "agent-1",
|
||||
@@ -20,6 +20,60 @@ describe("createBashExecutor", () => {
|
||||
await expect(bash("exit 1", process.cwd(), ctx)).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("includes stdout and exit code on non-zero exit", async () => {
|
||||
const bash = createBashExecutor();
|
||||
let error: unknown;
|
||||
try {
|
||||
await bash(
|
||||
{
|
||||
command: process.execPath,
|
||||
args: [
|
||||
"-e",
|
||||
"process.stdout.write('failure details'); process.exit(1)",
|
||||
],
|
||||
},
|
||||
process.cwd(),
|
||||
ctx,
|
||||
);
|
||||
} catch (caught) {
|
||||
error = caught;
|
||||
}
|
||||
|
||||
if (!(error instanceof CommandExitError)) {
|
||||
throw new Error("Expected CommandExitError");
|
||||
}
|
||||
expect(error.exitCode).toBe(1);
|
||||
expect(error.output).toContain("[Command exited with code 1]");
|
||||
expect(error.output).toContain("failure details");
|
||||
});
|
||||
|
||||
it("excludes stderr on non-zero exit when combineOutput is false", async () => {
|
||||
const bash = createBashExecutor({ combineOutput: false });
|
||||
let error: unknown;
|
||||
try {
|
||||
await bash(
|
||||
{
|
||||
command: process.execPath,
|
||||
args: [
|
||||
"-e",
|
||||
"process.stdout.write('visible'); process.stderr.write('hidden'); process.exit(1)",
|
||||
],
|
||||
},
|
||||
process.cwd(),
|
||||
ctx,
|
||||
);
|
||||
} catch (caught) {
|
||||
error = caught;
|
||||
}
|
||||
|
||||
if (!(error instanceof CommandExitError)) {
|
||||
throw new Error("Expected CommandExitError");
|
||||
}
|
||||
expect(error.output).toContain("visible");
|
||||
expect(error.output).not.toContain("[stderr]");
|
||||
expect(error.output).not.toContain("hidden");
|
||||
});
|
||||
|
||||
it("includes stderr in combined output on success", async () => {
|
||||
const bash = createBashExecutor({ combineOutput: true });
|
||||
const output = await bash(
|
||||
@@ -61,17 +115,95 @@ describe("createBashExecutor", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("truncates output exceeding maxOutputBytes", async () => {
|
||||
const bash = createBashExecutor({ maxOutputBytes: 10 });
|
||||
it("middle-truncates output exceeding maxOutputBytes, keeping head and tail", async () => {
|
||||
const bash = createBashExecutor({ maxOutputBytes: 20 });
|
||||
const output = await bash(
|
||||
{
|
||||
command: process.execPath,
|
||||
args: ["-e", "process.stdout.write('a'.repeat(100))"],
|
||||
args: ["-e", "process.stdout.write('HEAD' + 'x'.repeat(100) + 'TAIL')"],
|
||||
},
|
||||
process.cwd(),
|
||||
ctx,
|
||||
);
|
||||
expect(output).toContain("[Output truncated:");
|
||||
expect(output).toContain("HEAD");
|
||||
expect(output).toContain("TAIL");
|
||||
expect(output).toContain("[... output truncated: 108 chars total");
|
||||
expect(output.length).toBeLessThan(300);
|
||||
});
|
||||
|
||||
it("keeps default-capped output bounded with the notice in the preserved head/tail", async () => {
|
||||
// Provider-request building (session/services/message-builder.ts)
|
||||
// may middle-cut long tool-result strings again with its own
|
||||
// backstop. The executor keeps its truncation notice in the head and
|
||||
// tail halves, so the recovery guidance survives any such cut.
|
||||
const bash = createBashExecutor();
|
||||
const output = await bash(
|
||||
{
|
||||
command: process.execPath,
|
||||
args: ["-e", "process.stdout.write('x'.repeat(60_000))"],
|
||||
},
|
||||
process.cwd(),
|
||||
ctx,
|
||||
);
|
||||
expect(output.length).toBeLessThanOrEqual(50_000);
|
||||
expect(output).toContain("output truncated: 60000 chars total");
|
||||
});
|
||||
|
||||
it("does not truncate output within maxOutputBytes", async () => {
|
||||
const bash = createBashExecutor({ maxOutputBytes: 1000 });
|
||||
const payload = "b".repeat(500);
|
||||
const output = await bash(
|
||||
{
|
||||
command: process.execPath,
|
||||
args: ["-e", `process.stdout.write('${payload}')`],
|
||||
},
|
||||
process.cwd(),
|
||||
ctx,
|
||||
);
|
||||
expect(output).toBe(payload);
|
||||
});
|
||||
|
||||
it("marks truncation in the captured output when a failing command floods stderr", async () => {
|
||||
const bash = createBashExecutor({ maxOutputBytes: 20 });
|
||||
let error: unknown;
|
||||
try {
|
||||
await bash(
|
||||
{
|
||||
command: process.execPath,
|
||||
args: [
|
||||
"-e",
|
||||
"process.stderr.write('ERR' + 'x'.repeat(100) + 'TAIL'); process.exit(1)",
|
||||
],
|
||||
},
|
||||
process.cwd(),
|
||||
ctx,
|
||||
);
|
||||
} catch (caught) {
|
||||
error = caught;
|
||||
}
|
||||
|
||||
if (!(error instanceof CommandExitError)) {
|
||||
throw new Error("Expected CommandExitError");
|
||||
}
|
||||
expect(error.output).toContain("output truncated");
|
||||
});
|
||||
|
||||
it("keeps the tail of streamed output written in many chunks", async () => {
|
||||
const bash = createBashExecutor({ maxOutputBytes: 40 });
|
||||
const output = await bash(
|
||||
{
|
||||
command: process.execPath,
|
||||
args: [
|
||||
"-e",
|
||||
"for (let i = 0; i < 50; i++) process.stdout.write('line' + i + '\\n'); process.stdout.write('FINAL')",
|
||||
],
|
||||
},
|
||||
process.cwd(),
|
||||
ctx,
|
||||
);
|
||||
expect(output).toContain("line0");
|
||||
expect(output).toContain("FINAL");
|
||||
expect(output).toContain("output truncated");
|
||||
});
|
||||
|
||||
it("rejects when abort signal fires", async () => {
|
||||
@@ -84,6 +216,42 @@ describe("createBashExecutor", () => {
|
||||
"aborted",
|
||||
);
|
||||
});
|
||||
|
||||
it("flushes a trailing incomplete multibyte sequence instead of dropping it", async () => {
|
||||
const bash = createBashExecutor();
|
||||
// Output ends with the first byte of a two-byte UTF-8 sequence; the
|
||||
// decoder must flush it at end-of-stream (as U+FFFD) rather than
|
||||
// silently dropping buffered bytes.
|
||||
const output = await bash(
|
||||
{
|
||||
command: process.execPath,
|
||||
args: ["-e", "process.stdout.write(Buffer.from([0x61, 0x62, 0xc3]))"],
|
||||
},
|
||||
process.cwd(),
|
||||
ctx,
|
||||
);
|
||||
expect(output).toHaveLength(3);
|
||||
expect(output.startsWith("ab")).toBe(true);
|
||||
});
|
||||
|
||||
it("honors maxOutputChars and the deprecated maxOutputBytes alias", async () => {
|
||||
const emit = {
|
||||
command: process.execPath,
|
||||
args: ["-e", "process.stdout.write('x'.repeat(500))"],
|
||||
};
|
||||
const renamed = await createBashExecutor({ maxOutputChars: 100 })(
|
||||
emit,
|
||||
process.cwd(),
|
||||
ctx,
|
||||
);
|
||||
const alias = await createBashExecutor({ maxOutputBytes: 100 })(
|
||||
emit,
|
||||
process.cwd(),
|
||||
ctx,
|
||||
);
|
||||
expect(renamed).toContain("output truncated: 500 chars total");
|
||||
expect(alias).toContain("output truncated: 500 chars total");
|
||||
});
|
||||
});
|
||||
|
||||
describe.runIf(process.platform === "win32")("createWindowsExecutor", () => {
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
*/
|
||||
|
||||
import { spawn } from "node:child_process";
|
||||
import { StringDecoder } from "node:string_decoder";
|
||||
import {
|
||||
type AgentToolContext,
|
||||
getDefaultShell,
|
||||
@@ -12,6 +13,17 @@ import {
|
||||
} from "@cline/shared";
|
||||
import { TimeoutError } from "../helpers";
|
||||
import type { BashExecutor } from "../types";
|
||||
import { MAX_COMMAND_OUTPUT_CHARS } from "./output-limits";
|
||||
|
||||
export class CommandExitError extends Error {
|
||||
constructor(
|
||||
readonly exitCode: number,
|
||||
readonly output: string,
|
||||
) {
|
||||
super(`Command exited with code ${exitCode}`);
|
||||
this.name = "CommandExitError";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for the bash executor
|
||||
@@ -30,8 +42,18 @@ export interface BashExecutorOptions {
|
||||
timeoutMs?: number;
|
||||
|
||||
/**
|
||||
* Maximum output size in bytes
|
||||
* @default 1_000_000 (1MB)
|
||||
* Maximum output kept, in characters. Output beyond this is
|
||||
* middle-truncated: the head and tail are preserved and the middle is
|
||||
* elided, since build and test failures usually live at the end of the
|
||||
* output.
|
||||
* @default 48_000 — see MAX_COMMAND_OUTPUT_CHARS in output-limits.ts
|
||||
*/
|
||||
maxOutputChars?: number;
|
||||
|
||||
/**
|
||||
* @deprecated Misnamed — the limit was always enforced in characters,
|
||||
* not bytes. Use {@link maxOutputChars}; this alias is honored when
|
||||
* maxOutputChars is not set.
|
||||
*/
|
||||
maxOutputBytes?: number;
|
||||
|
||||
@@ -54,11 +76,70 @@ interface SpawnConfig {
|
||||
env: Record<string, string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Collects stream output with bounded memory: the first half of the budget
|
||||
* is kept verbatim, the rest rolls so the latest output always survives.
|
||||
*/
|
||||
function createRollingCollector(maxChars: number) {
|
||||
const headLimit = Math.ceil(maxChars / 2);
|
||||
const tailLimit = Math.max(1, maxChars - headLimit);
|
||||
// StringDecoder keeps multibyte UTF-8 sequences split across stream
|
||||
// chunks intact instead of corrupting them at chunk boundaries.
|
||||
const decoder = new StringDecoder("utf8");
|
||||
let head = "";
|
||||
let tail = "";
|
||||
let totalChars = 0;
|
||||
|
||||
const appendText = (text: string): void => {
|
||||
if (!text) return;
|
||||
totalChars += text.length;
|
||||
const headRoom = headLimit - head.length;
|
||||
if (headRoom > 0) {
|
||||
head += text.slice(0, headRoom);
|
||||
tail = (tail + text.slice(headRoom)).slice(-tailLimit);
|
||||
return;
|
||||
}
|
||||
tail = (tail + text).slice(-tailLimit);
|
||||
};
|
||||
|
||||
return {
|
||||
append(data: Buffer): void {
|
||||
appendText(decoder.write(data));
|
||||
},
|
||||
snapshot() {
|
||||
// Flush bytes the decoder buffered for an incomplete multibyte
|
||||
// sequence at end-of-stream; otherwise the final characters of
|
||||
// non-ASCII output are silently dropped.
|
||||
appendText(decoder.end());
|
||||
return {
|
||||
text: head + tail,
|
||||
totalChars,
|
||||
dropped: totalChars > head.length + tail.length,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function truncateMiddle(
|
||||
text: string,
|
||||
maxChars: number,
|
||||
totalChars: number,
|
||||
): string {
|
||||
const headLimit = Math.ceil(maxChars / 2);
|
||||
const tailLimit = Math.max(1, maxChars - headLimit);
|
||||
return (
|
||||
`${text.slice(0, headLimit)}\n` +
|
||||
`[... output truncated: ${totalChars} chars total. ` +
|
||||
"Refine the command (grep, head, tail) to view the elided middle ...]\n" +
|
||||
text.slice(-tailLimit)
|
||||
);
|
||||
}
|
||||
|
||||
function spawnAndCollect(
|
||||
config: SpawnConfig,
|
||||
context: AgentToolContext,
|
||||
timeoutMs: number,
|
||||
maxOutputBytes: number,
|
||||
maxOutputChars: number,
|
||||
combineOutput: boolean,
|
||||
): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
@@ -76,9 +157,8 @@ function spawnAndCollect(
|
||||
});
|
||||
const childPid = child.pid;
|
||||
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
let outputSize = 0;
|
||||
const stdout = createRollingCollector(maxOutputChars);
|
||||
const stderr = createRollingCollector(maxOutputChars);
|
||||
let killed = false;
|
||||
let settled = false;
|
||||
|
||||
@@ -132,32 +212,52 @@ function spawnAndCollect(
|
||||
};
|
||||
|
||||
child.stdout?.on("data", (data: Buffer) => {
|
||||
outputSize += data.length;
|
||||
if (outputSize <= maxOutputBytes) stdout += data.toString();
|
||||
stdout.append(data);
|
||||
});
|
||||
|
||||
child.stderr?.on("data", (data: Buffer) => {
|
||||
outputSize += data.length;
|
||||
if (outputSize <= maxOutputBytes) stderr += data.toString();
|
||||
stderr.append(data);
|
||||
});
|
||||
|
||||
child.on("close", (code) => {
|
||||
cleanup();
|
||||
if (killed) return;
|
||||
|
||||
let output = combineOutput
|
||||
? stdout + (stderr ? `\n[stderr]\n${stderr}` : "")
|
||||
: stdout;
|
||||
|
||||
if (outputSize > maxOutputBytes) {
|
||||
output += `\n\n[Output truncated: ${outputSize} bytes total, showing first ${maxOutputBytes} bytes]`;
|
||||
}
|
||||
const out = stdout.snapshot();
|
||||
const err = stderr.snapshot();
|
||||
|
||||
if (code !== 0) {
|
||||
settle(() =>
|
||||
reject(new Error(stderr || `Command exited with code ${code}`)),
|
||||
);
|
||||
const exitCode = code ?? 1;
|
||||
let failureOutput = combineOutput
|
||||
? out.text + (err.text ? `\n[stderr]\n${err.text}` : "")
|
||||
: out.text;
|
||||
const dropped = out.dropped || (combineOutput && err.dropped);
|
||||
const totalChars = combineOutput
|
||||
? out.totalChars + err.totalChars
|
||||
: out.totalChars;
|
||||
if (dropped || failureOutput.length > maxOutputChars) {
|
||||
failureOutput = truncateMiddle(
|
||||
failureOutput,
|
||||
maxOutputChars,
|
||||
totalChars,
|
||||
);
|
||||
}
|
||||
const result =
|
||||
failureOutput.length > 0
|
||||
? `[Command exited with code ${exitCode}]\n${failureOutput}`
|
||||
: `[Command exited with code ${exitCode}]`;
|
||||
settle(() => reject(new CommandExitError(exitCode, result)));
|
||||
} else {
|
||||
let output = combineOutput
|
||||
? out.text + (err.text ? `\n[stderr]\n${err.text}` : "")
|
||||
: out.text;
|
||||
const dropped = out.dropped || (combineOutput && err.dropped);
|
||||
if (dropped || output.length > maxOutputChars) {
|
||||
const totalChars = combineOutput
|
||||
? out.totalChars + err.totalChars
|
||||
: out.totalChars;
|
||||
output = truncateMiddle(output, maxOutputChars, totalChars);
|
||||
}
|
||||
settle(() => resolve(output));
|
||||
}
|
||||
});
|
||||
@@ -190,10 +290,13 @@ export function createBashExecutor(
|
||||
const {
|
||||
shell = getDefaultShell(process.platform),
|
||||
timeoutMs = 30000,
|
||||
maxOutputBytes = 1_000_000,
|
||||
env = {},
|
||||
combineOutput = true,
|
||||
} = options;
|
||||
const maxOutputChars =
|
||||
options.maxOutputChars ??
|
||||
options.maxOutputBytes ??
|
||||
MAX_COMMAND_OUTPUT_CHARS;
|
||||
|
||||
return (command, cwd, context) => {
|
||||
const isStructured = typeof command !== "string";
|
||||
@@ -208,7 +311,7 @@ export function createBashExecutor(
|
||||
},
|
||||
context,
|
||||
timeoutMs,
|
||||
maxOutputBytes,
|
||||
maxOutputChars,
|
||||
combineOutput,
|
||||
);
|
||||
};
|
||||
|
||||
@@ -6,47 +6,202 @@ import { createFileReadExecutor } from "./file-read";
|
||||
|
||||
describe("createFileReadExecutor", () => {
|
||||
it("reads a file from an absolute path", async () => {
|
||||
const result = await readTempFile("hello absolute path");
|
||||
expect(result).toBe("1 | hello absolute path");
|
||||
});
|
||||
|
||||
it("returns only the requested inclusive line range", async () => {
|
||||
const result = await readTempFile("alpha\nbeta\ngamma\ndelta", {
|
||||
start_line: 2,
|
||||
end_line: 3,
|
||||
});
|
||||
expect(result).toBe("2 | beta\n3 | gamma");
|
||||
});
|
||||
|
||||
async function readTempFile(
|
||||
content: string,
|
||||
range?: { start_line?: number; end_line?: number },
|
||||
): Promise<string> {
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "agents-file-read-"));
|
||||
const filePath = path.join(dir, "example.txt");
|
||||
await fs.writeFile(filePath, "hello absolute path", "utf-8");
|
||||
try {
|
||||
const filePath = path.join(dir, "example.txt");
|
||||
await fs.writeFile(filePath, content, "utf-8");
|
||||
const readFile = createFileReadExecutor();
|
||||
return (await readFile(
|
||||
{ path: filePath, ...range },
|
||||
{ agentId: "agent-1", conversationId: "conv-1", iteration: 1 },
|
||||
)) as string;
|
||||
} finally {
|
||||
await fs.rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
const numberedLines = (count: number) =>
|
||||
Array.from({ length: count }, (_, i) => `line ${i + 1}`).join("\n");
|
||||
|
||||
it("windows whole-file reads to the line cap and reports how to paginate", async () => {
|
||||
const result = await readTempFile(numberedLines(2500));
|
||||
|
||||
expect(result).toContain("1 | line 1");
|
||||
expect(result).toContain("2000 | line 2000");
|
||||
expect(result).not.toContain("line 2001");
|
||||
expect(result).toContain(
|
||||
"[Showing lines 1-2000 of 2500. Use start_line/end_line to read other sections.]",
|
||||
);
|
||||
});
|
||||
|
||||
it("bounds line counting for unranged reads after the capture window", async () => {
|
||||
const result = await readTempFile(numberedLines(60_000));
|
||||
|
||||
expect(result).toContain("1 | line 1");
|
||||
expect(result).toContain("2000 | line 2000");
|
||||
expect(result).not.toContain("line 2001");
|
||||
expect(result).toContain(
|
||||
"[Showing lines 1-2000 of 50000+ lines. Use start_line/end_line to read other sections.]",
|
||||
);
|
||||
});
|
||||
|
||||
it("honors explicit ranges beyond the default window start", async () => {
|
||||
const result = await readTempFile(numberedLines(2500), {
|
||||
start_line: 2400,
|
||||
end_line: 2402,
|
||||
});
|
||||
|
||||
expect(result).toBe("2400 | line 2400\n2401 | line 2401\n2402 | line 2402");
|
||||
});
|
||||
|
||||
it("reports the requested finite end line when a range exceeds the line cap", async () => {
|
||||
const result = await readTempFile(numberedLines(3000), {
|
||||
start_line: 1,
|
||||
end_line: 3000,
|
||||
});
|
||||
|
||||
expect(result).toContain("1 | line 1");
|
||||
expect(result).toContain("2000 | line 2000");
|
||||
expect(result).not.toContain("line 2001");
|
||||
expect(result).toContain(
|
||||
"[Showing lines 1-2000 of 3000. Use start_line/end_line to read other sections.]",
|
||||
);
|
||||
});
|
||||
|
||||
it("reads a requested range from a text file larger than the size gate", async () => {
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "agents-file-read-"));
|
||||
const filePath = path.join(dir, "large.txt");
|
||||
await fs.writeFile(filePath, numberedLines(100), "utf-8");
|
||||
|
||||
try {
|
||||
const readFile = createFileReadExecutor();
|
||||
const result = await readFile(
|
||||
{ path: filePath },
|
||||
{
|
||||
agentId: "agent-1",
|
||||
conversationId: "conv-1",
|
||||
iteration: 1,
|
||||
},
|
||||
);
|
||||
expect(result).toBe("1 | hello absolute path");
|
||||
const readFile = createFileReadExecutor({ maxFileSizeBytes: 10 });
|
||||
const result = (await readFile(
|
||||
{ path: filePath, start_line: 50, end_line: 52 },
|
||||
{ agentId: "agent-1", conversationId: "conv-1", iteration: 1 },
|
||||
)) as string;
|
||||
|
||||
expect(result).toBe("50 | line 50\n51 | line 51\n52 | line 52");
|
||||
} finally {
|
||||
await fs.rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("returns only the requested inclusive line range", async () => {
|
||||
it("returns a window for an unranged text file larger than the size gate", async () => {
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "agents-file-read-"));
|
||||
const filePath = path.join(dir, "example.txt");
|
||||
await fs.writeFile(filePath, "alpha\nbeta\ngamma\ndelta", "utf-8");
|
||||
const filePath = path.join(dir, "large.txt");
|
||||
await fs.writeFile(filePath, numberedLines(2500), "utf-8");
|
||||
|
||||
try {
|
||||
const readFile = createFileReadExecutor();
|
||||
const result = await readFile(
|
||||
{ path: filePath, start_line: 2, end_line: 3 },
|
||||
{
|
||||
agentId: "agent-1",
|
||||
conversationId: "conv-1",
|
||||
iteration: 1,
|
||||
},
|
||||
const readFile = createFileReadExecutor({ maxFileSizeBytes: 10 });
|
||||
const result = (await readFile(
|
||||
{ path: filePath },
|
||||
{ agentId: "agent-1", conversationId: "conv-1", iteration: 1 },
|
||||
)) as string;
|
||||
|
||||
expect(result).toContain("1 | line 1");
|
||||
expect(result).toContain("2000 | line 2000");
|
||||
expect(result).toContain(
|
||||
"[Showing lines 1-2000 of 2500. Use start_line/end_line to read other sections.]",
|
||||
);
|
||||
expect(result).toBe("2 | beta\n3 | gamma");
|
||||
} finally {
|
||||
await fs.rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("truncates very long lines", async () => {
|
||||
const result = await readTempFile(`short\n${"y".repeat(5000)}\nend`);
|
||||
|
||||
expect(result).toContain("short");
|
||||
expect(result).toContain("[line truncated]");
|
||||
expect(result).toContain("end");
|
||||
expect(result.length).toBeLessThan(2500);
|
||||
});
|
||||
|
||||
it("rejects very large text files instead of streaming them", async () => {
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "agents-file-read-"));
|
||||
const filePath = path.join(dir, "huge.txt");
|
||||
await fs.writeFile(filePath, "hello", "utf-8");
|
||||
await fs.truncate(filePath, 100_000_001);
|
||||
|
||||
try {
|
||||
const readFile = createFileReadExecutor();
|
||||
|
||||
await expect(
|
||||
readFile(
|
||||
{ path: filePath, start_line: 1, end_line: 1 },
|
||||
{ agentId: "agent-1", conversationId: "conv-1", iteration: 1 },
|
||||
),
|
||||
).rejects.toThrow("Text file too large to stream safely");
|
||||
} finally {
|
||||
await fs.rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects when the abort signal fires before reading", async () => {
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "agents-file-read-"));
|
||||
const filePath = path.join(dir, "example.txt");
|
||||
await fs.writeFile(filePath, "hello", "utf-8");
|
||||
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
controller.abort(new Error("stop reading"));
|
||||
const readFile = createFileReadExecutor();
|
||||
|
||||
await expect(
|
||||
readFile(
|
||||
{ path: filePath },
|
||||
{
|
||||
agentId: "agent-1",
|
||||
conversationId: "conv-1",
|
||||
iteration: 1,
|
||||
signal: controller.signal,
|
||||
},
|
||||
),
|
||||
).rejects.toThrow("stop reading");
|
||||
} finally {
|
||||
await fs.rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("caps the returned window by characters for dense files", async () => {
|
||||
// 1500 lines of 100 chars each is ~150k chars, well over the ~50k cap
|
||||
// while staying under the 2000-line cap.
|
||||
const result = await readTempFile(
|
||||
Array.from({ length: 1500 }, () => "z".repeat(100)).join("\n"),
|
||||
);
|
||||
|
||||
// Bounded by the read window cap; the pagination notice sits at the
|
||||
// end of the kept window, inside the tail that any downstream
|
||||
// provider-request middle-cut preserves.
|
||||
expect(result.length).toBeLessThanOrEqual(50_000);
|
||||
expect(result).toContain("of 1500. Use start_line/end_line");
|
||||
});
|
||||
|
||||
it("keeps dense output capped when total line-number width grows after capture", async () => {
|
||||
const result = await readTempFile(
|
||||
Array.from({ length: 10_000 }, () => "z".repeat(100)).join("\n"),
|
||||
);
|
||||
|
||||
expect(result.length).toBeLessThanOrEqual(50_000);
|
||||
expect(result).toContain("of 10000. Use start_line/end_line");
|
||||
});
|
||||
|
||||
it("returns image blocks for image files when the model supports images", async () => {
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "agents-file-read-"));
|
||||
const filePath = path.join(dir, "example.png");
|
||||
|
||||
@@ -4,12 +4,19 @@
|
||||
* Built-in implementation for reading files using Node.js fs module.
|
||||
*/
|
||||
|
||||
import { createReadStream } from "node:fs";
|
||||
import * as fs from "node:fs/promises";
|
||||
import * as path from "node:path";
|
||||
import { createInterface } from "node:readline";
|
||||
import type { AgentToolContext } from "@cline/shared";
|
||||
import { resolveExistingFilePath } from "@cline/shared/storage";
|
||||
import type { ReadFileRequest } from "../schemas";
|
||||
import type { FileReadExecutor } from "../types";
|
||||
import {
|
||||
MAX_LINE_CHARS,
|
||||
MAX_READ_LINES,
|
||||
MAX_READ_OUTPUT_CHARS,
|
||||
} from "./output-limits";
|
||||
|
||||
const IMAGE_MEDIA_TYPES = new Map<string, string>([
|
||||
[".gif", "image/gif"],
|
||||
@@ -48,6 +55,139 @@ const DEFAULT_FILE_READ_OPTIONS: Required<FileReadExecutorOptions> = {
|
||||
includeLineNumbers: true, // Include line numbers by default
|
||||
};
|
||||
|
||||
const MAX_TEXT_STREAM_BYTES = 100_000_000;
|
||||
const MAX_UNRANGED_LINE_SCAN = 50_000;
|
||||
|
||||
interface CapturedLine {
|
||||
lineNumber: number;
|
||||
text: string;
|
||||
}
|
||||
|
||||
function getAbortError(signal: AbortSignal): Error {
|
||||
const { reason } = signal;
|
||||
if (reason instanceof Error) {
|
||||
return reason;
|
||||
}
|
||||
if (reason !== undefined) {
|
||||
return new Error(String(reason));
|
||||
}
|
||||
return new Error("File read was aborted");
|
||||
}
|
||||
|
||||
async function readTextWindow(
|
||||
filePath: string,
|
||||
encoding: BufferEncoding,
|
||||
includeLineNumbers: boolean,
|
||||
startLine: number | null | undefined,
|
||||
endLine: number | null | undefined,
|
||||
signal?: AbortSignal,
|
||||
): Promise<string> {
|
||||
if (signal?.aborted) {
|
||||
throw getAbortError(signal);
|
||||
}
|
||||
|
||||
const requestedStartLine = Math.max(startLine ?? 1, 1);
|
||||
const requestedEndLine = endLine ?? Number.POSITIVE_INFINITY;
|
||||
const hasFiniteEndLine = Number.isFinite(requestedEndLine);
|
||||
const maxScannedLine = hasFiniteEndLine
|
||||
? requestedEndLine
|
||||
: requestedStartLine + MAX_UNRANGED_LINE_SCAN - 1;
|
||||
const captured: CapturedLine[] = [];
|
||||
let chars = 0;
|
||||
let totalLines = 0;
|
||||
let capped = false;
|
||||
let approximateTotalLines = false;
|
||||
const maxCapturedLineNumber = Number.isFinite(requestedEndLine)
|
||||
? Math.min(requestedEndLine, requestedStartLine + MAX_READ_LINES - 1)
|
||||
: requestedStartLine + MAX_READ_LINES - 1;
|
||||
const lineNumberPrefixChars = includeLineNumbers
|
||||
? String(maxCapturedLineNumber).length + 3
|
||||
: 0;
|
||||
|
||||
const stream = createReadStream(filePath, { encoding });
|
||||
const reader = createInterface({
|
||||
input: stream,
|
||||
crlfDelay: Number.POSITIVE_INFINITY,
|
||||
});
|
||||
const abortHandler = signal
|
||||
? () => stream.destroy(getAbortError(signal))
|
||||
: undefined;
|
||||
|
||||
if (signal && abortHandler) {
|
||||
signal.addEventListener("abort", abortHandler, { once: true });
|
||||
}
|
||||
|
||||
try {
|
||||
for await (const rawLine of reader) {
|
||||
totalLines += 1;
|
||||
if (totalLines > requestedEndLine) {
|
||||
totalLines = requestedEndLine;
|
||||
break;
|
||||
}
|
||||
if (!hasFiniteEndLine && capped && totalLines >= maxScannedLine) {
|
||||
approximateTotalLines = true;
|
||||
break;
|
||||
}
|
||||
if (totalLines < requestedStartLine || capped) {
|
||||
continue;
|
||||
}
|
||||
if (captured.length >= MAX_READ_LINES) {
|
||||
capped = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
let line = rawLine;
|
||||
if (line.length > MAX_LINE_CHARS) {
|
||||
line = `${line.slice(0, MAX_LINE_CHARS)} [line truncated]`;
|
||||
}
|
||||
|
||||
const nextChars = chars + line.length + lineNumberPrefixChars + 1;
|
||||
if (nextChars > MAX_READ_OUTPUT_CHARS && captured.length > 0) {
|
||||
capped = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
captured.push({ lineNumber: totalLines, text: line });
|
||||
chars = nextChars;
|
||||
}
|
||||
} finally {
|
||||
if (signal && abortHandler) {
|
||||
signal.removeEventListener("abort", abortHandler);
|
||||
}
|
||||
reader.close();
|
||||
stream.destroy();
|
||||
}
|
||||
|
||||
const maxLineNumWidth = String(
|
||||
captured[captured.length - 1]?.lineNumber ?? totalLines,
|
||||
).length;
|
||||
const body = captured
|
||||
.map(({ lineNumber, text }) =>
|
||||
includeLineNumbers
|
||||
? `${String(lineNumber).padStart(maxLineNumWidth, " ")} | ${text}`
|
||||
: text,
|
||||
)
|
||||
.join("\n");
|
||||
const lastCapturedLine = captured[captured.length - 1]?.lineNumber;
|
||||
if (lastCapturedLine === undefined) {
|
||||
return body;
|
||||
}
|
||||
|
||||
const effectiveEndLine = Math.min(requestedEndLine, totalLines);
|
||||
if (lastCapturedLine >= effectiveEndLine) {
|
||||
return body;
|
||||
}
|
||||
const totalLineText = approximateTotalLines
|
||||
? `${totalLines}+ lines`
|
||||
: effectiveEndLine;
|
||||
|
||||
return (
|
||||
`${body}\n\n` +
|
||||
`[Showing lines ${requestedStartLine}-${lastCapturedLine} of ${totalLineText}. ` +
|
||||
"Use start_line/end_line to read other sections.]"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a file read executor using Node.js fs module
|
||||
*
|
||||
@@ -88,15 +228,12 @@ export function createFileReadExecutor(
|
||||
throw new Error(`Path is not a file: ${resolvedPath}`);
|
||||
}
|
||||
|
||||
// Check file size
|
||||
if (stat.size > maxFileSizeBytes) {
|
||||
throw new Error(
|
||||
`File too large: ${stat.size} bytes (max: ${maxFileSizeBytes} bytes). ` +
|
||||
`Consider reading specific sections or using a different approach.`,
|
||||
);
|
||||
}
|
||||
|
||||
if (imageMediaType) {
|
||||
if (stat.size > maxFileSizeBytes) {
|
||||
throw new Error(
|
||||
`Image file too large: ${stat.size} bytes (max: ${maxFileSizeBytes} bytes).`,
|
||||
);
|
||||
}
|
||||
if (context.metadata?.modelSupportsImages !== true) {
|
||||
throw new Error("Current model does not support image input");
|
||||
}
|
||||
@@ -114,27 +251,19 @@ export function createFileReadExecutor(
|
||||
];
|
||||
}
|
||||
|
||||
// Read file content
|
||||
const content = await fs.readFile(resolvedPath, encoding);
|
||||
const allLines = content.split("\n");
|
||||
const rangeStart = Math.max((start_line ?? 1) - 1, 0);
|
||||
const rangeEndExclusive = Math.min(
|
||||
end_line ?? allLines.length,
|
||||
allLines.length,
|
||||
);
|
||||
const lines = allLines.slice(rangeStart, rangeEndExclusive);
|
||||
|
||||
// Optionally add line numbers - one-based indexing for better readability
|
||||
if (includeLineNumbers) {
|
||||
const maxLineNumWidth = String(allLines.length).length;
|
||||
return lines
|
||||
.map(
|
||||
(line, i) =>
|
||||
`${String(rangeStart + i + 1).padStart(maxLineNumWidth, " ")} | ${line}`,
|
||||
)
|
||||
.join("\n");
|
||||
if (stat.size > MAX_TEXT_STREAM_BYTES) {
|
||||
throw new Error(
|
||||
`Text file too large to stream safely: ${stat.size} bytes (max: ${MAX_TEXT_STREAM_BYTES} bytes). Use a targeted command such as sed, grep, head, or tail to inspect specific sections.`,
|
||||
);
|
||||
}
|
||||
|
||||
return lines.join("\n");
|
||||
return readTextWindow(
|
||||
resolvedPath,
|
||||
encoding,
|
||||
includeLineNumbers,
|
||||
start_line,
|
||||
end_line,
|
||||
context.signal,
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Shared caps for how much tool output may enter the conversation. Every
|
||||
* character returned by an executor is re-sent to the model on each
|
||||
* subsequent request, so oversized outputs cost quadratically over the
|
||||
* remaining run. Limits are measured in characters (UTF-16 code units),
|
||||
* which tracks token cost more closely than bytes and is what JS strings
|
||||
* measure exactly. Executors enforce these caps; tool descriptions
|
||||
* reference them so the model pages or narrows instead of retrying.
|
||||
*
|
||||
* Truncation notices always live in the preserved head/tail of an entry,
|
||||
* never in the elided middle. Provider-request building may re-truncate
|
||||
* long strings with its own (possibly tighter) middle-cut backstop
|
||||
* (session/services/message-builder.ts); keeping the notices at the edges
|
||||
* means the recovery guidance survives that cut too.
|
||||
*/
|
||||
|
||||
/** Max characters of command output kept; beyond this the middle is elided. */
|
||||
export const MAX_COMMAND_OUTPUT_CHARS = 48_000;
|
||||
|
||||
/** Max lines returned per file read when the range is larger or absent. */
|
||||
export const MAX_READ_LINES = 2_000;
|
||||
|
||||
/** Max characters kept per line in file reads (defangs minified files). */
|
||||
export const MAX_LINE_CHARS = 2_000;
|
||||
|
||||
/** Max characters returned per file read window. */
|
||||
export const MAX_READ_OUTPUT_CHARS = 48_000;
|
||||
|
||||
/** Max characters returned per search query; beyond this the middle is elided. */
|
||||
export const MAX_SEARCH_OUTPUT_CHARS = 48_000;
|
||||
@@ -0,0 +1,39 @@
|
||||
import * as fs from "node:fs/promises";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import type { AgentToolContext } from "@cline/shared";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { MAX_SEARCH_OUTPUT_CHARS } from "./output-limits";
|
||||
import { createSearchExecutor } from "./search";
|
||||
|
||||
const ctx: AgentToolContext = {
|
||||
agentId: "agent-1",
|
||||
conversationId: "conv-1",
|
||||
iteration: 1,
|
||||
};
|
||||
|
||||
describe("createSearchExecutor", () => {
|
||||
it("middle-truncates oversized search output with recovery guidance", async () => {
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "agents-search-"));
|
||||
const filePath = path.join(dir, "large.ts");
|
||||
await fs.writeFile(
|
||||
filePath,
|
||||
`needle ${"x".repeat(MAX_SEARCH_OUTPUT_CHARS * 2)} TAIL`,
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
try {
|
||||
const search = createSearchExecutor({ contextLines: 0 });
|
||||
const result = await search("(?=needle)", dir, ctx);
|
||||
|
||||
expect(result.length).toBeGreaterThan(MAX_SEARCH_OUTPUT_CHARS);
|
||||
expect(result.length).toBeLessThanOrEqual(50_000);
|
||||
expect(result).toContain("Found 1 result for pattern");
|
||||
expect(result).toContain("search output truncated");
|
||||
expect(result).toContain("Narrow the pattern or scope");
|
||||
expect(result).toContain("TAIL");
|
||||
} finally {
|
||||
await fs.rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -10,6 +10,7 @@ import * as path from "node:path";
|
||||
import type { AgentToolContext } from "@cline/shared";
|
||||
import { getFileIndex } from "../../../services/workspace";
|
||||
import type { SearchExecutor } from "../types";
|
||||
import { MAX_SEARCH_OUTPUT_CHARS } from "./output-limits";
|
||||
|
||||
/**
|
||||
* Options for the search executor
|
||||
@@ -363,7 +364,7 @@ export function createSearchExecutor(
|
||||
);
|
||||
}
|
||||
|
||||
return resultLines.join("\n");
|
||||
return capSearchOutput(resultLines.join("\n"));
|
||||
}
|
||||
|
||||
// Fallback to manual regex search
|
||||
@@ -470,6 +471,27 @@ export function createSearchExecutor(
|
||||
);
|
||||
}
|
||||
|
||||
return resultLines.join("\n");
|
||||
return capSearchOutput(resultLines.join("\n"));
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Middle-truncate oversized search output. Matches with long context lines
|
||||
* can blow past the per-query cap even within the maxResults bound; the
|
||||
* head (earliest matches plus the result count) and tail (the refine hint)
|
||||
* are preserved and the middle is elided with a notice teaching the model
|
||||
* to narrow the pattern instead of retrying.
|
||||
*/
|
||||
function capSearchOutput(text: string): string {
|
||||
if (text.length <= MAX_SEARCH_OUTPUT_CHARS) {
|
||||
return text;
|
||||
}
|
||||
const headLimit = Math.ceil(MAX_SEARCH_OUTPUT_CHARS / 2);
|
||||
const tailLimit = Math.max(1, MAX_SEARCH_OUTPUT_CHARS - headLimit);
|
||||
return (
|
||||
`${text.slice(0, headLimit)}\n` +
|
||||
`[... search output truncated: ${text.length} chars total. ` +
|
||||
"Narrow the pattern or scope to view the elided matches ...]\n" +
|
||||
text.slice(-tailLimit)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@ const BASE_TOOL_CATALOG: readonly RuntimeToolCatalogEntry[] = [
|
||||
{
|
||||
id: "read_files",
|
||||
description:
|
||||
"Read the full content of text or image files at the provided absolute paths, or return only an inclusive one-based line range when start_line/end_line are provided.",
|
||||
"Read the content of text or image files at the provided absolute paths, or return only an inclusive one-based line range when start_line/end_line are provided. Long files are windowed; page with start_line/end_line.",
|
||||
headlessToolNames: ["read_files"],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -34,7 +34,7 @@ export const ReadFileLineRangeSchema = z
|
||||
.nullable()
|
||||
.optional()
|
||||
.describe(
|
||||
"Optional one-based ending line number to read through; use null or omit for the end of the file",
|
||||
"Optional one-based ending line number to read through; use null or omit to read to the end of the file or the read cap, whichever comes first",
|
||||
),
|
||||
})
|
||||
.describe("Optional inclusive one-based file line range");
|
||||
@@ -56,7 +56,7 @@ export const ReadFilesInputSchema = z.object({
|
||||
files: z
|
||||
.array(ReadFileRequestSchema)
|
||||
.describe(
|
||||
"Array of file read requests. Omit start_line/end_line or set them to null to return the full file content boundaries; provide integers to return only that inclusive one-based line range. Prefer this tool over running terminal command to get file content for better performance and reliability.",
|
||||
"Array of file read requests. Omit start_line/end_line or set them to null to read from the start; provide integers to return only that inclusive one-based line range. Reads are capped, so page through long files with start_line/end_line. Prefer this tool over running terminal command to get file content for better performance and reliability.",
|
||||
),
|
||||
});
|
||||
|
||||
|
||||
@@ -12,8 +12,8 @@ export type {
|
||||
AgentEvent,
|
||||
AgentExtension as AgentPlugin, // Public-facing alias for extensions
|
||||
AgentExtensionCommand,
|
||||
AgentExtensionCommandResult,
|
||||
AgentExtensionCommand as AgentPluginCommand,
|
||||
AgentExtensionCommandResult,
|
||||
AgentHooks,
|
||||
AgentMode,
|
||||
AgentResult,
|
||||
@@ -468,6 +468,17 @@ export {
|
||||
toggleDisabledTool,
|
||||
writeGlobalSettings,
|
||||
} from "./services/global-settings";
|
||||
export type {
|
||||
PluginMcpSettingsMutation,
|
||||
PluginMcpSettingsSyncResult,
|
||||
RemovePluginMcpServersFromSettingsOptions,
|
||||
SyncPluginMcpServersToSettingsOptions,
|
||||
} from "./services/plugin-mcp-settings";
|
||||
export {
|
||||
disablePluginMcpServersInSettings,
|
||||
removePluginMcpServersFromSettings,
|
||||
syncPluginMcpServersToSettings,
|
||||
} from "./services/plugin-mcp-settings";
|
||||
export type {
|
||||
ListPluginToolsResult,
|
||||
PluginToolSummary,
|
||||
@@ -515,6 +526,7 @@ export {
|
||||
SqliteTeamStore,
|
||||
type SqliteTeamStoreOptions,
|
||||
} from "./services/storage/team-store";
|
||||
export { resolveCoreDistinctId } from "./services/telemetry";
|
||||
export type {
|
||||
CaptureCompactionExecutedProperties,
|
||||
CaptureCompactionSkippedProperties,
|
||||
|
||||
@@ -294,6 +294,73 @@ describe("LocalRuntimeHost", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("resolves OAuth credentials before creating the agent", async () => {
|
||||
const sessionId = "sess-oauth-bootstrap";
|
||||
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(),
|
||||
writeSessionManifest: vi.fn(),
|
||||
listSessions: vi.fn().mockResolvedValue([]),
|
||||
deleteSession: vi.fn().mockResolvedValue({ deleted: true }),
|
||||
};
|
||||
const runtimeBuilder = {
|
||||
build: vi.fn().mockReturnValue({ tools: [], shutdown: vi.fn() }),
|
||||
};
|
||||
const agent = {
|
||||
run: vi.fn().mockResolvedValue(createResult()),
|
||||
continue: vi.fn().mockResolvedValue(createResult()),
|
||||
getMessages: vi.fn().mockReturnValue([]),
|
||||
getAgentId: vi.fn().mockReturnValue("agent-root-1"),
|
||||
getConversationId: vi.fn().mockReturnValue("conv-root-1"),
|
||||
abort: vi.fn(),
|
||||
subscribeEvents: vi.fn().mockReturnValue(() => {}),
|
||||
canStartRun: vi.fn().mockReturnValue(true),
|
||||
shutdown: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
const createAgent = vi.fn(() => agent as never);
|
||||
const oauthTokenManager = {
|
||||
resolveProviderApiKey: vi.fn().mockResolvedValue({
|
||||
apiKey: "workos:resolved-token",
|
||||
refreshed: false,
|
||||
}),
|
||||
};
|
||||
const manager = new RuntimeHostUnderTest({
|
||||
distinctId,
|
||||
sessionService: sessionService as never,
|
||||
runtimeBuilder: runtimeBuilder as never,
|
||||
createAgent,
|
||||
oauthTokenManager: oauthTokenManager as never,
|
||||
});
|
||||
|
||||
await manager.startSession(
|
||||
normalizeStartInput({
|
||||
config: createConfig({
|
||||
sessionId,
|
||||
providerId: "cline-pass",
|
||||
modelId: "cline-pass/glm-5.1",
|
||||
apiKey: undefined,
|
||||
}),
|
||||
prompt: "hello",
|
||||
}),
|
||||
);
|
||||
|
||||
expect(oauthTokenManager.resolveProviderApiKey).toHaveBeenCalledWith({
|
||||
providerId: "cline-pass",
|
||||
});
|
||||
expect(createAgent).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
providerId: "cline-pass",
|
||||
apiKey: "workos:resolved-token",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("captures active session lookup misses as handled telemetry", async () => {
|
||||
const adapter = {
|
||||
name: "test",
|
||||
@@ -4012,7 +4079,6 @@ describe("LocalRuntimeHost", () => {
|
||||
runtimeBuilder,
|
||||
oauthTokenManager: {
|
||||
resolveProviderApiKey: vi.fn().mockResolvedValue({
|
||||
providerId: "openai-codex",
|
||||
apiKey: "oauth-access-new",
|
||||
refreshed: true,
|
||||
}),
|
||||
@@ -4409,7 +4475,6 @@ describe("LocalRuntimeHost", () => {
|
||||
.fn()
|
||||
.mockResolvedValueOnce(null)
|
||||
.mockResolvedValueOnce({
|
||||
providerId: "openai-codex",
|
||||
apiKey: "oauth-access-new",
|
||||
refreshed: true,
|
||||
});
|
||||
|
||||
@@ -270,6 +270,29 @@ export class LocalRuntimeHost implements RuntimeHost {
|
||||
});
|
||||
}
|
||||
|
||||
private async applyInitialOAuthCredentials(
|
||||
input: StartSessionInput,
|
||||
): Promise<StartSessionInput> {
|
||||
if (input.config.apiKey?.trim()) {
|
||||
return input;
|
||||
}
|
||||
|
||||
const resolved = await this.oauthTokenManager.resolveProviderApiKey({
|
||||
providerId: input.config.providerId,
|
||||
});
|
||||
if (!resolved?.apiKey) {
|
||||
return input;
|
||||
}
|
||||
|
||||
return {
|
||||
...input,
|
||||
config: {
|
||||
...input.config,
|
||||
apiKey: resolved.apiKey,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ── Public API ──────────────────────────────────────────────────────
|
||||
|
||||
async startSession(input: StartSessionInput): Promise<StartSessionResult> {
|
||||
@@ -277,7 +300,8 @@ export class LocalRuntimeHost implements RuntimeHost {
|
||||
const startedAt = nowIso();
|
||||
const requestedSessionId = input.config.sessionId?.trim() ?? "";
|
||||
const sessionId = requestedSessionId || createSessionId();
|
||||
const startInput: StartSessionInput = input;
|
||||
const startInput: StartSessionInput =
|
||||
await this.applyInitialOAuthCredentials(input);
|
||||
const initialMessages = startInput.initialMessages ?? [];
|
||||
const initialUsage =
|
||||
initialMessages.length > 0
|
||||
|
||||
@@ -62,7 +62,6 @@ describe("RuntimeOAuthTokenManager", () => {
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
providerId: "openai-codex",
|
||||
apiKey: "access-new",
|
||||
accountId: "acct-new",
|
||||
refreshed: true,
|
||||
@@ -80,6 +79,65 @@ describe("RuntimeOAuthTokenManager", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("resolves ClinePass OAuth using Cline storage and WorkOS formatting", async () => {
|
||||
const getProviderSettings = vi.fn().mockReturnValue({
|
||||
provider: "cline",
|
||||
baseUrl: "https://api.cline.test",
|
||||
auth: {
|
||||
accessToken: "workos:access-old",
|
||||
refreshToken: "refresh-old",
|
||||
expiresAt: Date.now() - 1_000,
|
||||
accountId: "acct-old",
|
||||
},
|
||||
});
|
||||
const saveProviderSettings = vi.fn();
|
||||
|
||||
getValidClineCredentials.mockResolvedValueOnce({
|
||||
access: "access-new",
|
||||
refresh: "refresh-new",
|
||||
expires: 4_000_000_000_000,
|
||||
accountId: "acct-new",
|
||||
});
|
||||
|
||||
const manager = new RuntimeOAuthTokenManager({
|
||||
providerSettingsManager: {
|
||||
getProviderSettings,
|
||||
saveProviderSettings,
|
||||
} as never,
|
||||
});
|
||||
|
||||
const result = await manager.resolveProviderApiKey({
|
||||
providerId: "cline-pass",
|
||||
});
|
||||
|
||||
expect(getProviderSettings).toHaveBeenCalledWith("cline");
|
||||
expect(getValidClineCredentials).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
access: "access-old",
|
||||
refresh: "refresh-old",
|
||||
}),
|
||||
expect.objectContaining({ apiBaseUrl: "https://api.cline.test" }),
|
||||
{ forceRefresh: false },
|
||||
);
|
||||
expect(result).toMatchObject({
|
||||
apiKey: "workos:access-new",
|
||||
accountId: "acct-new",
|
||||
refreshed: true,
|
||||
});
|
||||
expect(saveProviderSettings).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
provider: "cline",
|
||||
auth: expect.objectContaining({
|
||||
accessToken: "workos:access-new",
|
||||
refreshToken: "refresh-new",
|
||||
accountId: "acct-new",
|
||||
expiresAt: 4_000_000_000_000,
|
||||
}),
|
||||
}),
|
||||
{ setLastUsed: false, tokenSource: "oauth" },
|
||||
);
|
||||
});
|
||||
|
||||
it("throws re-auth required when refresh returns null", async () => {
|
||||
getValidOpenAICodexCredentials.mockResolvedValueOnce(null);
|
||||
const manager = new RuntimeOAuthTokenManager({
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user