mirror of
https://github.com/cline/cline.git
synced 2026-09-17 06:47:31 +08:00
Compare commits
18
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4f770011e9 | ||
|
|
a1d69fee6f | ||
|
|
3575b38122 | ||
|
|
b5468e1227 | ||
|
|
10d1c41b7a | ||
|
|
b823358867 | ||
|
|
b876945c6d | ||
|
|
453cdea040 | ||
|
|
091eccdfe2 | ||
|
|
a2a46ae600 | ||
|
|
82c9e77de2 | ||
|
|
dfd0e022a4 | ||
|
|
f0ec6a35bb | ||
|
|
c09d54f5a2 | ||
|
|
984d70a351 | ||
|
|
bbe7b6fd49 | ||
|
|
be97d951fa | ||
|
|
c331a8f4b6 |
@@ -1,5 +1,21 @@
|
||||
# Cline CLI Changelog
|
||||
|
||||
## 3.0.36
|
||||
|
||||
- Fixed plan mode's `switch_to_act_mode` tool not taking effect until the end of the turn: the model would keep running with plan-mode tools (no file editor) and fall back to editing files through shell commands. Switching to act mode now ends the plan-mode run and automatically continues with the approved plan using the full act-mode toolset. A Tab mode toggle racing a completing turn can no longer auto-start plan execution you didn't approve.
|
||||
|
||||
## 3.0.35
|
||||
|
||||
- ClinePass is now enabled for all CLI users
|
||||
- Recover missing interactive sessions when reading messages
|
||||
- Format structured commands in history export
|
||||
- Add the subscription promo code when linking to the dashboard subscription page
|
||||
- Add Tencent TokenHub as a provider (from SDK v0.0.55)
|
||||
- Fix first-prompt truncation on high-output models (e.g. MiniMax M3) that could immediately auto-compact and cut the initial task down to just the input wrapper (from SDK v0.0.55)
|
||||
- Use a curated default when migrating legacy provider settings (from SDK v0.0.55)
|
||||
- Advertise run commands as shell strings (from SDK v0.0.55)
|
||||
- Refresh the bundled model catalog with the latest provider models (from SDK v0.0.55)
|
||||
|
||||
## 3.0.34
|
||||
|
||||
- Fixed the ClinePass upgrade notice appearing immediately after completing onboarding.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@cline/cli",
|
||||
"displayName": "cline",
|
||||
"version": "3.0.34",
|
||||
"version": "3.0.36",
|
||||
"description": "Autonomous coding agent CLI - capable of creating/editing files, running commands, using the browser, and more",
|
||||
"type": "module",
|
||||
"publishConfig": {
|
||||
|
||||
@@ -313,6 +313,45 @@ describe("runHistoryExport", () => {
|
||||
await expect(readFile(outputPath, "utf8")).resolves.toContain("world");
|
||||
});
|
||||
|
||||
it("exports run_commands history with structured command objects", async () => {
|
||||
tempDir = await mkdtemp(join(tmpdir(), "cline-history-export-"));
|
||||
const outputPath = join(tempDir, "export.html");
|
||||
const artifact = {
|
||||
version: 1,
|
||||
updated_at: "2026-04-22T17:42:10.123Z",
|
||||
sessionId: "sess_1",
|
||||
messages: [
|
||||
{
|
||||
id: "m1",
|
||||
role: "assistant",
|
||||
content: [
|
||||
{
|
||||
type: "tool_use",
|
||||
id: "tool_1",
|
||||
name: "run_commands",
|
||||
input: {
|
||||
commands: [{ command: "cmd", args: ["/c", "dir"] }],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
} satisfies NonNullable<
|
||||
Awaited<ReturnType<typeof readSessionMessagesArtifact>>
|
||||
>;
|
||||
mockedReadSessionMessagesArtifact.mockResolvedValue(artifact);
|
||||
const io = {
|
||||
writeln: vi.fn(),
|
||||
writeErr: vi.fn(),
|
||||
};
|
||||
|
||||
const code = await runHistoryExport("sess_1", outputPath, "text", io);
|
||||
|
||||
expect(code).toBe(0);
|
||||
expect(io.writeErr).not.toHaveBeenCalled();
|
||||
await expect(readFile(outputPath, "utf8")).resolves.toContain("cmd /c dir");
|
||||
});
|
||||
|
||||
it("fails when the session artifact is missing", async () => {
|
||||
mockedReadSessionMessagesArtifact.mockResolvedValue(undefined);
|
||||
const io = {
|
||||
|
||||
@@ -2,7 +2,15 @@ import { createTool } from "@cline/shared";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { Config } from "../../utils/types";
|
||||
import { resolveSystemPrompt } from "../prompt";
|
||||
import { applyInteractiveModeConfig } from "./mode";
|
||||
import {
|
||||
ACT_MODE_CONTINUATION_PROMPT,
|
||||
type AppliedModeChange,
|
||||
applyInteractiveModeConfig,
|
||||
createInteractiveModeSwitchTool,
|
||||
createModeSwitchNoticeTracker,
|
||||
type PendingModeChange,
|
||||
sendTurnWithActModeContinuation,
|
||||
} from "./mode";
|
||||
|
||||
vi.mock("../prompt", () => ({
|
||||
resolveSystemPrompt: vi.fn(async (input: { mode?: string }) => {
|
||||
@@ -40,6 +48,190 @@ const switchToActModeTool = createTool({
|
||||
execute: async () => "ok",
|
||||
});
|
||||
|
||||
describe("createInteractiveModeSwitchTool", () => {
|
||||
function makeSwitchTool(config: Config) {
|
||||
const pendingModeChange: PendingModeChange = {
|
||||
current: null,
|
||||
source: null,
|
||||
};
|
||||
const tuiModeChanged: {
|
||||
current: ((mode: "plan" | "act") => void) | null;
|
||||
} = { current: vi.fn() };
|
||||
const tool = createInteractiveModeSwitchTool({
|
||||
config,
|
||||
pendingModeChange,
|
||||
tuiModeChanged,
|
||||
});
|
||||
return { tool, pendingModeChange, tuiModeChanged };
|
||||
}
|
||||
|
||||
const toolContext = {
|
||||
agentId: "agent-1",
|
||||
iteration: 0,
|
||||
} as const;
|
||||
|
||||
it("completes the run so the model never continues with plan-mode tools", () => {
|
||||
const config = makeConfig();
|
||||
config.mode = "plan";
|
||||
const { tool } = makeSwitchTool(config);
|
||||
|
||||
// The act-mode tool set only exists after the session rebuild, which
|
||||
// happens between runs; without completesRun the model keeps working
|
||||
// with stale plan-mode tools after being told the switch succeeded.
|
||||
expect(tool.lifecycle?.completesRun).toBe(true);
|
||||
});
|
||||
|
||||
it("queues a tool-sourced mode change and notifies the TUI", async () => {
|
||||
const config = makeConfig();
|
||||
config.mode = "plan";
|
||||
const { tool, pendingModeChange, tuiModeChanged } = makeSwitchTool(config);
|
||||
|
||||
const result = await tool.execute({}, toolContext);
|
||||
|
||||
expect(pendingModeChange).toEqual({ current: "act", source: "tool" });
|
||||
expect(tuiModeChanged.current).toHaveBeenCalledWith("act");
|
||||
expect(result).toContain("successfully switched to act mode");
|
||||
});
|
||||
|
||||
it("errors instead of completing the run when already in act mode", async () => {
|
||||
const config = makeConfig();
|
||||
config.mode = "act";
|
||||
const { tool, pendingModeChange } = makeSwitchTool(config);
|
||||
|
||||
// A successful result would end the run via completesRun even though
|
||||
// nothing changed, so the no-op case must surface as a tool error.
|
||||
await expect(tool.execute({}, toolContext)).rejects.toThrow(
|
||||
"Already in act mode.",
|
||||
);
|
||||
expect(pendingModeChange.current).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("sendTurnWithActModeContinuation", () => {
|
||||
type TurnResult = { finishReason: string; iterations: number };
|
||||
|
||||
function makeHarness(input: {
|
||||
initial: TurnResult | undefined;
|
||||
continuation?: TurnResult | undefined;
|
||||
modeChanges: Array<AppliedModeChange | undefined>;
|
||||
}) {
|
||||
const applied = [...input.modeChanges];
|
||||
const sendContinuationTurn = vi.fn(async () => input.continuation);
|
||||
return {
|
||||
sendContinuationTurn,
|
||||
run: () =>
|
||||
sendTurnWithActModeContinuation<TurnResult>({
|
||||
sendInitialTurn: async () => input.initial,
|
||||
sendContinuationTurn,
|
||||
applyPendingModeChange: async () => applied.shift(),
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
it("continues the plan after a tool-initiated switch completes the run", async () => {
|
||||
const { run, sendContinuationTurn } = makeHarness({
|
||||
initial: { finishReason: "completed", iterations: 2 },
|
||||
continuation: { finishReason: "completed", iterations: 3 },
|
||||
modeChanges: [{ mode: "act", source: "tool" }, undefined],
|
||||
});
|
||||
|
||||
const result = await run();
|
||||
|
||||
expect(sendContinuationTurn).toHaveBeenCalledWith(
|
||||
ACT_MODE_CONTINUATION_PROMPT,
|
||||
);
|
||||
expect(result).toEqual({ finishReason: "completed", iterations: 5 });
|
||||
});
|
||||
|
||||
it("does not continue after a UI-initiated mode change", async () => {
|
||||
// A Tab toggle can race a natural turn completion; a "ui" source must
|
||||
// never start executing a plan the user did not approve.
|
||||
const { run, sendContinuationTurn } = makeHarness({
|
||||
initial: { finishReason: "completed", iterations: 2 },
|
||||
modeChanges: [{ mode: "act", source: "ui" }],
|
||||
});
|
||||
|
||||
const result = await run();
|
||||
|
||||
expect(sendContinuationTurn).not.toHaveBeenCalled();
|
||||
expect(result).toEqual({ finishReason: "completed", iterations: 2 });
|
||||
});
|
||||
|
||||
it("does not continue when the switch turn was aborted", async () => {
|
||||
const { run, sendContinuationTurn } = makeHarness({
|
||||
initial: { finishReason: "aborted", iterations: 1 },
|
||||
modeChanges: [{ mode: "act", source: "tool" }],
|
||||
});
|
||||
|
||||
const result = await run();
|
||||
|
||||
expect(sendContinuationTurn).not.toHaveBeenCalled();
|
||||
expect(result).toEqual({ finishReason: "aborted", iterations: 1 });
|
||||
});
|
||||
|
||||
it("does not continue when no mode change was pending", async () => {
|
||||
const { run, sendContinuationTurn } = makeHarness({
|
||||
initial: { finishReason: "completed", iterations: 2 },
|
||||
modeChanges: [undefined],
|
||||
});
|
||||
|
||||
const result = await run();
|
||||
|
||||
expect(sendContinuationTurn).not.toHaveBeenCalled();
|
||||
expect(result).toEqual({ finishReason: "completed", iterations: 2 });
|
||||
});
|
||||
|
||||
it("returns the switch turn result when the continuation yields nothing", async () => {
|
||||
const { run } = makeHarness({
|
||||
initial: { finishReason: "completed", iterations: 2 },
|
||||
continuation: undefined,
|
||||
modeChanges: [{ mode: "act", source: "tool" }, undefined],
|
||||
});
|
||||
|
||||
const result = await run();
|
||||
|
||||
expect(result).toEqual({ finishReason: "completed", iterations: 2 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("createModeSwitchNoticeTracker", () => {
|
||||
it("records a switch and clears it on consume", () => {
|
||||
const tracker = createModeSwitchNoticeTracker();
|
||||
|
||||
tracker.record("act", "plan");
|
||||
|
||||
expect(tracker.consume()).toEqual({ from: "act", to: "plan" });
|
||||
expect(tracker.consume()).toBeNull();
|
||||
});
|
||||
|
||||
it("cancels a round trip that returns to the mode the model last saw", () => {
|
||||
const tracker = createModeSwitchNoticeTracker();
|
||||
|
||||
tracker.record("act", "plan");
|
||||
tracker.record("plan", "act");
|
||||
|
||||
expect(tracker.consume()).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps the original starting mode across chained switches", () => {
|
||||
const tracker = createModeSwitchNoticeTracker();
|
||||
|
||||
tracker.record("act", "plan");
|
||||
tracker.record("plan", "act");
|
||||
tracker.record("act", "plan");
|
||||
|
||||
expect(tracker.consume()).toEqual({ from: "act", to: "plan" });
|
||||
});
|
||||
|
||||
it("ignores a no-op switch", () => {
|
||||
const tracker = createModeSwitchNoticeTracker();
|
||||
|
||||
tracker.record("plan", "plan");
|
||||
|
||||
expect(tracker.consume()).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyInteractiveModeConfig", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(resolveSystemPrompt).mockClear();
|
||||
|
||||
@@ -2,17 +2,42 @@ import { createTool } from "@cline/shared";
|
||||
import type { Config } from "../../utils/types";
|
||||
import { resolveSystemPrompt } from "../prompt";
|
||||
|
||||
type InteractiveUiMode = "plan" | "act";
|
||||
export type InteractiveUiMode = "plan" | "act";
|
||||
|
||||
/**
|
||||
* Pending mode change plus who requested it. The switch_to_act_mode tool and
|
||||
* the TUI mode toggle share this slot, but only a tool-initiated switch means
|
||||
* "the user approved the plan" -- a UI toggle that lands as a turn finishes
|
||||
* must not trigger plan execution.
|
||||
*/
|
||||
export type PendingModeChange = {
|
||||
current: InteractiveUiMode | null;
|
||||
source: "tool" | "ui" | null;
|
||||
};
|
||||
|
||||
export type AppliedModeChange = {
|
||||
mode: InteractiveUiMode;
|
||||
source: "tool" | "ui";
|
||||
};
|
||||
|
||||
/**
|
||||
* Canned prompt that drives the auto-continue turn after the model calls
|
||||
* switch_to_act_mode. It is a synthetic user message, so transcript hydration
|
||||
* filters it out of the chat display.
|
||||
*/
|
||||
export const ACT_MODE_CONTINUATION_PROMPT =
|
||||
"The user approved switching to act mode. Continue with the approved plan now.";
|
||||
|
||||
export function createInteractiveModeSwitchTool(input: {
|
||||
config: Config;
|
||||
pendingModeChange: { current: InteractiveUiMode | null };
|
||||
pendingModeChange: PendingModeChange;
|
||||
tuiModeChanged: { current: ((mode: InteractiveUiMode) => void) | null };
|
||||
}) {
|
||||
return createTool({
|
||||
name: "switch_to_act_mode",
|
||||
description:
|
||||
"Switch from plan mode to act mode. Call this after the user has confirmed they want to proceed with the plan. Do not call this proactively or before the user has agreed.",
|
||||
"Switch from plan mode to act mode. Switching to act mode immediately starts executing the plan, so only call this after the user has explicitly approved the plan in a message sent AFTER you presented it (e.g. 'looks good', 'go ahead', 'switch to act mode'). " +
|
||||
"Never call this in the same turn you present a plan, never call it proactively, and never treat the original task request as approval.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {},
|
||||
@@ -20,17 +45,101 @@ export function createInteractiveModeSwitchTool(input: {
|
||||
timeoutMs: 5000,
|
||||
retryable: false,
|
||||
maxRetries: 0,
|
||||
// The act-mode tools only exist after the session is rebuilt with the
|
||||
// new mode config, which can't happen mid-run. End the run right after
|
||||
// the tool result so the model never keeps working with plan-mode tools
|
||||
// it was just told it no longer has; run-interactive applies the pending
|
||||
// change and auto-continues on the rebuilt session.
|
||||
lifecycle: {
|
||||
completesRun: true,
|
||||
},
|
||||
execute: async () => {
|
||||
if (input.config.mode === "act") {
|
||||
return "Already in act mode.";
|
||||
// Throw instead of returning: a successful result would end the
|
||||
// run via completesRun even though nothing changed.
|
||||
throw new Error("Already in act mode.");
|
||||
}
|
||||
input.pendingModeChange.current = "act";
|
||||
input.pendingModeChange.source = "tool";
|
||||
input.tuiModeChanged.current?.("act");
|
||||
return "You successfully switched to act mode, proceed with the plan. You now have access to editing files and running commands. (The switch_to_act_mode tool is only available in plan mode.)";
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs one interactive turn, and when the model ended it by calling
|
||||
* switch_to_act_mode, continues the approved plan on the rebuilt act-mode
|
||||
* session instead of waiting for the user to prompt again.
|
||||
*
|
||||
* The continuation only fires for a tool-initiated switch on a turn that
|
||||
* finished "completed": a UI toggle mid-run aborts the turn, and even if the
|
||||
* toggle races a natural completion its source is "ui", so the user's Tab
|
||||
* press can never start executing a plan they did not approve.
|
||||
*/
|
||||
export async function sendTurnWithActModeContinuation<
|
||||
T extends { finishReason: string; iterations: number },
|
||||
>(input: {
|
||||
sendInitialTurn: () => Promise<T | undefined>;
|
||||
sendContinuationTurn: (prompt: string) => Promise<T | undefined>;
|
||||
applyPendingModeChange: () => Promise<AppliedModeChange | undefined>;
|
||||
}): Promise<T | undefined> {
|
||||
const result = await input.sendInitialTurn();
|
||||
const switched = await input.applyPendingModeChange();
|
||||
if (
|
||||
switched?.mode !== "act" ||
|
||||
switched.source !== "tool" ||
|
||||
result?.finishReason !== "completed"
|
||||
) {
|
||||
return result;
|
||||
}
|
||||
const continuation = await input.sendContinuationTurn(
|
||||
ACT_MODE_CONTINUATION_PROMPT,
|
||||
);
|
||||
// Honor a mode toggle made while the continuation was running.
|
||||
await input.applyPendingModeChange();
|
||||
if (!continuation) {
|
||||
return result;
|
||||
}
|
||||
return {
|
||||
...continuation,
|
||||
iterations: result.iterations + continuation.iterations,
|
||||
};
|
||||
}
|
||||
|
||||
export type ModeSwitchNotice = {
|
||||
from: InteractiveUiMode;
|
||||
to: InteractiveUiMode;
|
||||
};
|
||||
|
||||
/**
|
||||
* Tracks a user-initiated mode switch so the next user message can carry a
|
||||
* <mode_notice> marking it. Only UI toggles are recorded: the model-initiated
|
||||
* switch_to_act_mode path already announces itself via the continuation
|
||||
* prompt. A round trip (plan -> act -> plan before sending anything) cancels
|
||||
* out, since the mode the model last saw never effectively changed.
|
||||
*/
|
||||
export function createModeSwitchNoticeTracker() {
|
||||
let pending: ModeSwitchNotice | null = null;
|
||||
return {
|
||||
record(from: InteractiveUiMode, to: InteractiveUiMode): void {
|
||||
if (from === to) {
|
||||
return;
|
||||
}
|
||||
if (pending) {
|
||||
pending = pending.from === to ? null : { from: pending.from, to };
|
||||
return;
|
||||
}
|
||||
pending = { from, to };
|
||||
},
|
||||
consume(): ModeSwitchNotice | null {
|
||||
const notice = pending;
|
||||
pending = null;
|
||||
return notice;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function applyInteractiveModeConfig(input: {
|
||||
config: Config;
|
||||
mode: InteractiveUiMode;
|
||||
|
||||
@@ -210,6 +210,40 @@ describe("createInteractiveSessionRuntime", () => {
|
||||
expect(runtime.getActiveSessionId()).toBe("session-2");
|
||||
});
|
||||
|
||||
it("holds concurrent ensureReady during a restart instead of booting an empty session", async () => {
|
||||
const manager = makeManager();
|
||||
const runtime = makeRuntime(manager);
|
||||
|
||||
await runtime.ensureReady();
|
||||
expect(runtime.getActiveSessionId()).toBe("session-1");
|
||||
|
||||
// Keep the replacement session's start in flight so the restart window
|
||||
// (old session stopped, no active session yet) stays open.
|
||||
const gate = deferred<void>();
|
||||
manager.start.mockImplementationOnce(async () => {
|
||||
await gate.promise;
|
||||
return {
|
||||
sessionId: "session-restarted",
|
||||
manifest: { session_id: "session-restarted" },
|
||||
};
|
||||
});
|
||||
|
||||
const restart = runtime.restartWithCurrentMessages();
|
||||
await vi.waitFor(() => {
|
||||
expect(manager.start).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
// A message submitted mid-restart (e.g. right after a plan/act toggle)
|
||||
// calls ensureReady; it must wait for the restart instead of booting a
|
||||
// blank session that races the replacement for the active slot.
|
||||
const ready = runtime.ensureReady();
|
||||
gate.resolve();
|
||||
await Promise.all([restart, ready]);
|
||||
|
||||
expect(manager.start).toHaveBeenCalledTimes(2);
|
||||
expect(runtime.getActiveSessionId()).toBe("session-restarted");
|
||||
});
|
||||
|
||||
it("adds a live interactive approval policy hook to started sessions", async () => {
|
||||
const manager = makeManager();
|
||||
const upstreamBeforeTool = vi.fn(async () => ({
|
||||
|
||||
@@ -383,11 +383,31 @@ export function createInteractiveSessionRuntime(input: {
|
||||
): Promise<void> => {
|
||||
sessionStartGeneration += 1;
|
||||
pendingResumeSessionId = undefined;
|
||||
startupPromise = undefined;
|
||||
startupError = undefined;
|
||||
await stopCurrentSession();
|
||||
clearActiveSession();
|
||||
await startFreshSession(messages, sessionMetadata);
|
||||
// Publish the restart as the in-flight startup. Teardown leaves a window
|
||||
// with no active session, and without this barrier a concurrent
|
||||
// ensureReady() (e.g. a message submitted right after a plan/act toggle)
|
||||
// reads that window as "no session" and boots an empty session that then
|
||||
// races the restarted one for the active slot.
|
||||
const restart = (async () => {
|
||||
await stopCurrentSession();
|
||||
clearActiveSession();
|
||||
await startFreshSession(messages, sessionMetadata);
|
||||
})().catch((error) => {
|
||||
startupError = error;
|
||||
throw error;
|
||||
});
|
||||
startupPromise = restart;
|
||||
try {
|
||||
await restart;
|
||||
} finally {
|
||||
// Restore the pre-restart steady state (startupPromise unset) so a
|
||||
// failed restart stays retryable by the next ensureReady(). A newer
|
||||
// startup that already replaced the barrier is left alone.
|
||||
if (startupPromise === restart) {
|
||||
startupPromise = undefined;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const restartWithCurrentMessages = async (): Promise<void> => {
|
||||
|
||||
@@ -9,6 +9,10 @@ import {
|
||||
import { type AgentMode, buildClineSystemPrompt } from "@cline/shared";
|
||||
import { isImagePath, loadImageAsDataUrl } from "../utils/image-attachments";
|
||||
|
||||
const MODE_TAG_INSTRUCTIONS = `# Plan / Act Modes
|
||||
|
||||
User messages arrive wrapped in a <user_input mode="..."> tag. The mode attribute is the interaction mode the user was in when they sent that message: "plan" means plan-mode constraints applied (explore, analyze, and align on a plan -- no edits or state-changing commands), while "act" (or "yolo") means implementation was allowed. If the mode attribute changes between messages, the user switched modes -- the newest message's mode is what governs right now, regardless of what earlier messages allowed. A <mode_notice> block inside a message marks exactly when such a switch happened.`;
|
||||
|
||||
const PLAN_MODE_INSTRUCTIONS = `# Plan Mode
|
||||
|
||||
You are in Plan mode. Your role is to explore, analyze, and plan -- not to execute.
|
||||
@@ -20,7 +24,7 @@ You are in Plan mode. Your role is to explore, analyze, and plan -- not to execu
|
||||
- Do NOT edit files, write code, run destructive commands, or make any changes
|
||||
- Do NOT implement anything -- focus on understanding and alignment first
|
||||
|
||||
When the user aligns on a plan and is ready to proceed, use the switch_to_act_mode tool to switch to act mode and begin implementation.`;
|
||||
Once the user has reviewed your plan and explicitly approved it in a follow-up message, use the switch_to_act_mode tool to switch to act mode and begin implementation. Calling switch_to_act_mode immediately starts execution, so never call it in the same turn you present a plan and never treat the original task request as approval -- end your turn after presenting the plan and wait for the user's response.`;
|
||||
|
||||
export async function resolveSystemPrompt(input: {
|
||||
cwd: string;
|
||||
@@ -31,10 +35,13 @@ export async function resolveSystemPrompt(input: {
|
||||
}): Promise<string> {
|
||||
const metadata = await buildWorkspaceMetadata(input.cwd);
|
||||
let rules = mergeRulesForSystemPrompt(undefined, input.rules);
|
||||
// Both modes get the mode-tag explanation: after a switch, the transcript
|
||||
// still contains messages tagged with the other mode.
|
||||
rules = rules
|
||||
? `${rules}\n\n${MODE_TAG_INSTRUCTIONS}`
|
||||
: MODE_TAG_INSTRUCTIONS;
|
||||
if (input.mode === "plan") {
|
||||
rules = rules
|
||||
? `${rules}\n\n${PLAN_MODE_INSTRUCTIONS}`
|
||||
: PLAN_MODE_INSTRUCTIONS;
|
||||
rules = `${rules}\n\n${PLAN_MODE_INSTRUCTIONS}`;
|
||||
}
|
||||
return buildClineSystemPrompt({
|
||||
ide: "Terminal Shell",
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
ProviderSettingsManager,
|
||||
type UserInstructionConfigService,
|
||||
} from "@cline/core";
|
||||
import { formatModeSwitchNotice } from "@cline/shared";
|
||||
import type { CliMigrationNotice } from "../kanban-migration/notice";
|
||||
import { logCliError } from "../logging/errors";
|
||||
import {
|
||||
@@ -52,7 +53,13 @@ import {
|
||||
type InteractiveExitSummary,
|
||||
} from "./interactive/exit-summary";
|
||||
import { createMistakeLimitDecisionResolver } from "./interactive/mistakes";
|
||||
import { createInteractiveModeSwitchTool } from "./interactive/mode";
|
||||
import {
|
||||
type AppliedModeChange,
|
||||
createInteractiveModeSwitchTool,
|
||||
createModeSwitchNoticeTracker,
|
||||
type PendingModeChange,
|
||||
sendTurnWithActModeContinuation,
|
||||
} from "./interactive/mode";
|
||||
import { assertInteractivePreflight } from "./interactive/preflight";
|
||||
import { createInteractiveSessionRuntime } from "./interactive/session-runtime";
|
||||
import { buildUserInputMessage } from "./prompt";
|
||||
@@ -149,8 +156,9 @@ export async function runInteractive(
|
||||
tuiAskQuestion,
|
||||
} = createInteractiveApprovalController(config);
|
||||
|
||||
const pendingModeChange: { current: "plan" | "act" | null } = {
|
||||
const pendingModeChange: PendingModeChange = {
|
||||
current: null,
|
||||
source: null,
|
||||
};
|
||||
const tuiModeChanged: {
|
||||
current: ((mode: "plan" | "act") => void) | null;
|
||||
@@ -204,6 +212,7 @@ export async function runInteractive(
|
||||
});
|
||||
let modeChangePromise: Promise<void> | undefined;
|
||||
let modeChangeTarget: "plan" | "act" | undefined;
|
||||
const modeSwitchNotice = createModeSwitchNoticeTracker();
|
||||
|
||||
const isInteractiveMode = (mode: unknown): mode is "plan" | "act" =>
|
||||
mode === "plan" || mode === "act";
|
||||
@@ -218,7 +227,11 @@ export async function runInteractive(
|
||||
await modeChangePromise;
|
||||
}
|
||||
await sessionRuntime.ensureReady();
|
||||
const from = config.mode;
|
||||
await sessionRuntime.applyMode(mode);
|
||||
if (isInteractiveMode(from)) {
|
||||
modeSwitchNotice.record(from, mode);
|
||||
}
|
||||
})().finally(() => {
|
||||
if (modeChangePromise === next) {
|
||||
modeChangePromise = undefined;
|
||||
@@ -520,27 +533,50 @@ export async function runInteractive(
|
||||
...(attachments?.userImages ?? []),
|
||||
...userImages,
|
||||
];
|
||||
// Mark a preceding user-initiated mode switch on this message so
|
||||
// the model sees exactly when the rules changed, instead of only
|
||||
// inferring it from the user_input mode attribute flipping.
|
||||
const switchNotice = modeSwitchNotice.consume();
|
||||
const noticedUserInput = switchNotice
|
||||
? `${formatModeSwitchNotice(switchNotice.from, switchNotice.to)}\n${userInput}`
|
||||
: userInput;
|
||||
|
||||
const applyPendingModeChange = async () => {
|
||||
const applyPendingModeChange = async (): Promise<
|
||||
AppliedModeChange | undefined
|
||||
> => {
|
||||
if (!pendingModeChange.current) return undefined;
|
||||
const newMode = pendingModeChange.current;
|
||||
const applied: AppliedModeChange = {
|
||||
mode: pendingModeChange.current,
|
||||
source: pendingModeChange.source ?? "ui",
|
||||
};
|
||||
pendingModeChange.current = null;
|
||||
await sessionRuntime.applyMode(newMode);
|
||||
tuiModeChanged.current?.(newMode);
|
||||
return newMode;
|
||||
pendingModeChange.source = null;
|
||||
const from = config.mode;
|
||||
await sessionRuntime.applyMode(applied.mode);
|
||||
tuiModeChanged.current?.(applied.mode);
|
||||
// The switch_to_act_mode path announces itself through the
|
||||
// continuation prompt; only UI toggles need a notice.
|
||||
if (applied.source === "ui" && isInteractiveMode(from)) {
|
||||
modeSwitchNotice.record(from, applied.mode);
|
||||
}
|
||||
return applied;
|
||||
};
|
||||
|
||||
const result = await sessionRuntime.sendCurrentTurn({
|
||||
prompt: userInput,
|
||||
mode,
|
||||
userImages:
|
||||
mergedUserImages.length > 0 ? mergedUserImages : undefined,
|
||||
userFiles: userFiles.length > 0 ? userFiles : undefined,
|
||||
delivery,
|
||||
const result = await sendTurnWithActModeContinuation({
|
||||
sendInitialTurn: () =>
|
||||
sessionRuntime.sendCurrentTurn({
|
||||
prompt: noticedUserInput,
|
||||
mode,
|
||||
userImages:
|
||||
mergedUserImages.length > 0 ? mergedUserImages : undefined,
|
||||
userFiles: userFiles.length > 0 ? userFiles : undefined,
|
||||
delivery,
|
||||
}),
|
||||
sendContinuationTurn: (prompt) =>
|
||||
sessionRuntime.sendCurrentTurn({ prompt, mode: "act" }),
|
||||
applyPendingModeChange,
|
||||
});
|
||||
|
||||
await applyPendingModeChange();
|
||||
|
||||
if (!result) {
|
||||
return {
|
||||
usage: { inputTokens: 0, outputTokens: 0 },
|
||||
@@ -642,6 +678,7 @@ export async function runInteractive(
|
||||
if (!isInteractiveMode(mode)) return;
|
||||
if (isRunning) {
|
||||
pendingModeChange.current = mode;
|
||||
pendingModeChange.source = "ui";
|
||||
sessionRuntime.abortAll();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
type ToolResultContent,
|
||||
type ToolUseContent,
|
||||
} from "@cline/shared";
|
||||
import { formatStructuredCommand } from "../utils/helpers";
|
||||
|
||||
export interface ConversationHistory {
|
||||
version: number;
|
||||
@@ -845,15 +846,15 @@ function renderDiffHTML(
|
||||
}
|
||||
|
||||
function renderCommandsHTML(
|
||||
commands: string[],
|
||||
commands: unknown[],
|
||||
_result?: ToolResultContent,
|
||||
): string {
|
||||
return commands
|
||||
.map(
|
||||
(cmd, i) => `
|
||||
(command, i) => `
|
||||
<div class="command-block">
|
||||
<div class="command-label">Command ${i + 1}</div>
|
||||
<code>${escapeHtml(cmd)}</code>
|
||||
<code>${escapeHtml(formatStructuredCommand(command))}</code>
|
||||
</div>
|
||||
`,
|
||||
)
|
||||
|
||||
@@ -128,7 +128,8 @@ export async function createClineAccountService(input: {
|
||||
clineProviderSettings?: ProviderSettings;
|
||||
providerSettingsManager?: ProviderSettingsManager;
|
||||
}): Promise<ClineAccountService | undefined> {
|
||||
const manager = input.providerSettingsManager ?? new ProviderSettingsManager();
|
||||
const manager =
|
||||
input.providerSettingsManager ?? new ProviderSettingsManager();
|
||||
const settings =
|
||||
manager.getProviderSettings("cline") ?? input.clineProviderSettings;
|
||||
const apiBaseUrl = resolveAccountApiBaseUrl({
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { CLI_PROMO_CODE } from "../../../utils/cline-pass-errors";
|
||||
|
||||
const CLINE_PASS_SUBSCRIPTION_PATH = "/dashboard/subscription";
|
||||
const DEFAULT_APP_BASE_URL = "https://app.cline.bot";
|
||||
|
||||
@@ -9,5 +11,6 @@ export function buildClinePassSubscriptionPageUrl(
|
||||
appBaseUrl || DEFAULT_APP_BASE_URL,
|
||||
);
|
||||
url.searchParams.set("personal", "true");
|
||||
url.searchParams.set("code", CLI_PROMO_CODE);
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { buildClinePassSubscriptionPageUrl } from "./provider-picker-helpers";
|
||||
describe("buildClinePassSubscriptionPageUrl", () => {
|
||||
it("opens the personal subscription page on production by default", () => {
|
||||
expect(buildClinePassSubscriptionPageUrl(undefined)).toBe(
|
||||
"https://app.cline.bot/dashboard/subscription?personal=true",
|
||||
"https://app.cline.bot/dashboard/subscription?personal=true&code=CLI-8OFF",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -12,7 +12,7 @@ describe("buildClinePassSubscriptionPageUrl", () => {
|
||||
expect(
|
||||
buildClinePassSubscriptionPageUrl("https://staging-app.cline.bot"),
|
||||
).toBe(
|
||||
"https://staging-app.cline.bot/dashboard/subscription?personal=true",
|
||||
"https://staging-app.cline.bot/dashboard/subscription?personal=true&code=CLI-8OFF",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { AgentEvent, TeamEvent } from "@cline/core";
|
||||
import { formatDisplayUserInput } from "@cline/shared";
|
||||
import { useCallback, useRef } from "react";
|
||||
import type {
|
||||
PendingPromptSnapshot,
|
||||
@@ -296,7 +297,13 @@ export function useAgentEventHandlers(deps: AgentEventDeps) {
|
||||
const handlePendingPromptSubmitted = useCallback(
|
||||
(event: PendingPromptSubmittedEvent) => {
|
||||
knownPendingPromptIdsRef.current.delete(event.id);
|
||||
appendEntry({ kind: "user_submitted", text: event.prompt });
|
||||
// Display boundary: formatDisplayUserInput strips runtime-generated
|
||||
// notice elements (e.g. mode_notice) that normalizeUserInput must
|
||||
// preserve, since the latter also sanitizes model-bound prompts.
|
||||
appendEntry({
|
||||
kind: "user_submitted",
|
||||
text: formatDisplayUserInput(event.prompt),
|
||||
});
|
||||
},
|
||||
[appendEntry],
|
||||
);
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import type { Message } from "@cline/shared";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { ACT_MODE_CONTINUATION_PROMPT } from "../../runtime/interactive/mode";
|
||||
import { hydrateSessionMessages } from "./hydrate-messages";
|
||||
|
||||
describe("hydrateSessionMessages", () => {
|
||||
it("renders regular user messages", () => {
|
||||
const messages = [
|
||||
{
|
||||
role: "user",
|
||||
content: '<user_input mode="plan">lets do it</user_input>',
|
||||
},
|
||||
] as Message[];
|
||||
|
||||
expect(hydrateSessionMessages(messages)).toEqual([
|
||||
{ kind: "user_submitted", text: "lets do it" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("hides the synthetic act-mode continuation prompt", () => {
|
||||
const messages = [
|
||||
{
|
||||
role: "user",
|
||||
content: `<user_input mode="act">${ACT_MODE_CONTINUATION_PROMPT}</user_input>`,
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `<user_input mode="act">${ACT_MODE_CONTINUATION_PROMPT}</user_input>`,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
role: "assistant",
|
||||
content: "On it.",
|
||||
},
|
||||
] as Message[];
|
||||
|
||||
expect(hydrateSessionMessages(messages)).toEqual([
|
||||
{ kind: "assistant_text", text: "On it.", streaming: false },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
import { formatDisplayUserInput, type Message } from "@cline/shared";
|
||||
import { ACT_MODE_CONTINUATION_PROMPT } from "../../runtime/interactive/mode";
|
||||
import { formatToolInput } from "../../utils/helpers";
|
||||
import type { ChatEntry } from "../types";
|
||||
|
||||
@@ -11,6 +12,12 @@ function getDisplayRole(msg: PersistedMessage): string | undefined {
|
||||
return typeof role === "string" ? role.trim().toLowerCase() : undefined;
|
||||
}
|
||||
|
||||
// The act-mode continuation prompt is runtime-generated, not typed by the
|
||||
// user, so it should not surface as a user bubble in the transcript.
|
||||
function isSyntheticUserText(text: string): boolean {
|
||||
return text === ACT_MODE_CONTINUATION_PROMPT;
|
||||
}
|
||||
|
||||
function stringifyToolResult(
|
||||
content: string | Array<{ type: string; text?: string; path?: string }>,
|
||||
): string {
|
||||
@@ -40,7 +47,9 @@ export function hydrateSessionMessages(messages: Message[]): ChatEntry[] {
|
||||
if (typeof msg.content === "string") {
|
||||
if (msg.role === "user") {
|
||||
const text = formatDisplayUserInput(msg.content);
|
||||
if (text) entries.push({ kind: "user_submitted", text });
|
||||
if (text && !isSyntheticUserText(text)) {
|
||||
entries.push({ kind: "user_submitted", text });
|
||||
}
|
||||
} else {
|
||||
entries.push({
|
||||
kind: "assistant_text",
|
||||
@@ -115,7 +124,7 @@ export function hydrateSessionMessages(messages: Message[]): ChatEntry[] {
|
||||
if (msg.role === "user" && userTextParts.length > 0) {
|
||||
const combined = userTextParts.join("\n");
|
||||
const text = formatDisplayUserInput(combined);
|
||||
if (text) {
|
||||
if (text && !isSyntheticUserText(text)) {
|
||||
entries.push({ kind: "user_submitted", text });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,9 +11,11 @@ import { getClineEnvironmentConfig } from "@cline/shared";
|
||||
|
||||
export { getClineOrgIndividualInferenceSubscriptionMessage };
|
||||
|
||||
export const CLI_PROMO_CODE = "CLI-8OFF";
|
||||
|
||||
export function getCliSubscriptionUrl(): string {
|
||||
return `${new URL(
|
||||
"/promo?code=CLI-8OFF&personal=true",
|
||||
`/promo?code=${CLI_PROMO_CODE}&personal=true`,
|
||||
getClineEnvironmentConfig().appBaseUrl,
|
||||
).toString()}`;
|
||||
}
|
||||
|
||||
@@ -61,7 +61,7 @@ export function truncate(str: string, maxLen: number): string {
|
||||
return `${oneLine.slice(0, maxLen - 3)}...`;
|
||||
}
|
||||
|
||||
function formatStructuredCommand(cmd: unknown): string {
|
||||
export function formatStructuredCommand(cmd: unknown): string {
|
||||
if (typeof cmd === "string") {
|
||||
return cmd;
|
||||
}
|
||||
|
||||
@@ -77,12 +77,17 @@ function getOwnServerRecord(
|
||||
* than once to verify deterministic output. Throw McpSettingsUpdateSkippedError
|
||||
* for normal no-op cases instead of returning a boolean that callers can ignore.
|
||||
*/
|
||||
function mutateServers(mutate: (servers: Record<string, unknown>) => void): void {
|
||||
function mutateServers(
|
||||
mutate: (servers: Record<string, unknown>) => void,
|
||||
): void {
|
||||
updateMcpSettingsFileSync(getSettingsPath(), (settings) => {
|
||||
const serversValue = settings.mcpServers;
|
||||
const servers = serversValue && typeof serversValue === "object" && !Array.isArray(serversValue)
|
||||
? { ...(serversValue as Record<string, unknown>) }
|
||||
: {};
|
||||
const servers =
|
||||
serversValue &&
|
||||
typeof serversValue === "object" &&
|
||||
!Array.isArray(serversValue)
|
||||
? { ...(serversValue as Record<string, unknown>) }
|
||||
: {};
|
||||
mutate(servers);
|
||||
settings.mcpServers = servers;
|
||||
});
|
||||
@@ -98,7 +103,9 @@ export function removeServer(name: string): boolean {
|
||||
try {
|
||||
mutateServers((servers) => {
|
||||
if (!(name in servers)) {
|
||||
throw new McpSettingsUpdateSkippedError(`MCP server not found: ${name}`);
|
||||
throw new McpSettingsUpdateSkippedError(
|
||||
`MCP server not found: ${name}`,
|
||||
);
|
||||
}
|
||||
delete servers[name];
|
||||
});
|
||||
@@ -126,7 +133,9 @@ export function clearServerOAuth(name: string): void {
|
||||
mutateServers((servers) => {
|
||||
const existing = getOwnServerRecord(servers, name);
|
||||
if (!existing) {
|
||||
throw new McpSettingsUpdateSkippedError(`MCP server not found: ${name}`);
|
||||
throw new McpSettingsUpdateSkippedError(
|
||||
`MCP server not found: ${name}`,
|
||||
);
|
||||
}
|
||||
delete existing.oauth;
|
||||
servers[name] = existing;
|
||||
|
||||
@@ -85,7 +85,8 @@ export function setMcpServerDisabled(
|
||||
// Hold the cross-process lock across read-modify-write so a concurrent writer
|
||||
// (the extension, the CLI) cannot clobber this change.
|
||||
updateMcpSettingsFileSync(resolveMcpSettingsPath(), (settings) => {
|
||||
const servers = ((settings.mcpServers as JsonRecord | undefined) ?? {}) as JsonRecord;
|
||||
const servers = ((settings.mcpServers as JsonRecord | undefined) ??
|
||||
{}) as JsonRecord;
|
||||
const current = servers[name];
|
||||
if (!current || typeof current !== "object") {
|
||||
throw new Error(`unknown MCP server: ${name}`);
|
||||
@@ -128,7 +129,8 @@ export function upsertMcpServer(input: JsonRecord): JsonRecord {
|
||||
// Hold the cross-process lock across read-modify-write so a concurrent writer
|
||||
// cannot clobber this upsert.
|
||||
updateMcpSettingsFileSync(resolveMcpSettingsPath(), (settings) => {
|
||||
const servers = ((settings.mcpServers as JsonRecord | undefined) ?? {}) as JsonRecord;
|
||||
const servers = ((settings.mcpServers as JsonRecord | undefined) ??
|
||||
{}) as JsonRecord;
|
||||
if (previousName && previousName !== name) {
|
||||
delete servers[previousName];
|
||||
}
|
||||
@@ -143,7 +145,8 @@ export function deleteMcpServer(name: string): JsonRecord {
|
||||
// Hold the cross-process lock across read-modify-write so a concurrent writer
|
||||
// cannot resurrect the deleted server from a stale snapshot.
|
||||
updateMcpSettingsFileSync(resolveMcpSettingsPath(), (settings) => {
|
||||
const servers = ((settings.mcpServers as JsonRecord | undefined) ?? {}) as JsonRecord;
|
||||
const servers = ((settings.mcpServers as JsonRecord | undefined) ??
|
||||
{}) as JsonRecord;
|
||||
delete servers[name];
|
||||
settings.mcpServers = servers;
|
||||
});
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { mapHistoryToWebviewMessages } from "./session-mapping";
|
||||
|
||||
describe("mapHistoryToWebviewMessages", () => {
|
||||
it("hydrates assistant tool uses with following user tool results", () => {
|
||||
const messages = mapHistoryToWebviewMessages([
|
||||
{
|
||||
id: "assistant-1",
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ type: "text", text: "I'll inspect the file." },
|
||||
{
|
||||
type: "tool_use",
|
||||
id: "toolu_1",
|
||||
name: "read_file",
|
||||
input: { path: "src/index.ts" },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "user-1",
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
type: "tool_result",
|
||||
id: "result-block-1",
|
||||
tool_use_id: "toolu_1",
|
||||
name: "read_file",
|
||||
content: "export const value = 1;",
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
expect(messages).toHaveLength(1);
|
||||
expect(messages[0]).toMatchObject({
|
||||
id: "assistant-1",
|
||||
role: "assistant",
|
||||
text: "I'll inspect the file.",
|
||||
toolEvents: [
|
||||
{
|
||||
toolCallId: "toolu_1",
|
||||
name: "read_file",
|
||||
state: "output-available",
|
||||
input: { path: "src/index.ts" },
|
||||
output: "export const value = 1;",
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(messages[0].blocks).toEqual([
|
||||
{
|
||||
id: "assistant-1:text:0",
|
||||
type: "text",
|
||||
text: "I'll inspect the file.",
|
||||
},
|
||||
{
|
||||
id: "assistant-1:tool:toolu_1",
|
||||
type: "tool",
|
||||
toolEvent: expect.objectContaining({
|
||||
toolCallId: "toolu_1",
|
||||
name: "read_file",
|
||||
state: "output-available",
|
||||
output: "export const value = 1;",
|
||||
}),
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("hydrates error tool results", () => {
|
||||
const messages = mapHistoryToWebviewMessages([
|
||||
{
|
||||
id: "assistant-1",
|
||||
role: "assistant",
|
||||
content: [
|
||||
{
|
||||
type: "tool_use",
|
||||
id: "toolu_1",
|
||||
name: "read_file",
|
||||
input: { path: "missing.ts" },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "user-1",
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
type: "tool_result",
|
||||
tool_use_id: "toolu_1",
|
||||
name: "read_file",
|
||||
content: "File not found",
|
||||
is_error: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
expect(messages).toHaveLength(1);
|
||||
expect(messages[0].toolEvents).toEqual([
|
||||
expect.objectContaining({
|
||||
toolCallId: "toolu_1",
|
||||
name: "read_file",
|
||||
state: "output-error",
|
||||
output: "File not found",
|
||||
error: "File not found",
|
||||
}),
|
||||
]);
|
||||
expect(messages[0].blocks?.[0]).toMatchObject({
|
||||
type: "tool",
|
||||
toolEvent: {
|
||||
toolCallId: "toolu_1",
|
||||
state: "output-error",
|
||||
error: "File not found",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("hydrates orphan tool results as standalone meta tool blocks", () => {
|
||||
const messages = mapHistoryToWebviewMessages([
|
||||
{
|
||||
id: "user-1",
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
type: "tool_result",
|
||||
tool_use_id: "toolu_orphan",
|
||||
name: "read_file",
|
||||
content: "orphan output",
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
expect(messages).toHaveLength(1);
|
||||
expect(messages[0]).toMatchObject({
|
||||
id: "user-1",
|
||||
role: "meta",
|
||||
text: "",
|
||||
toolEvents: [
|
||||
{
|
||||
toolCallId: "toolu_orphan",
|
||||
name: "read_file",
|
||||
state: "output-available",
|
||||
input: undefined,
|
||||
output: "orphan output",
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(messages[0].blocks).toEqual([
|
||||
{
|
||||
id: "user-1:tool:toolu_orphan",
|
||||
type: "tool",
|
||||
toolEvent: expect.objectContaining({
|
||||
toolCallId: "toolu_orphan",
|
||||
input: undefined,
|
||||
output: "orphan output",
|
||||
}),
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("hydrates plain string content as a text block", () => {
|
||||
const messages = mapHistoryToWebviewMessages([
|
||||
{
|
||||
id: "assistant-1",
|
||||
role: "assistant",
|
||||
content: "Plain response",
|
||||
},
|
||||
]);
|
||||
|
||||
expect(messages).toEqual([
|
||||
{
|
||||
id: "assistant-1",
|
||||
role: "assistant",
|
||||
text: "Plain response",
|
||||
reasoning: undefined,
|
||||
reasoningRedacted: undefined,
|
||||
toolEvents: undefined,
|
||||
blocks: [
|
||||
{
|
||||
id: "assistant-1:text:0",
|
||||
type: "text",
|
||||
text: "Plain response",
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("hydrates same-message tool-call and tool-result blocks", () => {
|
||||
const messages = mapHistoryToWebviewMessages([
|
||||
{
|
||||
id: "assistant-1",
|
||||
role: "assistant",
|
||||
content: [
|
||||
{
|
||||
type: "tool-call",
|
||||
toolCallId: "call_1",
|
||||
toolName: "search",
|
||||
input: { query: "cline" },
|
||||
},
|
||||
{
|
||||
type: "tool-result",
|
||||
toolCallId: "call_1",
|
||||
toolName: "search",
|
||||
output: [{ query: "cline", result: "found", success: true }],
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
expect(messages).toHaveLength(1);
|
||||
expect(messages[0].toolEvents).toEqual([
|
||||
expect.objectContaining({
|
||||
toolCallId: "call_1",
|
||||
name: "search",
|
||||
state: "output-available",
|
||||
input: { query: "cline" },
|
||||
output: [{ query: "cline", result: "found", success: true }],
|
||||
}),
|
||||
]);
|
||||
expect(messages[0].blocks).toHaveLength(1);
|
||||
expect(messages[0].blocks?.[0]).toMatchObject({
|
||||
type: "tool",
|
||||
toolEvent: {
|
||||
toolCallId: "call_1",
|
||||
state: "output-available",
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -9,6 +9,7 @@ import type { HubContext } from "./state";
|
||||
import type { SessionContext, TrackedClient, TrackedSession } from "./types";
|
||||
import {
|
||||
asNumber,
|
||||
asRecord,
|
||||
asString,
|
||||
asTimestamp,
|
||||
basename,
|
||||
@@ -88,27 +89,291 @@ function summarizeClient(client: TrackedClient): {
|
||||
};
|
||||
}
|
||||
|
||||
type HistoryToolLocation = {
|
||||
messageIndex: number;
|
||||
blockIndex: number;
|
||||
};
|
||||
|
||||
function historyContentParts(content: unknown): Record<string, unknown>[] {
|
||||
if (Array.isArray(content)) {
|
||||
return content
|
||||
.map((part) => asRecord(part))
|
||||
.filter((part): part is Record<string, unknown> => Boolean(part));
|
||||
}
|
||||
if (typeof content === "string" && content.trim()) {
|
||||
return [{ type: "text", text: content }];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function blockType(block: Record<string, unknown>): string {
|
||||
return asString(block.type)?.toLowerCase() ?? "";
|
||||
}
|
||||
|
||||
function toolCallIdForCall(block: Record<string, unknown>): string | undefined {
|
||||
return (
|
||||
asString(block.id) ??
|
||||
asString(block.toolCallId) ??
|
||||
asString(block.tool_call_id)
|
||||
);
|
||||
}
|
||||
|
||||
function toolCallIdForResult(
|
||||
block: Record<string, unknown>,
|
||||
): string | undefined {
|
||||
return (
|
||||
asString(block.tool_use_id) ??
|
||||
asString(block.toolCallId) ??
|
||||
asString(block.tool_call_id)
|
||||
);
|
||||
}
|
||||
|
||||
function toolNameFor(block: Record<string, unknown>): string {
|
||||
return (
|
||||
asString(block.name) ??
|
||||
asString(block.toolName) ??
|
||||
asString(block.tool_name) ??
|
||||
"tool"
|
||||
);
|
||||
}
|
||||
|
||||
function toolInputFor(block: Record<string, unknown>): unknown {
|
||||
return block.input ?? block.args ?? block.arguments;
|
||||
}
|
||||
|
||||
function toolOutputFor(block: Record<string, unknown>): unknown {
|
||||
return block.output ?? block.result ?? block.content;
|
||||
}
|
||||
|
||||
function isErrorToolResult(block: Record<string, unknown>): boolean {
|
||||
return (
|
||||
block.is_error === true || block.isError === true || block.error === true
|
||||
);
|
||||
}
|
||||
|
||||
function pushTextBlock(
|
||||
blocks: NonNullable<WebviewChatMessage["blocks"]>,
|
||||
textParts: string[],
|
||||
messageKey: string | number,
|
||||
partIndex: number,
|
||||
text: string,
|
||||
): void {
|
||||
if (!text) return;
|
||||
textParts.push(text);
|
||||
blocks.push({
|
||||
id: `${messageKey}:text:${partIndex}`,
|
||||
type: "text",
|
||||
text,
|
||||
});
|
||||
}
|
||||
|
||||
function pushReasoningBlock(
|
||||
blocks: NonNullable<WebviewChatMessage["blocks"]>,
|
||||
reasoningParts: string[],
|
||||
messageKey: string | number,
|
||||
partIndex: number,
|
||||
text: string,
|
||||
redacted?: boolean,
|
||||
): boolean {
|
||||
if (!text) return false;
|
||||
reasoningParts.push(text);
|
||||
blocks.push({
|
||||
id: `${messageKey}:reasoning:${partIndex}`,
|
||||
type: "reasoning",
|
||||
text,
|
||||
redacted,
|
||||
});
|
||||
return redacted === true;
|
||||
}
|
||||
|
||||
export function mapHistoryToWebviewMessages(
|
||||
history: unknown[],
|
||||
): WebviewChatMessage[] {
|
||||
return history.map((entry, index) => {
|
||||
const mapped: WebviewChatMessage[] = [];
|
||||
const toolLocations = new Map<string, HistoryToolLocation>();
|
||||
|
||||
for (const [index, entry] of history.entries()) {
|
||||
const record =
|
||||
entry && typeof entry === "object"
|
||||
? (entry as Record<string, unknown>)
|
||||
: { content: entry };
|
||||
const messageKey = asString(record.id) ?? `history-${index}`;
|
||||
const rawRole = asString(record.role)?.toLowerCase();
|
||||
const role: WebviewChatMessage["role"] =
|
||||
let role: WebviewChatMessage["role"] =
|
||||
rawRole === "user" || rawRole === "assistant" || rawRole === "error"
|
||||
? rawRole
|
||||
: "meta";
|
||||
const text = stringifyContent(record.content ?? record.text ?? record);
|
||||
return {
|
||||
id: asString(record.id) ?? `history-${index}`,
|
||||
const blocks: NonNullable<WebviewChatMessage["blocks"]> = [];
|
||||
const textParts: string[] = [];
|
||||
const reasoningParts: string[] = [];
|
||||
const toolEvents = new Map<
|
||||
string,
|
||||
NonNullable<WebviewChatMessage["toolEvents"]>[number]
|
||||
>();
|
||||
const currentToolBlockIndexes = new Map<string, number>();
|
||||
let reasoningRedacted = false;
|
||||
|
||||
const contentParts = historyContentParts(record.content);
|
||||
if (contentParts.length === 0) {
|
||||
const text = stringifyContent(record.content ?? record.text ?? record);
|
||||
pushTextBlock(blocks, textParts, messageKey, 0, text);
|
||||
}
|
||||
|
||||
for (const [partIndex, part] of contentParts.entries()) {
|
||||
const type = blockType(part);
|
||||
if (type === "text") {
|
||||
pushTextBlock(
|
||||
blocks,
|
||||
textParts,
|
||||
messageKey,
|
||||
partIndex,
|
||||
asString(part.text) ?? asString(part.content) ?? "",
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (type === "thinking" || type === "reasoning") {
|
||||
reasoningRedacted =
|
||||
pushReasoningBlock(
|
||||
blocks,
|
||||
reasoningParts,
|
||||
messageKey,
|
||||
partIndex,
|
||||
asString(part.thinking) ??
|
||||
asString(part.reasoning) ??
|
||||
asString(part.text) ??
|
||||
"",
|
||||
part.redacted === true,
|
||||
) || reasoningRedacted;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (type === "redacted_thinking") {
|
||||
reasoningRedacted =
|
||||
pushReasoningBlock(
|
||||
blocks,
|
||||
reasoningParts,
|
||||
messageKey,
|
||||
partIndex,
|
||||
"[redacted]",
|
||||
true,
|
||||
) || reasoningRedacted;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (type === "tool_use" || type === "tool-call") {
|
||||
const toolCallId =
|
||||
toolCallIdForCall(part) ?? `${messageKey}:${partIndex}`;
|
||||
const name = toolNameFor(part);
|
||||
const toolEvent = {
|
||||
id: `${messageKey}:${toolCallId}`,
|
||||
toolCallId,
|
||||
name,
|
||||
text: `Running ${name}...`,
|
||||
state: "input-available" as const,
|
||||
input: toolInputFor(part),
|
||||
};
|
||||
toolEvents.set(toolCallId, toolEvent);
|
||||
blocks.push({
|
||||
id: `${messageKey}:tool:${toolCallId}`,
|
||||
type: "tool",
|
||||
toolEvent,
|
||||
});
|
||||
currentToolBlockIndexes.set(toolCallId, blocks.length - 1);
|
||||
toolLocations.set(toolCallId, {
|
||||
messageIndex: mapped.length,
|
||||
blockIndex: blocks.length - 1,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (type === "tool_result" || type === "tool-result") {
|
||||
const toolCallId =
|
||||
toolCallIdForResult(part) ?? `${messageKey}:${partIndex}`;
|
||||
const name = toolNameFor(part);
|
||||
const output = toolOutputFor(part);
|
||||
const isError = isErrorToolResult(part);
|
||||
const currentBlockIndex = currentToolBlockIndexes.get(toolCallId);
|
||||
const existingLocation = toolLocations.get(toolCallId);
|
||||
const existing =
|
||||
currentBlockIndex !== undefined
|
||||
? blocks[currentBlockIndex]
|
||||
: existingLocation !== undefined
|
||||
? mapped[existingLocation.messageIndex]?.blocks?.[
|
||||
existingLocation.blockIndex
|
||||
]
|
||||
: undefined;
|
||||
const existingToolEvent =
|
||||
existing?.type === "tool" ? existing.toolEvent : undefined;
|
||||
const toolEvent = {
|
||||
id: existingToolEvent?.id ?? `${messageKey}:${toolCallId}`,
|
||||
toolCallId,
|
||||
name: existingToolEvent?.name ?? name,
|
||||
text: isError
|
||||
? `${existingToolEvent?.name ?? name} failed`
|
||||
: `${existingToolEvent?.name ?? name} completed`,
|
||||
state: isError
|
||||
? ("output-error" as const)
|
||||
: ("output-available" as const),
|
||||
input: existingToolEvent?.input,
|
||||
output,
|
||||
error: isError ? stringifyContent(output) : undefined,
|
||||
};
|
||||
|
||||
if (currentBlockIndex !== undefined && existing?.type === "tool") {
|
||||
blocks[currentBlockIndex] = {
|
||||
...existing,
|
||||
toolEvent,
|
||||
};
|
||||
toolEvents.set(toolCallId, toolEvent);
|
||||
} else if (
|
||||
existingLocation !== undefined &&
|
||||
existing?.type === "tool"
|
||||
) {
|
||||
const target = mapped[existingLocation.messageIndex];
|
||||
const targetBlocks = target.blocks;
|
||||
const targetBlock = targetBlocks?.[existingLocation.blockIndex];
|
||||
if (targetBlocks && targetBlock?.type === "tool") {
|
||||
targetBlocks[existingLocation.blockIndex] = {
|
||||
...targetBlock,
|
||||
toolEvent,
|
||||
};
|
||||
}
|
||||
target.toolEvents = (target.toolEvents ?? []).map((event) =>
|
||||
event.toolCallId === toolCallId ? toolEvent : event,
|
||||
);
|
||||
} else {
|
||||
toolEvents.set(toolCallId, toolEvent);
|
||||
blocks.push({
|
||||
id: `${messageKey}:tool:${toolCallId}`,
|
||||
type: "tool",
|
||||
toolEvent,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const text = textParts.join("\n");
|
||||
const toolEventList = [...toolEvents.values()];
|
||||
if (!text && reasoningParts.length === 0 && toolEventList.length === 0) {
|
||||
continue;
|
||||
}
|
||||
if (!text && role === "user" && toolEventList.length > 0) {
|
||||
role = "meta";
|
||||
}
|
||||
mapped.push({
|
||||
id: messageKey,
|
||||
role,
|
||||
text,
|
||||
blocks: text ? [{ id: `history-${index}-text`, type: "text", text }] : [],
|
||||
};
|
||||
});
|
||||
reasoning:
|
||||
reasoningParts.length > 0 ? reasoningParts.join("\n") : undefined,
|
||||
reasoningRedacted: reasoningRedacted || undefined,
|
||||
toolEvents: toolEventList.length > 0 ? toolEventList : undefined,
|
||||
blocks,
|
||||
});
|
||||
}
|
||||
|
||||
return mapped;
|
||||
}
|
||||
|
||||
export function trackSession(record: unknown): TrackedSession | undefined {
|
||||
|
||||
@@ -21,7 +21,7 @@ export function PageFrame({
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className={cn("max-w-[86rem]", contentClassName)}>{children}</div>
|
||||
<div className={cn("max-w-344", contentClassName)}>{children}</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
);
|
||||
|
||||
@@ -134,7 +134,7 @@ export function ProviderListContent({
|
||||
isPanel ? "text-[24px]" : "text-[32px]",
|
||||
)}
|
||||
>
|
||||
Models
|
||||
Model Providers
|
||||
</h1>
|
||||
<p className="mt-3 text-[15px] leading-6 text-muted-foreground">
|
||||
Configure model providers and choose which ones are available.{" "}
|
||||
|
||||
@@ -1,10 +1,5 @@
|
||||
import { execFileSync, spawn } from "node:child_process";
|
||||
import {
|
||||
existsSync,
|
||||
readdirSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
} from "node:fs";
|
||||
import { existsSync, readdirSync, readFileSync, rmSync } from "node:fs";
|
||||
import { basename, dirname, extname, join } from "node:path";
|
||||
import type {
|
||||
ClineAccountActionRequest,
|
||||
@@ -1043,7 +1038,8 @@ export async function handleCommand(
|
||||
if (command === "set_mcp_server_disabled") {
|
||||
const path = ensureMcpSettingsFile();
|
||||
updateMcpSettingsFileSync(path, (settings) => {
|
||||
const servers = ((settings.mcpServers as JsonRecord | undefined) ?? {}) as JsonRecord;
|
||||
const servers = ((settings.mcpServers as JsonRecord | undefined) ??
|
||||
{}) as JsonRecord;
|
||||
const name = String(args?.name ?? "").trim();
|
||||
const current = servers[name];
|
||||
if (!current || typeof current !== "object") {
|
||||
@@ -1094,7 +1090,8 @@ export async function handleCommand(
|
||||
};
|
||||
const path = ensureMcpSettingsFile();
|
||||
updateMcpSettingsFileSync(path, (settings) => {
|
||||
const servers = ((settings.mcpServers as JsonRecord | undefined) ?? {}) as JsonRecord;
|
||||
const servers = ((settings.mcpServers as JsonRecord | undefined) ??
|
||||
{}) as JsonRecord;
|
||||
if (previousName && previousName !== name) {
|
||||
delete servers[previousName];
|
||||
}
|
||||
@@ -1106,7 +1103,8 @@ export async function handleCommand(
|
||||
if (command === "delete_mcp_server") {
|
||||
const path = ensureMcpSettingsFile();
|
||||
updateMcpSettingsFileSync(path, (settings) => {
|
||||
const servers = ((settings.mcpServers as JsonRecord | undefined) ?? {}) as JsonRecord;
|
||||
const servers = ((settings.mcpServers as JsonRecord | undefined) ??
|
||||
{}) as JsonRecord;
|
||||
delete servers[String(args?.name ?? "")];
|
||||
settings.mcpServers = servers;
|
||||
});
|
||||
|
||||
@@ -284,6 +284,10 @@ message Settings {
|
||||
optional string act_mode_cline_model_id = 180;
|
||||
optional OpenRouterModelInfo act_mode_cline_model_info = 181;
|
||||
optional bool show_feature_tips = 182;
|
||||
optional string plan_mode_cline_pass_model_id = 183;
|
||||
optional OpenRouterModelInfo plan_mode_cline_pass_model_info = 184;
|
||||
optional string act_mode_cline_pass_model_id = 185;
|
||||
optional OpenRouterModelInfo act_mode_cline_pass_model_info = 186;
|
||||
}
|
||||
|
||||
message State {
|
||||
@@ -425,6 +429,7 @@ message UpdateSettingsRequest {
|
||||
optional bool opt_out_of_remote_config = 39;
|
||||
optional bool worktrees_enabled = 40;
|
||||
optional bool show_feature_tips = 42;
|
||||
optional string compaction_strategy = 44;
|
||||
}
|
||||
|
||||
message UpdateTerminalConnectionTimeoutRequest {
|
||||
|
||||
@@ -85,7 +85,7 @@ function inferProtoType(typeText, fieldName) {
|
||||
["FocusChainSettings", "FocusChainSettings"],
|
||||
["OpenaiReasoningEffort", "OpenaiReasoningEffort"],
|
||||
["PlanActMode", "PlanActMode"],
|
||||
["ApiProvider", "ApiProvider"],
|
||||
["ApiProvider", "string"],
|
||||
["LanguageModelChatSelector", "LanguageModelChatSelector"], // Must come before "Mode" check
|
||||
]
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
// This allows the SdkController to reuse the classic state-building logic
|
||||
// without inheriting the entire classic Controller implementation.
|
||||
|
||||
import { readCompactionStrategyGlobally } from "@cline/core"
|
||||
import { getHooksEnabledSafe } from "@core/hooks/hooks-utils"
|
||||
import type { ExtensionState, Platform } from "@shared/ExtensionMessage"
|
||||
import { ClineEnv } from "@/config"
|
||||
@@ -40,6 +41,7 @@ export async function getStateToPostToWebview(controller: {
|
||||
const mode = stateManager.getGlobalSettingsKey("mode")
|
||||
const yoloModeToggled = stateManager.getGlobalSettingsKey("yoloModeToggled")
|
||||
const useAutoCondense = stateManager.getGlobalSettingsKey("useAutoCondense")
|
||||
const compactionStrategy = readCompactionStrategyGlobally()
|
||||
const subagentsEnabled = stateManager.getGlobalSettingsKey("subagentsEnabled")
|
||||
const userInfo = stateManager.getGlobalStateKey("userInfo")
|
||||
const mcpMarketplaceEnabled = stateManager.getGlobalStateKey("mcpMarketplaceEnabled")
|
||||
@@ -118,6 +120,7 @@ export async function getStateToPostToWebview(controller: {
|
||||
mode,
|
||||
yoloModeToggled,
|
||||
useAutoCondense,
|
||||
compactionStrategy,
|
||||
subagentsEnabled,
|
||||
userInfo,
|
||||
mcpMarketplaceEnabled,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { setCompactionStrategyGlobally } from "@cline/core"
|
||||
import { Empty } from "@shared/proto/cline/common"
|
||||
import { PlanActMode, McpDisplayMode as ProtoMcpDisplayMode, UpdateSettingsRequest } from "@shared/proto/cline/state"
|
||||
import { convertProtoToApiProvider } from "@shared/proto-conversions/models/api-configuration-conversion"
|
||||
@@ -179,6 +180,14 @@ export async function updateSettings(controller: Controller, request: UpdateSett
|
||||
controller.stateManager.setGlobalState("useAutoCondense", request.useAutoCondense)
|
||||
}
|
||||
|
||||
if (request.compactionStrategy !== undefined) {
|
||||
const strategy = request.compactionStrategy
|
||||
if (strategy !== "basic" && strategy !== "agentic") {
|
||||
throw new Error(`Invalid compaction strategy value: ${strategy}`)
|
||||
}
|
||||
setCompactionStrategyGlobally(strategy)
|
||||
}
|
||||
|
||||
// Update custom prompt choice
|
||||
if (request.customPrompt !== undefined) {
|
||||
const value = request.customPrompt === "compact" ? "compact" : undefined
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Empty } from "@shared/proto/cline/common"
|
||||
import { PlanActMode, UpdateSettingsRequestCli } from "@shared/proto/cline/state"
|
||||
import { convertProtoToApiProvider } from "@shared/proto-conversions/models/api-configuration-conversion"
|
||||
import { Settings } from "@shared/storage/state-keys"
|
||||
import type { Settings } from "@shared/storage/state-keys"
|
||||
import { TelemetrySetting } from "@shared/TelemetrySetting"
|
||||
import { ClineEnv } from "@/config"
|
||||
import { Logger } from "@/shared/services/Logger"
|
||||
|
||||
@@ -71,9 +71,11 @@ vi.mock("@shared/services/Logger", () => ({
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
let tempDir: string
|
||||
const previousGlobalSettingsPath = process.env.CLINE_GLOBAL_SETTINGS_PATH
|
||||
|
||||
beforeEach(() => {
|
||||
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "cline-session-factory-"))
|
||||
process.env.CLINE_GLOBAL_SETTINGS_PATH = path.join(tempDir, "global-settings.json")
|
||||
vi.clearAllMocks()
|
||||
mocks.stateManager.getApiConfiguration.mockReturnValue({
|
||||
actModeApiProvider: "anthropic",
|
||||
@@ -91,6 +93,7 @@ beforeEach(() => {
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
process.env.CLINE_GLOBAL_SETTINGS_PATH = previousGlobalSettingsPath
|
||||
fs.rmSync(tempDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
@@ -651,6 +654,46 @@ describe("buildSessionConfig", () => {
|
||||
})
|
||||
})
|
||||
|
||||
it("uses the configured SDK compaction strategy when auto condense is enabled", async () => {
|
||||
writeJson(process.env.CLINE_GLOBAL_SETTINGS_PATH!, { compactionStrategy: "agentic" })
|
||||
mocks.stateManager.getGlobalSettingsKey.mockImplementation((key: string) => {
|
||||
if (key === "useAutoCondense") {
|
||||
return true
|
||||
}
|
||||
if (key === "subagentsEnabled") {
|
||||
return false
|
||||
}
|
||||
return undefined
|
||||
})
|
||||
|
||||
const config = await buildSessionConfig({ cwd: "/tmp/workspace" })
|
||||
|
||||
expect(config.compaction).toEqual({
|
||||
enabled: true,
|
||||
strategy: "agentic",
|
||||
})
|
||||
})
|
||||
|
||||
it("falls back to basic SDK compaction for an invalid stored strategy", async () => {
|
||||
writeJson(process.env.CLINE_GLOBAL_SETTINGS_PATH!, { compactionStrategy: "invalid" })
|
||||
mocks.stateManager.getGlobalSettingsKey.mockImplementation((key: string) => {
|
||||
if (key === "useAutoCondense") {
|
||||
return true
|
||||
}
|
||||
if (key === "subagentsEnabled") {
|
||||
return false
|
||||
}
|
||||
return undefined
|
||||
})
|
||||
|
||||
const config = await buildSessionConfig({ cwd: "/tmp/workspace" })
|
||||
|
||||
expect(config.compaction).toEqual({
|
||||
enabled: true,
|
||||
strategy: "basic",
|
||||
})
|
||||
})
|
||||
|
||||
it("does not enable SDK compaction when global useAutoCondense is false", async () => {
|
||||
const config = await buildSessionConfig({ cwd: "/tmp/workspace" })
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
type CoreSessionConfig,
|
||||
getProviderAuthHandler,
|
||||
type ProviderSettings,
|
||||
readCompactionStrategyGlobally,
|
||||
resolveProviderApiKeyFromSettings,
|
||||
type StartSessionResult,
|
||||
} from "@cline/core"
|
||||
@@ -655,6 +656,7 @@ export async function buildSessionConfig(input: SessionConfigInput): Promise<Cor
|
||||
|
||||
const stateManager = StateManager.get()
|
||||
const globalUseAutoCondense = stateManager.getGlobalSettingsKey("useAutoCondense") ?? false
|
||||
const compactionStrategy = readCompactionStrategyGlobally()
|
||||
const enableCheckpoints = stateManager.getGlobalSettingsKey("enableCheckpointsSetting") ?? true
|
||||
const useAutoCondense = input.taskSettings?.useAutoCondense ?? globalUseAutoCondense
|
||||
|
||||
@@ -699,7 +701,7 @@ export async function buildSessionConfig(input: SessionConfigInput): Promise<Cor
|
||||
? {
|
||||
compaction: {
|
||||
enabled: true,
|
||||
strategy: "basic",
|
||||
strategy: compactionStrategy,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
|
||||
@@ -109,6 +109,7 @@ export interface ExtensionState {
|
||||
mcpResponsesCollapsed?: boolean
|
||||
yoloModeToggled?: boolean
|
||||
useAutoCondense?: boolean
|
||||
compactionStrategy?: string
|
||||
subagentsEnabled?: boolean
|
||||
worktreesEnabled?: ClineFeatureSetting
|
||||
customPrompt?: string
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { readFileSync, writeFileSync } from "node:fs"
|
||||
import { getGeneratedModelsForProvider, MODEL_COLLECTIONS_BY_PROVIDER_ID } from "@cline/llms"
|
||||
|
||||
export interface OAuthCredentials {
|
||||
@@ -12,6 +13,28 @@ export interface StartSessionResult {
|
||||
|
||||
export const MAX_COMMAND_OUTPUT_CHARS = 200_000
|
||||
|
||||
export type GlobalCompactionStrategy = "basic" | "agentic"
|
||||
|
||||
export function readCompactionStrategyGlobally(): GlobalCompactionStrategy {
|
||||
try {
|
||||
const settings = JSON.parse(readFileSync(process.env.CLINE_GLOBAL_SETTINGS_PATH ?? "", "utf8"))
|
||||
return settings.compactionStrategy === "agentic" ? "agentic" : "basic"
|
||||
} catch {
|
||||
return "basic"
|
||||
}
|
||||
}
|
||||
|
||||
export function setCompactionStrategyGlobally(compactionStrategy: GlobalCompactionStrategy): void {
|
||||
const filePath = process.env.CLINE_GLOBAL_SETTINGS_PATH
|
||||
if (filePath) {
|
||||
let settings = {}
|
||||
try {
|
||||
settings = JSON.parse(readFileSync(filePath, "utf8"))
|
||||
} catch {}
|
||||
writeFileSync(filePath, JSON.stringify({ ...settings, compactionStrategy }))
|
||||
}
|
||||
}
|
||||
|
||||
export function truncateCommandOutput(output: string): string {
|
||||
return output
|
||||
}
|
||||
|
||||
+34
-5
@@ -1,23 +1,27 @@
|
||||
import { fireEvent, render, screen } from "@testing-library/react"
|
||||
import { describe, expect, it, vi } from "vitest"
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest"
|
||||
import FeatureSettingsSection from "./FeatureSettingsSection"
|
||||
|
||||
const mockUpdateSetting = vi.fn()
|
||||
|
||||
vi.mock("@/context/ExtensionStateContext", () => ({
|
||||
useExtensionState: vi.fn(() => ({
|
||||
const mockExtensionState = vi.hoisted(() => ({
|
||||
value: {
|
||||
enableCheckpointsSetting: true,
|
||||
hooksEnabled: false,
|
||||
showFeatureTips: false,
|
||||
mcpDisplayMode: "rich",
|
||||
yoloModeToggled: false,
|
||||
useAutoCondense: false,
|
||||
compactionStrategy: "basic",
|
||||
subagentsEnabled: false,
|
||||
worktreesEnabled: { user: true, featureFlag: true },
|
||||
focusChainSettings: { enabled: false, remindClineInterval: 6 },
|
||||
remoteConfigSettings: {},
|
||||
backgroundEditEnabled: false,
|
||||
})),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock("@/context/ExtensionStateContext", () => ({
|
||||
useExtensionState: vi.fn(() => mockExtensionState.value),
|
||||
}))
|
||||
|
||||
vi.mock("../utils/settingsHandlers", () => ({
|
||||
@@ -25,6 +29,15 @@ vi.mock("../utils/settingsHandlers", () => ({
|
||||
}))
|
||||
|
||||
describe("FeatureSettingsSection", () => {
|
||||
beforeEach(() => {
|
||||
mockUpdateSetting.mockClear()
|
||||
mockExtensionState.value = {
|
||||
...mockExtensionState.value,
|
||||
useAutoCondense: false,
|
||||
compactionStrategy: "basic",
|
||||
}
|
||||
})
|
||||
|
||||
it("renders Hooks feature toggle", () => {
|
||||
const { container } = render(<FeatureSettingsSection renderSectionHeader={() => null} />)
|
||||
|
||||
@@ -49,6 +62,22 @@ describe("FeatureSettingsSection", () => {
|
||||
expect(agentSection?.querySelector('[id="Feature Tips"]')).toBeNull()
|
||||
})
|
||||
|
||||
it("renders the Auto Compact Strategy setting in the Agent section", () => {
|
||||
const { container } = render(<FeatureSettingsSection renderSectionHeader={() => null} />)
|
||||
|
||||
expect(screen.getByText("Auto Compact Strategy")).toBeTruthy()
|
||||
|
||||
const agentSection = container.querySelector("#agent-features")
|
||||
expect(agentSection?.textContent).toContain("Basic")
|
||||
})
|
||||
|
||||
it("disables Auto Compact Strategy when Auto Compact is off", () => {
|
||||
const { container } = render(<FeatureSettingsSection renderSectionHeader={() => null} />)
|
||||
|
||||
const strategySelect = container.querySelector("#agent-features button[role='combobox']")
|
||||
expect(strategySelect).toHaveAttribute("disabled")
|
||||
})
|
||||
|
||||
it("calls updateSetting with hooksEnabled when toggled", () => {
|
||||
const { container } = render(<FeatureSettingsSection renderSectionHeader={() => null} />)
|
||||
|
||||
|
||||
@@ -154,6 +154,7 @@ const FeatureSettingsSection = ({ renderSectionHeader }: FeatureSettingsSectionP
|
||||
mcpDisplayMode,
|
||||
yoloModeToggled,
|
||||
useAutoCondense,
|
||||
compactionStrategy,
|
||||
subagentsEnabled,
|
||||
worktreesEnabled,
|
||||
remoteConfigSettings,
|
||||
@@ -201,6 +202,22 @@ const FeatureSettingsSection = ({ renderSectionHeader }: FeatureSettingsSectionP
|
||||
onChange={(checked) => updateSetting(feature.settingKey, checked)}
|
||||
/>
|
||||
))}
|
||||
<div className="space-y-2 py-3">
|
||||
<Label className="text-sm font-medium text-foreground">Auto Compact Strategy</Label>
|
||||
<p className="text-xs text-muted-foreground">Controls how auto compaction rewrites context.</p>
|
||||
<Select
|
||||
disabled={!useAutoCondense}
|
||||
onValueChange={(value) => updateSetting("compactionStrategy", value)}
|
||||
value={compactionStrategy ?? "basic"}>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="basic">Basic</SelectItem>
|
||||
<SelectItem value="agentic">Agentic</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -301,6 +301,7 @@ export const ExtensionStateContextProvider: React.FC<{
|
||||
yoloModeToggled: false,
|
||||
customPrompt: undefined,
|
||||
useAutoCondense: false,
|
||||
compactionStrategy: "basic",
|
||||
subagentsEnabled: false,
|
||||
worktreesEnabled: { user: true, featureFlag: false },
|
||||
favoritedModelIds: [],
|
||||
|
||||
@@ -1,5 +1,20 @@
|
||||
# Cline SDK Changelog
|
||||
|
||||
## 0.0.56
|
||||
|
||||
- Tool calls from weaker models that use slightly-off argument shapes (e.g. a bare string where an array is expected) or malformed/truncated JSON are now coerced or repaired and executed, instead of being rejected before the tools can handle them
|
||||
- Fixed plan/act mode notices being stripped from outbound prompts
|
||||
- Added support for surfacing plan/act mode switches to the model
|
||||
|
||||
## 0.0.55
|
||||
|
||||
- Add Tencent TokenHub as a provider
|
||||
- Add a compaction strategy setting so you can choose how context compaction works
|
||||
- Fix first-prompt truncation on high-output models (e.g. MiniMax M3), where a shallow session could auto-compact immediately and reduce the initial task to just the input wrapper
|
||||
- Use a curated default when migrating legacy provider settings
|
||||
- Advertise run commands as shell strings
|
||||
- Refresh the bundled model catalog with the latest provider models
|
||||
|
||||
## 0.0.54
|
||||
|
||||
- Improve basic compaction token budgeting so context compaction is more accurate
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@cline/agents",
|
||||
"version": "0.0.54",
|
||||
"version": "0.0.56",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/cline/cline",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@cline/core",
|
||||
"description": "Cline Core SDK for Node Runtime",
|
||||
"version": "0.0.54",
|
||||
"version": "0.0.56",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/cline/cline",
|
||||
|
||||
@@ -165,9 +165,7 @@ describe("ClineAccountService", () => {
|
||||
userId: "user-1",
|
||||
};
|
||||
const fetchImpl = vi.fn(async (input: unknown, init?: RequestInit) => {
|
||||
expect(String(input)).toBe(
|
||||
"https://api.cline.bot/api/v1/users/me/plan",
|
||||
);
|
||||
expect(String(input)).toBe("https://api.cline.bot/api/v1/users/me/plan");
|
||||
expect(init?.headers).toMatchObject({
|
||||
Authorization: "Bearer workos:token-123",
|
||||
});
|
||||
|
||||
@@ -273,6 +273,7 @@ function trimCandidatesToBudget(
|
||||
candidates: BasicCompactionCandidate[],
|
||||
targetTokens: number,
|
||||
totalTokens: number,
|
||||
triggerTokens: number,
|
||||
estimateMessageTokens: EstimateMessageTokens,
|
||||
): number {
|
||||
if (totalTokens <= targetTokens) {
|
||||
@@ -311,6 +312,10 @@ function trimCandidatesToBudget(
|
||||
(candidate) => candidate.isFirstUser,
|
||||
);
|
||||
if (firstUserIndex >= 0) {
|
||||
const firstUser = candidates[firstUserIndex];
|
||||
if (firstUser.estimatedTokens <= triggerTokens) {
|
||||
return totalTokens;
|
||||
}
|
||||
while (totalTokens > targetTokens) {
|
||||
const candidate = candidates[firstUserIndex];
|
||||
const desiredTokens = Math.max(
|
||||
@@ -431,6 +436,7 @@ export function runBasicCompaction(options: {
|
||||
candidates,
|
||||
targetTokens,
|
||||
totalTokens,
|
||||
options.context.triggerTokens,
|
||||
options.estimateMessageTokens,
|
||||
);
|
||||
|
||||
|
||||
@@ -36,13 +36,14 @@ function totalJsonTokens(messages: LlmsProviders.Message[]): number {
|
||||
}
|
||||
|
||||
describe("createTokenEstimator", () => {
|
||||
it("does not treat assistant request metrics as per-message token counts", () => {
|
||||
it("does not treat cumulative request metrics as per-message token counts", () => {
|
||||
const estimateMessageTokens = createTokenEstimator();
|
||||
const message: MessageWithMetadata = {
|
||||
role: "assistant",
|
||||
content: "short",
|
||||
metrics: {
|
||||
inputTokens: 12,
|
||||
inputTokens: 100,
|
||||
cacheReadTokens: 80,
|
||||
outputTokens: 7,
|
||||
},
|
||||
};
|
||||
@@ -410,6 +411,91 @@ describe("createContextCompactionPrepareTurn", () => {
|
||||
expect(compacted).toBe(messages);
|
||||
});
|
||||
|
||||
it("does not truncate a shallow first task prompt below the trigger for high-output models", async () => {
|
||||
const prepareTurn = createContextCompactionPrepareTurn({
|
||||
providerId: "openrouter",
|
||||
modelId: "minimax/minimax-m3",
|
||||
providerConfig: {
|
||||
providerId: "openrouter",
|
||||
modelId: "minimax/minimax-m3",
|
||||
} as LlmsProviders.ProviderConfig,
|
||||
compaction: {
|
||||
enabled: true,
|
||||
strategy: "basic",
|
||||
maxInputTokens: 1_000,
|
||||
thresholdRatio: 0.9,
|
||||
},
|
||||
logger: undefined,
|
||||
});
|
||||
const task =
|
||||
'<user_input mode="act">Create /app/filter.py that removes JavaScript from HTML files. ' +
|
||||
"Keep this task prompt intact. ".repeat(25) +
|
||||
"</user_input>";
|
||||
const messages: LlmsProviders.Message[] = [
|
||||
{ role: "user", content: task },
|
||||
{ role: "assistant", content: "old assistant context ".repeat(500) },
|
||||
{ role: "user", content: "Continue" },
|
||||
];
|
||||
|
||||
const result = await prepareTurn?.({
|
||||
agentId: "agent-1",
|
||||
conversationId: "conv-1",
|
||||
parentAgentId: null,
|
||||
iteration: 1,
|
||||
abortSignal: new AbortController().signal,
|
||||
systemPrompt: "You are helpful.",
|
||||
tools: [],
|
||||
messages,
|
||||
apiMessages: messages,
|
||||
model: {
|
||||
id: "minimax/minimax-m3",
|
||||
provider: "openrouter",
|
||||
info: {
|
||||
id: "minimax/minimax-m3",
|
||||
maxInputTokens: 1_000,
|
||||
maxTokens: 950,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(result?.messages?.[0]?.content).toBe(task);
|
||||
expect(JSON.stringify(result?.messages)).toContain("Create /app/filter.py");
|
||||
expect(JSON.stringify(result?.messages)).not.toContain("<user_input\n...");
|
||||
});
|
||||
|
||||
it("can truncate an oversized first task prompt when it exceeds the trigger", () => {
|
||||
const oversizedPrompt = "<user_input>".repeat(500);
|
||||
const messages: LlmsProviders.Message[] = [
|
||||
{ role: "user", content: oversizedPrompt },
|
||||
{ role: "assistant", content: "old assistant context ".repeat(500) },
|
||||
{ role: "user", content: "current turn" },
|
||||
];
|
||||
|
||||
const compacted = runBasicCompaction({
|
||||
context: {
|
||||
agentId: "agent-1",
|
||||
conversationId: "conv-1",
|
||||
parentAgentId: null,
|
||||
iteration: 1,
|
||||
messages,
|
||||
model: {
|
||||
id: "mock-model",
|
||||
provider: "openrouter",
|
||||
info: { id: "mock-model", maxInputTokens: 1_000 },
|
||||
},
|
||||
maxInputTokens: 1_000,
|
||||
triggerTokens: 900,
|
||||
targetTokens: 100,
|
||||
thresholdRatio: 0.9,
|
||||
utilizationRatio: 2,
|
||||
},
|
||||
estimateMessageTokens: estimateJsonTokens,
|
||||
});
|
||||
|
||||
expect(compacted?.messages[0]?.content).not.toBe(oversizedPrompt);
|
||||
expect(String(compacted?.messages[0]?.content)).toContain("\n...");
|
||||
});
|
||||
|
||||
it("does not add unsupported max output tokens to Codex OAuth summarizer requests", () => {
|
||||
const codexConfig = resolveSummarizerConfig({
|
||||
activeProviderConfig: {
|
||||
@@ -1158,7 +1244,7 @@ describe("createContextCompactionPrepareTurn", () => {
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it("targets basic compaction below the model output-reserved input budget", async () => {
|
||||
it("targets basic compaction at half the input budget for long conversations", async () => {
|
||||
const compact = vi.fn((_context: CoreCompactionContext) => ({
|
||||
messages: [
|
||||
{ role: "user" as const, content: "Compacted by target budget" },
|
||||
@@ -1175,10 +1261,17 @@ describe("createContextCompactionPrepareTurn", () => {
|
||||
logger: undefined,
|
||||
});
|
||||
const messages: MessageWithMetadata[] = [
|
||||
{
|
||||
role: "user",
|
||||
content: "large prompt ".repeat(70_000),
|
||||
},
|
||||
{ role: "user", content: "turn 1" },
|
||||
{ role: "assistant", content: "answer 1" },
|
||||
{ role: "user", content: "turn 2" },
|
||||
{ role: "assistant", content: "answer 2" },
|
||||
{ role: "user", content: "turn 3" },
|
||||
{ role: "assistant", content: "answer 3" },
|
||||
{ role: "user", content: "turn 4" },
|
||||
{ role: "assistant", content: "answer 4" },
|
||||
{ role: "user", content: "turn 5" },
|
||||
{ role: "assistant", content: "answer 5" },
|
||||
{ role: "user", content: "large prompt ".repeat(70_000) },
|
||||
];
|
||||
|
||||
await prepareTurn?.({
|
||||
@@ -1205,7 +1298,69 @@ describe("createContextCompactionPrepareTurn", () => {
|
||||
expect(compact).toHaveBeenCalledTimes(1);
|
||||
const context = compact.mock.calls[0]?.[0];
|
||||
expect(context?.triggerTokens).toBe(244_800);
|
||||
expect(context?.targetTokens).toBe(100_800);
|
||||
expect(context?.targetTokens).toBe(136_000);
|
||||
});
|
||||
|
||||
it("keeps the long-conversation target below low custom trigger thresholds", async () => {
|
||||
const compact = vi.fn((_context: CoreCompactionContext) => ({
|
||||
messages: [
|
||||
{ role: "user" as const, content: "Compacted by low threshold" },
|
||||
],
|
||||
}));
|
||||
const prepareTurn = createContextCompactionPrepareTurn({
|
||||
providerId: "anthropic",
|
||||
modelId: "mock-model",
|
||||
providerConfig: {
|
||||
providerId: "anthropic",
|
||||
modelId: "mock-model",
|
||||
} as LlmsProviders.ProviderConfig,
|
||||
compaction: {
|
||||
enabled: true,
|
||||
strategy: "basic",
|
||||
thresholdRatio: 0.4,
|
||||
compact,
|
||||
},
|
||||
logger: undefined,
|
||||
});
|
||||
const messages: MessageWithMetadata[] = [
|
||||
{ role: "user", content: "turn 1" },
|
||||
{ role: "assistant", content: "answer 1" },
|
||||
{ role: "user", content: "turn 2" },
|
||||
{ role: "assistant", content: "answer 2" },
|
||||
{ role: "user", content: "turn 3" },
|
||||
{ role: "assistant", content: "answer 3" },
|
||||
{ role: "user", content: "turn 4" },
|
||||
{ role: "assistant", content: "answer 4" },
|
||||
{ role: "user", content: "turn 5" },
|
||||
{ role: "assistant", content: "answer 5" },
|
||||
{ role: "user", content: "large prompt ".repeat(20) },
|
||||
];
|
||||
|
||||
await prepareTurn?.({
|
||||
agentId: "agent-1",
|
||||
conversationId: "conv-1",
|
||||
parentAgentId: null,
|
||||
iteration: 1,
|
||||
abortSignal: new AbortController().signal,
|
||||
systemPrompt: "You are helpful.",
|
||||
tools: [],
|
||||
messages,
|
||||
apiMessages: messages,
|
||||
model: {
|
||||
id: "mock-model",
|
||||
provider: "anthropic",
|
||||
info: {
|
||||
id: "mock-model",
|
||||
maxInputTokens: 100,
|
||||
maxTokens: 20,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(compact).toHaveBeenCalledTimes(1);
|
||||
const context = compact.mock.calls[0]?.[0];
|
||||
expect(context?.triggerTokens).toBe(40);
|
||||
expect(context?.targetTokens).toBe(39);
|
||||
});
|
||||
|
||||
it("derives input budget by reserving model max output tokens from context window", async () => {
|
||||
@@ -1358,6 +1513,52 @@ describe("createContextCompactionPrepareTurn", () => {
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not collapse context-only input budget when output is nearly the full context", async () => {
|
||||
const compact = vi.fn((_context: CoreCompactionContext) => ({
|
||||
messages: [{ role: "user" as const, content: "Compacted by fallback" }],
|
||||
}));
|
||||
const prepareTurn = createContextCompactionPrepareTurn({
|
||||
providerId: "openrouter",
|
||||
modelId: "minimax/minimax-m3",
|
||||
providerConfig: {
|
||||
providerId: "openrouter",
|
||||
modelId: "minimax/minimax-m3",
|
||||
} as LlmsProviders.ProviderConfig,
|
||||
compaction: { enabled: true, strategy: "basic", compact },
|
||||
logger: undefined,
|
||||
});
|
||||
const messages: MessageWithMetadata[] = [
|
||||
{
|
||||
role: "user",
|
||||
content: "regex prompt ".repeat(2_000),
|
||||
},
|
||||
];
|
||||
|
||||
const result = await prepareTurn?.({
|
||||
agentId: "agent-1",
|
||||
conversationId: "conv-1",
|
||||
parentAgentId: null,
|
||||
iteration: 1,
|
||||
abortSignal: new AbortController().signal,
|
||||
systemPrompt: "You are helpful.",
|
||||
tools: [],
|
||||
messages,
|
||||
apiMessages: messages,
|
||||
model: {
|
||||
id: "minimax/minimax-m3",
|
||||
provider: "openrouter",
|
||||
info: {
|
||||
id: "minimax/minimax-m3",
|
||||
contextWindow: 524_288,
|
||||
maxTokens: 512_000,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(compact).not.toHaveBeenCalled();
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it("triggers compaction from provider-sized tool result payloads", async () => {
|
||||
const compact = vi.fn((_context: CoreCompactionContext) => ({
|
||||
messages: [
|
||||
|
||||
@@ -69,6 +69,9 @@ export interface ContextCompactionPrepareTurnOptions {
|
||||
manualTargetRatio?: number;
|
||||
}
|
||||
|
||||
const MIN_CONTEXT_DERIVED_INPUT_RATIO = 0.5;
|
||||
const LONG_CONVERSATION_TARGET_RATIO = 0.5;
|
||||
|
||||
function safeJsonSize(value: unknown): number {
|
||||
try {
|
||||
return JSON.stringify(value).length;
|
||||
@@ -96,11 +99,15 @@ function resolveMaxInputTokens(input: {
|
||||
}
|
||||
if (isPositiveFiniteNumber(input.contextWindow)) {
|
||||
candidates.push(input.contextWindow);
|
||||
const derivedInputTokens = isPositiveFiniteNumber(input.modelMaxTokens)
|
||||
? input.contextWindow - input.modelMaxTokens
|
||||
: undefined;
|
||||
if (
|
||||
isPositiveFiniteNumber(input.modelMaxTokens) &&
|
||||
input.modelMaxTokens < input.contextWindow
|
||||
isPositiveFiniteNumber(derivedInputTokens) &&
|
||||
derivedInputTokens >=
|
||||
input.contextWindow * MIN_CONTEXT_DERIVED_INPUT_RATIO
|
||||
) {
|
||||
candidates.push(input.contextWindow - input.modelMaxTokens);
|
||||
candidates.push(derivedInputTokens);
|
||||
}
|
||||
}
|
||||
return candidates.length > 0
|
||||
@@ -245,22 +252,38 @@ function resolveBasicTargetTokens(input: {
|
||||
maxInputTokens: number;
|
||||
modelMaxTokens?: number;
|
||||
triggerTokens: number;
|
||||
messagePairCount: number;
|
||||
}): number {
|
||||
const targetBaseTokens =
|
||||
const targetTokens =
|
||||
input.messagePairCount >= 5 &&
|
||||
typeof input.modelMaxTokens === "number" &&
|
||||
Number.isFinite(input.modelMaxTokens) &&
|
||||
input.modelMaxTokens < input.maxInputTokens
|
||||
? input.maxInputTokens - input.modelMaxTokens
|
||||
: input.triggerTokens;
|
||||
? Math.floor(input.maxInputTokens * LONG_CONVERSATION_TARGET_RATIO)
|
||||
: Math.floor(input.triggerTokens * DEFAULT_TARGET_RATIO);
|
||||
const triggerCeiling = Math.max(1, input.triggerTokens - 1);
|
||||
return Math.max(
|
||||
1,
|
||||
Math.min(
|
||||
Math.floor(targetBaseTokens * DEFAULT_TARGET_RATIO),
|
||||
input.maxInputTokens,
|
||||
),
|
||||
Math.min(targetTokens, input.maxInputTokens, triggerCeiling),
|
||||
);
|
||||
}
|
||||
|
||||
function countUserAssistantPairs(
|
||||
messages: CoreCompactionContext["messages"],
|
||||
): number {
|
||||
let pairs = 0;
|
||||
let hasPendingUser = false;
|
||||
for (const message of messages) {
|
||||
if (message.role === "user") {
|
||||
hasPendingUser = true;
|
||||
} else if (message.role === "assistant" && hasPendingUser) {
|
||||
pairs += 1;
|
||||
hasPendingUser = false;
|
||||
}
|
||||
}
|
||||
return pairs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the `prepareTurn` callback used by the agent runtime to compact the
|
||||
* transcript before each model request.
|
||||
@@ -351,37 +374,38 @@ export function createContextCompactionPrepareTurn(
|
||||
if (mode === "auto" && !triggerState.shouldCompact) {
|
||||
return undefined;
|
||||
}
|
||||
const targetState =
|
||||
mode === "manual"
|
||||
? resolveManualTargetState({
|
||||
inputTokens,
|
||||
maxInputTokens,
|
||||
const targetState =
|
||||
mode === "manual"
|
||||
? resolveManualTargetState({
|
||||
inputTokens,
|
||||
maxInputTokens,
|
||||
autoTriggerTokens: triggerState.triggerTokens,
|
||||
manualTargetRatio: options.manualTargetRatio,
|
||||
})
|
||||
: triggerState;
|
||||
const targetTokens =
|
||||
mode === "auto"
|
||||
? resolveBasicTargetTokens({
|
||||
maxInputTokens,
|
||||
modelMaxTokens: context.model.info?.maxTokens,
|
||||
triggerTokens: targetState.triggerTokens,
|
||||
})
|
||||
: undefined;
|
||||
: triggerState;
|
||||
const targetTokens =
|
||||
mode === "auto"
|
||||
? resolveBasicTargetTokens({
|
||||
maxInputTokens,
|
||||
modelMaxTokens: context.model.info?.maxTokens,
|
||||
triggerTokens: targetState.triggerTokens,
|
||||
messagePairCount: countUserAssistantPairs(context.messages),
|
||||
})
|
||||
: undefined;
|
||||
|
||||
const compactionContext = {
|
||||
agentId: context.agentId,
|
||||
conversationId: context.conversationId,
|
||||
const compactionContext = {
|
||||
agentId: context.agentId,
|
||||
conversationId: context.conversationId,
|
||||
parentAgentId: context.parentAgentId,
|
||||
iteration: context.iteration,
|
||||
messages: context.messages,
|
||||
model: context.model,
|
||||
maxInputTokens,
|
||||
triggerTokens: targetState.triggerTokens,
|
||||
targetTokens,
|
||||
thresholdRatio: targetState.thresholdRatio,
|
||||
utilizationRatio: maxInputTokens > 0 ? inputTokens / maxInputTokens : 0,
|
||||
};
|
||||
model: context.model,
|
||||
maxInputTokens,
|
||||
triggerTokens: targetState.triggerTokens,
|
||||
targetTokens,
|
||||
thresholdRatio: targetState.thresholdRatio,
|
||||
utilizationRatio: maxInputTokens > 0 ? inputTokens / maxInputTokens : 0,
|
||||
};
|
||||
|
||||
const statusReason =
|
||||
mode === "manual" ? "manual_compaction" : "auto_compaction";
|
||||
|
||||
@@ -1,4 +1,13 @@
|
||||
import { existsSync, mkdirSync, readdirSync, renameSync, rmdirSync, statSync, unlinkSync, writeFileSync } from "node:fs";
|
||||
import {
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
readdirSync,
|
||||
renameSync,
|
||||
rmdirSync,
|
||||
statSync,
|
||||
unlinkSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
@@ -413,8 +422,18 @@ describe("mcp config loader", () => {
|
||||
JSON.stringify(
|
||||
{
|
||||
mcpServers: {
|
||||
linear: { transport: { type: "streamableHttp", url: "https://linear.example.com" } },
|
||||
github: { transport: { type: "streamableHttp", url: "https://github.example.com" } },
|
||||
linear: {
|
||||
transport: {
|
||||
type: "streamableHttp",
|
||||
url: "https://linear.example.com",
|
||||
},
|
||||
},
|
||||
github: {
|
||||
transport: {
|
||||
type: "streamableHttp",
|
||||
url: "https://github.example.com",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
null,
|
||||
@@ -423,16 +442,28 @@ describe("mcp config loader", () => {
|
||||
"utf8",
|
||||
);
|
||||
|
||||
updateMcpServerOAuthState("linear", () => ({ tokens: { access_token: "linear-token" } }), {
|
||||
filePath,
|
||||
});
|
||||
updateMcpServerOAuthState("github", () => ({ tokens: { access_token: "github-token" } }), {
|
||||
filePath,
|
||||
});
|
||||
updateMcpServerOAuthState(
|
||||
"linear",
|
||||
() => ({ tokens: { access_token: "linear-token" } }),
|
||||
{
|
||||
filePath,
|
||||
},
|
||||
);
|
||||
updateMcpServerOAuthState(
|
||||
"github",
|
||||
() => ({ tokens: { access_token: "github-token" } }),
|
||||
{
|
||||
filePath,
|
||||
},
|
||||
);
|
||||
|
||||
const written = JSON.parse(await readFile(filePath, "utf8"));
|
||||
expect(written.mcpServers.linear.oauth?.tokens?.access_token).toBe("linear-token");
|
||||
expect(written.mcpServers.github.oauth?.tokens?.access_token).toBe("github-token");
|
||||
expect(written.mcpServers.linear.oauth?.tokens?.access_token).toBe(
|
||||
"linear-token",
|
||||
);
|
||||
expect(written.mcpServers.github.oauth?.tokens?.access_token).toBe(
|
||||
"github-token",
|
||||
);
|
||||
// Lockfile is released after each critical section.
|
||||
expect(existsSync(`${filePath}.lock`)).toBe(false);
|
||||
});
|
||||
@@ -441,7 +472,11 @@ describe("mcp config loader", () => {
|
||||
const tempRoot = await mkdtemp(join(tmpdir(), "core-mcp-config-loader-"));
|
||||
tempRoots.push(tempRoot);
|
||||
const filePath = join(tempRoot, "cline_mcp_settings.json");
|
||||
await writeFile(filePath, JSON.stringify({ mcpServers: {} }, null, 2), "utf8");
|
||||
await writeFile(
|
||||
filePath,
|
||||
JSON.stringify({ mcpServers: {} }, null, 2),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
// Simulate a crashed writer that left a lock directory behind, backdated well
|
||||
// past the 10s stale threshold.
|
||||
@@ -467,7 +502,11 @@ describe("mcp config loader", () => {
|
||||
const tempRoot = await mkdtemp(join(tmpdir(), "core-mcp-config-loader-"));
|
||||
tempRoots.push(tempRoot);
|
||||
const filePath = join(tempRoot, "cline_mcp_settings.json");
|
||||
await writeFile(filePath, JSON.stringify({ mcpServers: {} }, null, 2), "utf8");
|
||||
await writeFile(
|
||||
filePath,
|
||||
JSON.stringify({ mcpServers: {} }, null, 2),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const lockPath = `${filePath}.lock`;
|
||||
updateMcpSettingsFileSync(filePath, () => {
|
||||
@@ -479,7 +518,11 @@ describe("mcp config loader", () => {
|
||||
rmdirSync(lockPath);
|
||||
const replacement = `${lockPath}.replacement`;
|
||||
mkdirSync(replacement);
|
||||
writeFileSync(join(replacement, "owner.replacement"), "replacement-owner", { flag: "wx" });
|
||||
writeFileSync(
|
||||
join(replacement, "owner.replacement"),
|
||||
"replacement-owner",
|
||||
{ flag: "wx" },
|
||||
);
|
||||
renameSync(replacement, lockPath);
|
||||
});
|
||||
|
||||
@@ -490,7 +533,11 @@ describe("mcp config loader", () => {
|
||||
const tempRoot = await mkdtemp(join(tmpdir(), "core-mcp-config-loader-"));
|
||||
tempRoots.push(tempRoot);
|
||||
const filePath = join(tempRoot, "cline_mcp_settings.json");
|
||||
await writeFile(filePath, JSON.stringify({ mcpServers: {} }, null, 2), "utf8");
|
||||
await writeFile(
|
||||
filePath,
|
||||
JSON.stringify({ mcpServers: {} }, null, 2),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
let count = 0;
|
||||
expect(() =>
|
||||
@@ -507,16 +554,24 @@ describe("updateMcpSettingsFile (async acquisition)", () => {
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(
|
||||
tempRoots.map((directory) => rm(directory, { recursive: true, force: true })),
|
||||
tempRoots.map((directory) =>
|
||||
rm(directory, { recursive: true, force: true }),
|
||||
),
|
||||
);
|
||||
tempRoots.length = 0;
|
||||
});
|
||||
|
||||
async function makeSettingsFile(): Promise<string> {
|
||||
const tempRoot = await mkdtemp(join(tmpdir(), "core-mcp-config-loader-async-"));
|
||||
const tempRoot = await mkdtemp(
|
||||
join(tmpdir(), "core-mcp-config-loader-async-"),
|
||||
);
|
||||
tempRoots.push(tempRoot);
|
||||
const filePath = join(tempRoot, "cline_mcp_settings.json");
|
||||
await writeFile(filePath, JSON.stringify({ mcpServers: {} }, null, 2), "utf8");
|
||||
await writeFile(
|
||||
filePath,
|
||||
JSON.stringify({ mcpServers: {} }, null, 2),
|
||||
"utf8",
|
||||
);
|
||||
return filePath;
|
||||
}
|
||||
|
||||
@@ -526,7 +581,9 @@ describe("updateMcpSettingsFile (async acquisition)", () => {
|
||||
|
||||
const result = await updateMcpSettingsFile(filePath, (settings) => {
|
||||
mutatorRan = true;
|
||||
settings.mcpServers = { alpha: { transport: { type: "stdio", command: "node" } } };
|
||||
settings.mcpServers = {
|
||||
alpha: { transport: { type: "stdio", command: "node" } },
|
||||
};
|
||||
return "ok";
|
||||
});
|
||||
|
||||
@@ -554,7 +611,10 @@ describe("updateMcpSettingsFile (async acquisition)", () => {
|
||||
const linear = updateMcpSettingsFile(
|
||||
filePath,
|
||||
(settings) => {
|
||||
const servers = settings.mcpServers as Record<string, Record<string, unknown>>;
|
||||
const servers = settings.mcpServers as Record<
|
||||
string,
|
||||
Record<string, unknown>
|
||||
>;
|
||||
servers.linear.oauth = { tokens: { access_token: "linear-token" } };
|
||||
},
|
||||
{ timeoutMs: 5_000 },
|
||||
@@ -562,7 +622,10 @@ describe("updateMcpSettingsFile (async acquisition)", () => {
|
||||
const github = updateMcpSettingsFile(
|
||||
filePath,
|
||||
(settings) => {
|
||||
const servers = settings.mcpServers as Record<string, Record<string, unknown>>;
|
||||
const servers = settings.mcpServers as Record<
|
||||
string,
|
||||
Record<string, unknown>
|
||||
>;
|
||||
servers.github.oauth = { tokens: { access_token: "github-token" } };
|
||||
},
|
||||
{ timeoutMs: 5_000 },
|
||||
@@ -575,8 +638,18 @@ describe("updateMcpSettingsFile (async acquisition)", () => {
|
||||
JSON.stringify(
|
||||
{
|
||||
mcpServers: {
|
||||
linear: { transport: { type: "streamableHttp", url: "https://linear.example.com" } },
|
||||
github: { transport: { type: "streamableHttp", url: "https://github.example.com" } },
|
||||
linear: {
|
||||
transport: {
|
||||
type: "streamableHttp",
|
||||
url: "https://linear.example.com",
|
||||
},
|
||||
},
|
||||
github: {
|
||||
transport: {
|
||||
type: "streamableHttp",
|
||||
url: "https://github.example.com",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
null,
|
||||
@@ -590,8 +663,12 @@ describe("updateMcpSettingsFile (async acquisition)", () => {
|
||||
await Promise.all([linear, github]);
|
||||
|
||||
const written = JSON.parse(await readFile(filePath, "utf8"));
|
||||
expect(written.mcpServers.linear.oauth?.tokens?.access_token).toBe("linear-token");
|
||||
expect(written.mcpServers.github.oauth?.tokens?.access_token).toBe("github-token");
|
||||
expect(written.mcpServers.linear.oauth?.tokens?.access_token).toBe(
|
||||
"linear-token",
|
||||
);
|
||||
expect(written.mcpServers.github.oauth?.tokens?.access_token).toBe(
|
||||
"github-token",
|
||||
);
|
||||
// Lock released after each critical section.
|
||||
expect(existsSync(lockDir)).toBe(false);
|
||||
// The whole point of the async path: it must never freeze the loop.
|
||||
@@ -602,7 +679,9 @@ describe("updateMcpSettingsFile (async acquisition)", () => {
|
||||
});
|
||||
|
||||
it("creates a missing settings file inside the lock", async () => {
|
||||
const tempRoot = await mkdtemp(join(tmpdir(), "core-mcp-config-loader-async-"));
|
||||
const tempRoot = await mkdtemp(
|
||||
join(tmpdir(), "core-mcp-config-loader-async-"),
|
||||
);
|
||||
tempRoots.push(tempRoot);
|
||||
const filePath = join(tempRoot, "cline_mcp_settings.json");
|
||||
|
||||
|
||||
@@ -309,12 +309,18 @@ interface AcquiredSettingsLock {
|
||||
* operations and avoids inode- or handle-based deletion, so it works with
|
||||
* Node's portable fs APIs on Windows and POSIX.
|
||||
*/
|
||||
function tryAcquireSettingsLock(lockDir: string, token: string): AcquiredSettingsLock | undefined {
|
||||
function tryAcquireSettingsLock(
|
||||
lockDir: string,
|
||||
token: string,
|
||||
): AcquiredSettingsLock | undefined {
|
||||
mkdirSync(dirname(lockDir), { recursive: true });
|
||||
const stagingDir = `${lockDir}.tmp.${token}`;
|
||||
rmSync(stagingDir, { recursive: true, force: true });
|
||||
mkdirSync(stagingDir, { recursive: true });
|
||||
writeFileSync(join(stagingDir, `owner.${token}`), token, { encoding: "utf8", flag: "wx" });
|
||||
writeFileSync(join(stagingDir, `owner.${token}`), token, {
|
||||
encoding: "utf8",
|
||||
flag: "wx",
|
||||
});
|
||||
try {
|
||||
renameSync(stagingDir, lockDir);
|
||||
return { lockDir, ownerFile: join(lockDir, `owner.${token}`) };
|
||||
@@ -327,7 +333,10 @@ function tryAcquireSettingsLock(lockDir: string, token: string): AcquiredSetting
|
||||
}
|
||||
}
|
||||
|
||||
function reclaimStaleLock(lockDir: string, options: McpSettingsLockOptions): void {
|
||||
function reclaimStaleLock(
|
||||
lockDir: string,
|
||||
options: McpSettingsLockOptions,
|
||||
): void {
|
||||
let ageMs: number;
|
||||
try {
|
||||
ageMs = Date.now() - statSync(lockDir).mtimeMs;
|
||||
@@ -340,9 +349,12 @@ function reclaimStaleLock(lockDir: string, options: McpSettingsLockOptions): voi
|
||||
if (ageMs < SETTINGS_LOCK_STALE_MS) {
|
||||
return;
|
||||
}
|
||||
options.logger?.log(`[mcp-settings] Stale lock directory at ${lockDir} (age ${ageMs}ms); reclaiming.`, {
|
||||
severity: "warn",
|
||||
});
|
||||
options.logger?.log(
|
||||
`[mcp-settings] Stale lock directory at ${lockDir} (age ${ageMs}ms); reclaiming.`,
|
||||
{
|
||||
severity: "warn",
|
||||
},
|
||||
);
|
||||
const staleDir = `${lockDir}.stale.${makeLockToken()}`;
|
||||
try {
|
||||
renameSync(lockDir, staleDir);
|
||||
@@ -383,7 +395,10 @@ function beginAcquire(filePath: string): { lockDir: string; token: string } {
|
||||
return { lockDir, token: makeLockToken() };
|
||||
}
|
||||
|
||||
function acquireSettingsLockSync(filePath: string, options: McpSettingsLockOptions): AcquiredSettingsLock {
|
||||
function acquireSettingsLockSync(
|
||||
filePath: string,
|
||||
options: McpSettingsLockOptions,
|
||||
): AcquiredSettingsLock {
|
||||
const { lockDir, token } = beginAcquire(filePath);
|
||||
const timeoutMs = options.timeoutMs ?? SETTINGS_LOCK_STALE_MS;
|
||||
const startedAt = Date.now();
|
||||
@@ -408,7 +423,10 @@ function acquireSettingsLockSync(filePath: string, options: McpSettingsLockOptio
|
||||
* attempts. Reclaims a stale lock left by a crashed holder and throws
|
||||
* McpSettingsLockTimeoutError once `timeoutMs` elapses.
|
||||
*/
|
||||
async function acquireSettingsLockAsync(filePath: string, options: McpSettingsLockOptions): Promise<AcquiredSettingsLock> {
|
||||
async function acquireSettingsLockAsync(
|
||||
filePath: string,
|
||||
options: McpSettingsLockOptions,
|
||||
): Promise<AcquiredSettingsLock> {
|
||||
const { lockDir, token } = beginAcquire(filePath);
|
||||
const timeoutMs = options.timeoutMs ?? SETTINGS_LOCK_STALE_MS;
|
||||
const startedAt = Date.now();
|
||||
@@ -433,7 +451,11 @@ async function acquireSettingsLockAsync(filePath: string, options: McpSettingsLo
|
||||
* never yields, so a concurrent waiter cannot interleave between the read and
|
||||
* the write, and the lock is released the moment the mutation completes.
|
||||
*/
|
||||
function runLockedSettingsMutation<T>(lock: AcquiredSettingsLock, filePath: string, mutator: McpSettingsMutator<T>): T {
|
||||
function runLockedSettingsMutation<T>(
|
||||
lock: AcquiredSettingsLock,
|
||||
filePath: string,
|
||||
mutator: McpSettingsMutator<T>,
|
||||
): T {
|
||||
try {
|
||||
const settings = loadRawSettingsObject(filePath);
|
||||
const result = runPureSettingsMutator(settings, mutator);
|
||||
@@ -495,13 +517,20 @@ export async function updateMcpSettingsFile<T>(
|
||||
*/
|
||||
function loadRawSettingsObject(filePath: string): Record<string, unknown> {
|
||||
const settings = readJsonObjectOrEmpty(filePath);
|
||||
if (!settings.mcpServers || typeof settings.mcpServers !== "object" || Array.isArray(settings.mcpServers)) {
|
||||
if (
|
||||
!settings.mcpServers ||
|
||||
typeof settings.mcpServers !== "object" ||
|
||||
Array.isArray(settings.mcpServers)
|
||||
) {
|
||||
settings.mcpServers = {};
|
||||
}
|
||||
return settings;
|
||||
}
|
||||
|
||||
function runPureSettingsMutator<T>(settings: Record<string, unknown>, mutator: McpSettingsMutator<T>): T {
|
||||
function runPureSettingsMutator<T>(
|
||||
settings: Record<string, unknown>,
|
||||
mutator: McpSettingsMutator<T>,
|
||||
): T {
|
||||
const before = JSON.stringify(settings);
|
||||
const shadow = JSON.parse(before) as Record<string, unknown>;
|
||||
const shadowResult = mutator(shadow);
|
||||
@@ -742,7 +771,10 @@ export function updateMcpServerOAuthState(
|
||||
options: LoadMcpSettingsOptions = {},
|
||||
): McpServerOAuthState {
|
||||
const filePath = options.filePath ?? resolveDefaultMcpSettingsPath();
|
||||
return updateMcpSettingsFileSync(filePath, buildOAuthStateMutator(serverName, updater));
|
||||
return updateMcpSettingsFileSync(
|
||||
filePath,
|
||||
buildOAuthStateMutator(serverName, updater),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -756,7 +788,10 @@ export async function updateMcpServerOAuthStateAsync(
|
||||
options: LoadMcpSettingsOptions = {},
|
||||
): Promise<McpServerOAuthState> {
|
||||
const filePath = options.filePath ?? resolveDefaultMcpSettingsPath();
|
||||
return updateMcpSettingsFile(filePath, buildOAuthStateMutator(serverName, updater));
|
||||
return updateMcpSettingsFile(
|
||||
filePath,
|
||||
buildOAuthStateMutator(serverName, updater),
|
||||
);
|
||||
}
|
||||
|
||||
export function listMcpServerOAuthStatuses(
|
||||
|
||||
@@ -16,6 +16,19 @@ import { RUN_COMMAND_QUERY_PREVIEW_LIMIT, TimeoutError } from "./helpers";
|
||||
import { INPUT_ARG_CHAR_LIMIT } from "./schemas";
|
||||
import type { SkillsExecutorWithMetadata } from "./types";
|
||||
|
||||
function hasSchemaKey(value: unknown, key: string): boolean {
|
||||
if (Array.isArray(value)) {
|
||||
return value.some((item) => hasSchemaKey(item, key));
|
||||
}
|
||||
if (value && typeof value === "object") {
|
||||
return Object.entries(value).some(
|
||||
([entryKey, entryValue]) =>
|
||||
entryKey === key || hasSchemaKey(entryValue, key),
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function createMockSkillsExecutor(
|
||||
fn: (...args: unknown[]) => Promise<string> = async () => "ok",
|
||||
configuredSkills?: SkillsExecutorWithMetadata["configuredSkills"],
|
||||
@@ -610,6 +623,62 @@ describe("default run_commands tool", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("accepts mixed structured and string command arrays", async () => {
|
||||
const execute = vi.fn(
|
||||
async (command: string | { command: string; args?: string[] }) =>
|
||||
typeof command === "string"
|
||||
? `ran:${command}`
|
||||
: `ran:${command.command}:${(command.args ?? []).join(",")}`,
|
||||
);
|
||||
const tool = createShellTool(execute);
|
||||
|
||||
const result = await tool.execute(
|
||||
{
|
||||
commands: ["pwd", { command: "node", args: ["--version"] }],
|
||||
} as never,
|
||||
{
|
||||
agentId: "agent-1",
|
||||
conversationId: "conv-1",
|
||||
iteration: 1,
|
||||
},
|
||||
);
|
||||
|
||||
expect(result).toEqual([
|
||||
{ query: "pwd", result: "ran:pwd", success: true },
|
||||
{
|
||||
query: "node --version",
|
||||
result: "ran:node:--version",
|
||||
success: true,
|
||||
},
|
||||
]);
|
||||
expect(execute).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
"pwd",
|
||||
process.cwd(),
|
||||
expect.objectContaining({ iteration: 1 }),
|
||||
);
|
||||
expect(execute).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
{ command: "node", args: ["--version"] },
|
||||
process.cwd(),
|
||||
expect.objectContaining({ iteration: 1 }),
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects invalid text-object command entries", async () => {
|
||||
const execute = vi.fn(async () => "ran");
|
||||
const tool = createShellTool(execute);
|
||||
|
||||
await expect(
|
||||
tool.execute({ commands: [{ $text: "pwd" }] } as never, {
|
||||
agentId: "agent-1",
|
||||
conversationId: "conv-1",
|
||||
iteration: 1,
|
||||
}),
|
||||
).rejects.toThrow("Invalid input");
|
||||
expect(execute).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("preserves args on direct structured command objects", async () => {
|
||||
const execute = vi.fn(
|
||||
async (command: string | { command: string; args?: string[] }) =>
|
||||
@@ -1495,6 +1564,22 @@ describe("default read_files tool", () => {
|
||||
});
|
||||
|
||||
describe("zod schema conversion", () => {
|
||||
it("advertises run_commands as string-only command arrays", () => {
|
||||
const tool = createShellTool(async () => "ok");
|
||||
const inputSchema = tool.inputSchema as Record<string, unknown>;
|
||||
const serialized = JSON.stringify(inputSchema);
|
||||
|
||||
expect(serialized).not.toContain('"anyOf"');
|
||||
expect(serialized).not.toContain("Prefer structured");
|
||||
expect(hasSchemaKey(inputSchema, "command")).toBe(false);
|
||||
|
||||
const properties = inputSchema.properties as Record<string, unknown>;
|
||||
const commands = properties.commands as {
|
||||
items?: { type?: string };
|
||||
};
|
||||
expect(commands.items?.type).toBe("string");
|
||||
});
|
||||
|
||||
it("preserves read_files required properties in generated JSON schema", () => {
|
||||
const tool = createReadFilesTool(async () => "ok");
|
||||
const inputSchema = tool.inputSchema as Record<string, unknown>;
|
||||
|
||||
@@ -49,8 +49,8 @@ import {
|
||||
SearchCodebaseInputSchema,
|
||||
SearchCodebaseUnionInputSchema,
|
||||
type SkillsInput,
|
||||
type StructuredCommandInput,
|
||||
SkillsInputSchema,
|
||||
type StructuredCommandInput,
|
||||
type SubmitInput,
|
||||
SubmitInputSchema,
|
||||
} from "./schemas";
|
||||
@@ -415,7 +415,7 @@ export function createShellTool(
|
||||
? "Run non-interactive shell commands from the root of the workspace in Windows environment. " +
|
||||
RUN_COMMANDS_SHARED_INSTRUCTIONS +
|
||||
`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 in the same call when they are independent and safe to run concurrently. When independent reads, searches, or edits are also needed, call those tools in the same response."
|
||||
"Commands run through PowerShell; quote paths and arguments for PowerShell and use ';' to sequence commands. Include multiple commands in the same call when they are independent and safe to run concurrently. When independent reads, searches, or edits are also needed, call those tools in the same response."
|
||||
: "Run non-interactive shell commands from the root of the workspace. " +
|
||||
RUN_COMMANDS_SHARED_INSTRUCTIONS +
|
||||
"Commands should be properly shell-escaped and targeted to avoid error or timeout. Include multiple commands in the same call when they are independent complete shell commands and safe to run concurrently; multiline scripts and heredocs must be a single command string. When independent reads, searches, or edits are also needed, call those tools in the same response. " +
|
||||
|
||||
@@ -120,19 +120,14 @@ export const StructuredCommandEntrySchema = z.union([
|
||||
StructuredCommandInputSchema,
|
||||
]);
|
||||
|
||||
/**
|
||||
* Schema for run_commands tool input.
|
||||
*
|
||||
* Supports both shell strings and direct structured `{ command, args }` entries
|
||||
* on every platform. Plain strings are interpreted by the active shell;
|
||||
* structured entries bypass shell parsing and execute the command directly.
|
||||
*/
|
||||
export const RunCommandsInputSchema = z.object({
|
||||
commands: z
|
||||
.array(StructuredCommandEntrySchema)
|
||||
.describe(
|
||||
"Array of commands to execute. Prefer structured { command, args } entries for portability; plain strings are still supported and are interpreted by the active shell.",
|
||||
),
|
||||
.array(CommandInputSchema)
|
||||
.describe("Array of complete shell command strings to execute."),
|
||||
});
|
||||
|
||||
const StructuredCommandsInputSchema = z.object({
|
||||
commands: z.array(StructuredCommandEntrySchema),
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -140,6 +135,7 @@ export const RunCommandsInputSchema = z.object({
|
||||
*/
|
||||
export const RunCommandsInputUnionSchema = z.union([
|
||||
RunCommandsInputSchema,
|
||||
StructuredCommandsInputSchema,
|
||||
z.object({ commands: StructuredCommandEntrySchema }),
|
||||
z.array(StructuredCommandInputSchema),
|
||||
StructuredCommandInputSchema,
|
||||
|
||||
@@ -481,16 +481,19 @@ export {
|
||||
isPluginDisabledGlobally,
|
||||
isTelemetryOptedOutGlobally,
|
||||
isToolDisabledGlobally,
|
||||
readCompactionStrategyGlobally,
|
||||
readGlobalSettings,
|
||||
resolveDisabledPluginPaths,
|
||||
resolveDisabledToolNames,
|
||||
setAutoUpdateEnabledGlobally,
|
||||
setCompactionStrategyGlobally,
|
||||
setDisabledPlugin,
|
||||
setDisabledTools,
|
||||
setTelemetryOptOutGlobally,
|
||||
toggleDisabledTool,
|
||||
writeGlobalSettings,
|
||||
} from "./services/global-settings";
|
||||
export type { GlobalCompactionStrategy } from "./services/global-settings";
|
||||
export type {
|
||||
McpInstallOptions,
|
||||
McpInstallResult,
|
||||
|
||||
@@ -5,8 +5,10 @@ import type { ITelemetryService } from "@cline/shared";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
GlobalSettingsSchema,
|
||||
readCompactionStrategyGlobally,
|
||||
readGlobalSettings,
|
||||
setAutoUpdateEnabledGlobally,
|
||||
setCompactionStrategyGlobally,
|
||||
setDisabledPlugin,
|
||||
setDisabledTools,
|
||||
setTelemetryOptOutGlobally,
|
||||
@@ -44,11 +46,13 @@ describe("global-settings", () => {
|
||||
});
|
||||
expect(
|
||||
GlobalSettingsSchema.parse({
|
||||
compactionStrategy: "agentic",
|
||||
disabledTools: ["read_files"],
|
||||
extra: true,
|
||||
}),
|
||||
).toEqual({
|
||||
autoUpdateEnabled: true,
|
||||
compactionStrategy: "agentic",
|
||||
disabledTools: ["read_files"],
|
||||
telemetryOptOut: false,
|
||||
});
|
||||
@@ -199,6 +203,20 @@ describe("global-settings", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("reads and writes the compaction strategy globally", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "core-global-settings-"));
|
||||
try {
|
||||
const settingsPath = join(root, "global-settings.json");
|
||||
process.env.CLINE_GLOBAL_SETTINGS_PATH = settingsPath;
|
||||
|
||||
expect(readCompactionStrategyGlobally()).toBe("basic");
|
||||
setCompactionStrategyGlobally("agentic");
|
||||
expect(readCompactionStrategyGlobally()).toBe("agentic");
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
describe("caching", () => {
|
||||
it("invalidates the cache when writeGlobalSettings is called", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "core-global-settings-"));
|
||||
|
||||
@@ -29,10 +29,19 @@ const GlobalSettingsStringListSchema = z
|
||||
return normalized.length > 0 ? normalized : undefined;
|
||||
});
|
||||
|
||||
const GlobalCompactionStrategySchema = z
|
||||
.enum(["basic", "agentic"])
|
||||
.catch("basic");
|
||||
|
||||
export type GlobalCompactionStrategy = z.infer<
|
||||
typeof GlobalCompactionStrategySchema
|
||||
>;
|
||||
|
||||
export const GlobalSettingsSchema = z
|
||||
.object({
|
||||
telemetryOptOut: z.boolean().default(false).catch(false),
|
||||
autoUpdateEnabled: z.boolean().default(true).catch(true),
|
||||
compactionStrategy: GlobalCompactionStrategySchema.optional(),
|
||||
disabledTools: GlobalSettingsStringListSchema.optional(),
|
||||
disabledPlugins: GlobalSettingsStringListSchema.optional(),
|
||||
})
|
||||
@@ -41,12 +50,16 @@ export const GlobalSettingsSchema = z
|
||||
const normalized: {
|
||||
telemetryOptOut: boolean;
|
||||
autoUpdateEnabled: boolean;
|
||||
compactionStrategy?: GlobalCompactionStrategy;
|
||||
disabledTools?: string[];
|
||||
disabledPlugins?: string[];
|
||||
} = {
|
||||
autoUpdateEnabled: settings.autoUpdateEnabled,
|
||||
telemetryOptOut: settings.telemetryOptOut,
|
||||
};
|
||||
if (settings.compactionStrategy) {
|
||||
normalized.compactionStrategy = settings.compactionStrategy;
|
||||
}
|
||||
if (settings.disabledTools?.length) {
|
||||
normalized.disabledTools = settings.disabledTools;
|
||||
}
|
||||
@@ -180,6 +193,16 @@ export function setAutoUpdateEnabledGlobally(
|
||||
);
|
||||
}
|
||||
|
||||
export function readCompactionStrategyGlobally(): GlobalCompactionStrategy {
|
||||
return readGlobalSettings().compactionStrategy ?? "basic";
|
||||
}
|
||||
|
||||
export function setCompactionStrategyGlobally(
|
||||
compactionStrategy: GlobalCompactionStrategy,
|
||||
): void {
|
||||
writeGlobalSettings({ ...readGlobalSettings(), compactionStrategy });
|
||||
}
|
||||
|
||||
export function resolveDisabledToolNames(
|
||||
disabledToolNames?: ReadonlyArray<string>,
|
||||
): Set<string> {
|
||||
|
||||
@@ -64,35 +64,35 @@ const baseRequest: AgentModelRequest = {
|
||||
|
||||
describe("createAgentModelFromApiHandler", () => {
|
||||
it("maps text + usage chunks to events and appends a finish", async () => {
|
||||
const handler = fakeHandler([
|
||||
{ type: "text", text: "hello", id: "x" },
|
||||
{
|
||||
type: "usage",
|
||||
inputTokens: 10,
|
||||
outputTokens: 5,
|
||||
thoughtsTokenCount: 2,
|
||||
totalCost: 0.01,
|
||||
id: "x",
|
||||
},
|
||||
]);
|
||||
const handler = fakeHandler([
|
||||
{ type: "text", text: "hello", id: "x" },
|
||||
{
|
||||
type: "usage",
|
||||
inputTokens: 10,
|
||||
outputTokens: 5,
|
||||
thoughtsTokenCount: 2,
|
||||
totalCost: 0.01,
|
||||
id: "x",
|
||||
},
|
||||
]);
|
||||
const model = createAgentModelFromApiHandler(handler);
|
||||
const events = await collect(model.stream(baseRequest));
|
||||
|
||||
expect(events).toEqual([
|
||||
{ type: "text-delta", text: "hello" },
|
||||
{
|
||||
type: "usage",
|
||||
usage: {
|
||||
inputTokens: 10,
|
||||
outputTokens: 5,
|
||||
cacheReadTokens: undefined,
|
||||
cacheWriteTokens: undefined,
|
||||
reasoningTokenCount: 2,
|
||||
totalCost: 0.01,
|
||||
},
|
||||
{
|
||||
type: "usage",
|
||||
usage: {
|
||||
inputTokens: 10,
|
||||
outputTokens: 5,
|
||||
cacheReadTokens: undefined,
|
||||
cacheWriteTokens: undefined,
|
||||
reasoningTokenCount: 2,
|
||||
totalCost: 0.01,
|
||||
},
|
||||
{ type: "finish", reason: "stop" },
|
||||
]);
|
||||
},
|
||||
{ type: "finish", reason: "stop" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("maps tool_calls (object args) to a tool-call-delta event", async () => {
|
||||
|
||||
@@ -168,8 +168,8 @@ describe("resolveProviderConfig", () => {
|
||||
expect(resolved?.knownModels?.["zai/glm-5.2"]).toMatchObject({
|
||||
id: "zai/glm-5.2",
|
||||
name: "GLM 5.2",
|
||||
contextWindow: 1_000_000,
|
||||
maxInputTokens: 1_000_000,
|
||||
contextWindow: 1_040_000,
|
||||
maxInputTokens: 1_040_000,
|
||||
});
|
||||
expect(resolved?.knownModels?.["z-ai/glm-5.2"]).toBeUndefined();
|
||||
});
|
||||
@@ -196,8 +196,8 @@ describe("resolveProviderConfig", () => {
|
||||
});
|
||||
expect(resolved?.knownModels?.["zai/glm-5.2"]).toMatchObject({
|
||||
id: "zai/glm-5.2",
|
||||
contextWindow: 1_000_000,
|
||||
maxInputTokens: 1_000_000,
|
||||
contextWindow: 1_040_000,
|
||||
maxInputTokens: 1_040_000,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -114,14 +114,7 @@ describe("marketplace service", () => {
|
||||
expect(calls).toEqual([
|
||||
{
|
||||
command: "npx",
|
||||
args: [
|
||||
"-y",
|
||||
"skills@latest",
|
||||
"remove",
|
||||
"review-team",
|
||||
"-g",
|
||||
"-y",
|
||||
],
|
||||
args: ["-y", "skills@latest", "remove", "review-team", "-g", "-y"],
|
||||
},
|
||||
]);
|
||||
expect(existsSync(skillDir)).toBe(false);
|
||||
|
||||
@@ -2,7 +2,7 @@ import { mkdirSync, writeFileSync } from "node:fs";
|
||||
import { dirname } from "node:path";
|
||||
import type * as LlmsProviders from "@cline/llms";
|
||||
import type { AgentConfig, AgentEvent, AgentResult } from "@cline/shared";
|
||||
import { normalizeUserInput } from "@cline/shared";
|
||||
import { normalizeUserInput, stripModeNotices } from "@cline/shared";
|
||||
import { nanoid } from "nanoid";
|
||||
import {
|
||||
parseSubSessionId,
|
||||
@@ -231,7 +231,11 @@ export function normalizeTitle(title?: string | null): string | undefined {
|
||||
export function deriveTitleFromPrompt(
|
||||
prompt?: string | null,
|
||||
): string | undefined {
|
||||
const normalized = normalizeUserInput(prompt ?? "").trim();
|
||||
// Titles are display-only, so runtime-generated notice elements are
|
||||
// stripped here rather than inside normalizeUserInput -- that function also
|
||||
// sanitizes model-bound prompts (prepareTurnInput), where the notice must
|
||||
// survive to reach the model.
|
||||
const normalized = stripModeNotices(normalizeUserInput(prompt ?? "")).trim();
|
||||
if (!normalized) return undefined;
|
||||
return normalizeTitle(normalized.split("\n")[0]?.trim());
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import * as LlmsModels from "@cline/llms";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
type LegacyClineUserInfo,
|
||||
@@ -146,9 +147,13 @@ describe("migrateLegacyProviderSettings", () => {
|
||||
expect(manager.getProviderSettings("openai")?.apiKey).toBe(
|
||||
"already-migrated",
|
||||
);
|
||||
const anthropicDefault =
|
||||
LlmsModels.getProviderCollectionSync("anthropic")?.provider
|
||||
.defaultModelId;
|
||||
expect(anthropicDefault).toBeDefined();
|
||||
expect(manager.getProviderSettings("anthropic")).toEqual({
|
||||
provider: "anthropic",
|
||||
model: "claude-fable-5",
|
||||
model: anthropicDefault,
|
||||
apiKey: "legacy-key",
|
||||
});
|
||||
expect(manager.read().providers.openai?.tokenSource).toBe("manual");
|
||||
|
||||
@@ -416,6 +416,12 @@ function resolveLegacyCodexAuth(
|
||||
|
||||
function getDefaultModelForProvider(providerId: string): string | undefined {
|
||||
const builtInModels = LlmsModels.getGeneratedModelsForProvider(providerId);
|
||||
const providerCollection = LlmsModels.getProviderCollectionSync(providerId);
|
||||
const defaultModelId = providerCollection?.provider.defaultModelId;
|
||||
if (defaultModelId && builtInModels[defaultModelId]) {
|
||||
return defaultModelId;
|
||||
}
|
||||
|
||||
const firstModelId = Object.keys(builtInModels)[0];
|
||||
return firstModelId ?? undefined;
|
||||
}
|
||||
|
||||
@@ -54,24 +54,24 @@ describe("checkpoint workspace comparison", () => {
|
||||
join(dir, "tracked.txt").replaceAll("\\", "/"),
|
||||
join(dir, "untracked.txt").replaceAll("\\", "/"),
|
||||
]);
|
||||
expect(diffs.find((diff) => diff.filePath.endsWith("tracked.txt"))).toMatchObject(
|
||||
{
|
||||
leftContent: "base\n",
|
||||
rightContent: "changed\n",
|
||||
},
|
||||
);
|
||||
expect(diffs.find((diff) => diff.filePath.endsWith("deleted.txt"))).toMatchObject(
|
||||
{
|
||||
leftContent: "delete me\n",
|
||||
rightContent: "",
|
||||
},
|
||||
);
|
||||
expect(diffs.find((diff) => diff.filePath.endsWith("untracked.txt"))).toMatchObject(
|
||||
{
|
||||
leftContent: "",
|
||||
rightContent: "new file\n",
|
||||
},
|
||||
);
|
||||
expect(
|
||||
diffs.find((diff) => diff.filePath.endsWith("tracked.txt")),
|
||||
).toMatchObject({
|
||||
leftContent: "base\n",
|
||||
rightContent: "changed\n",
|
||||
});
|
||||
expect(
|
||||
diffs.find((diff) => diff.filePath.endsWith("deleted.txt")),
|
||||
).toMatchObject({
|
||||
leftContent: "delete me\n",
|
||||
rightContent: "",
|
||||
});
|
||||
expect(
|
||||
diffs.find((diff) => diff.filePath.endsWith("untracked.txt")),
|
||||
).toMatchObject({
|
||||
leftContent: "",
|
||||
rightContent: "new file\n",
|
||||
});
|
||||
});
|
||||
|
||||
it("uses the worktree snapshot stored in SDK stash checkpoints", async () => {
|
||||
|
||||
@@ -128,16 +128,19 @@ export {
|
||||
isPluginDisabledGlobally,
|
||||
isTelemetryOptedOutGlobally,
|
||||
isToolDisabledGlobally,
|
||||
readCompactionStrategyGlobally,
|
||||
readGlobalSettings,
|
||||
resolveDisabledPluginPaths,
|
||||
resolveDisabledToolNames,
|
||||
setAutoUpdateEnabledGlobally,
|
||||
setCompactionStrategyGlobally,
|
||||
setDisabledPlugin,
|
||||
setDisabledTools,
|
||||
setTelemetryOptOutGlobally,
|
||||
toggleDisabledTool,
|
||||
writeGlobalSettings,
|
||||
} from "./services/global-settings";
|
||||
export type { GlobalCompactionStrategy } from "./services/global-settings";
|
||||
export type {
|
||||
ListPluginToolsResult,
|
||||
PluginToolSummary,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@cline/llms",
|
||||
"version": "0.0.54",
|
||||
"version": "0.0.56",
|
||||
"description": "Config-driven SDK for selecting, extending, and instantiating LLM providers and models",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
||||
@@ -466,7 +466,9 @@ describe("models-dev-catalog", () => {
|
||||
);
|
||||
|
||||
expect(result["openai-native"]).toBeUndefined();
|
||||
expect(result["cline-pass"]?.["cline-pass/live-default-model"]).toMatchObject({
|
||||
expect(
|
||||
result["cline-pass"]?.["cline-pass/live-default-model"],
|
||||
).toMatchObject({
|
||||
id: "cline-pass/live-default-model",
|
||||
name: "Live Default Model",
|
||||
contextWindow: 128_000,
|
||||
|
||||
@@ -14,7 +14,7 @@ export const GENERATED_PROVIDER_MODELS: {
|
||||
version: number;
|
||||
providers: Record<string, Record<string, ModelInfo>>;
|
||||
} = {
|
||||
version: 1782923987348,
|
||||
version: 1783097653299,
|
||||
providers: {
|
||||
aihubmix: {
|
||||
"glm-5.2": {
|
||||
@@ -61,6 +61,38 @@ export const GENERATED_PROVIDER_MODELS: {
|
||||
releaseDate: "2026-06-02",
|
||||
family: "qwen",
|
||||
},
|
||||
"claude-opus-4-8": {
|
||||
id: "claude-opus-4-8",
|
||||
name: "Claude Opus 4.8",
|
||||
contextWindow: 200000,
|
||||
maxInputTokens: 200000,
|
||||
maxTokens: 32000,
|
||||
capabilities: ["images", "tools", "reasoning", "prompt-cache"],
|
||||
pricing: {
|
||||
input: 5,
|
||||
output: 25,
|
||||
cacheRead: 0.5,
|
||||
cacheWrite: 6.25,
|
||||
},
|
||||
releaseDate: "2026-05-28",
|
||||
family: "claude-opus",
|
||||
},
|
||||
"claude-opus-4-8-think": {
|
||||
id: "claude-opus-4-8-think",
|
||||
name: "Claude Opus 4.8",
|
||||
contextWindow: 200000,
|
||||
maxInputTokens: 200000,
|
||||
maxTokens: 32000,
|
||||
capabilities: ["images", "tools", "reasoning", "prompt-cache"],
|
||||
pricing: {
|
||||
input: 5,
|
||||
output: 25,
|
||||
cacheRead: 0.5,
|
||||
cacheWrite: 6.25,
|
||||
},
|
||||
releaseDate: "2026-05-28",
|
||||
family: "claude-opus",
|
||||
},
|
||||
"qwen3.7-max": {
|
||||
id: "qwen3.7-max",
|
||||
name: "Qwen3.7 Max",
|
||||
@@ -1963,13 +1995,29 @@ export const GENERATED_PROVIDER_MODELS: {
|
||||
releaseDate: "2026-06-30",
|
||||
family: "claude-sonnet",
|
||||
},
|
||||
"anthropic.claude-fable-5": {
|
||||
id: "anthropic.claude-fable-5",
|
||||
name: "Claude Fable 5",
|
||||
contextWindow: 1000000,
|
||||
maxInputTokens: 1000000,
|
||||
maxTokens: 128000,
|
||||
capabilities: ["images", "files", "tools", "reasoning", "prompt-cache"],
|
||||
pricing: {
|
||||
input: 10,
|
||||
output: 50,
|
||||
cacheRead: 1,
|
||||
cacheWrite: 12.5,
|
||||
},
|
||||
releaseDate: "2026-06-09",
|
||||
family: "claude-fable",
|
||||
},
|
||||
"eu.anthropic.claude-fable-5": {
|
||||
id: "eu.anthropic.claude-fable-5",
|
||||
name: "Claude Fable 5 (EU)",
|
||||
contextWindow: 1000000,
|
||||
maxInputTokens: 1000000,
|
||||
maxTokens: 128000,
|
||||
capabilities: ["images", "tools", "reasoning", "prompt-cache"],
|
||||
capabilities: ["images", "files", "tools", "reasoning", "prompt-cache"],
|
||||
pricing: {
|
||||
input: 11,
|
||||
output: 55,
|
||||
@@ -1985,7 +2033,7 @@ export const GENERATED_PROVIDER_MODELS: {
|
||||
contextWindow: 1000000,
|
||||
maxInputTokens: 1000000,
|
||||
maxTokens: 128000,
|
||||
capabilities: ["images", "tools", "reasoning", "prompt-cache"],
|
||||
capabilities: ["images", "files", "tools", "reasoning", "prompt-cache"],
|
||||
pricing: {
|
||||
input: 10,
|
||||
output: 50,
|
||||
@@ -2001,7 +2049,7 @@ export const GENERATED_PROVIDER_MODELS: {
|
||||
contextWindow: 1000000,
|
||||
maxInputTokens: 1000000,
|
||||
maxTokens: 128000,
|
||||
capabilities: ["images", "tools", "reasoning", "prompt-cache"],
|
||||
capabilities: ["images", "files", "tools", "reasoning", "prompt-cache"],
|
||||
pricing: {
|
||||
input: 10,
|
||||
output: 50,
|
||||
@@ -3858,6 +3906,28 @@ export const GENERATED_PROVIDER_MODELS: {
|
||||
},
|
||||
},
|
||||
cerebras: {
|
||||
"gemma-4-31b": {
|
||||
id: "gemma-4-31b",
|
||||
name: "Gemma 4 31B IT",
|
||||
contextWindow: 131072,
|
||||
maxInputTokens: 131072,
|
||||
maxTokens: 40960,
|
||||
capabilities: [
|
||||
"images",
|
||||
"tools",
|
||||
"reasoning",
|
||||
"structured_output",
|
||||
"temperature",
|
||||
],
|
||||
pricing: {
|
||||
input: 0.99,
|
||||
output: 1.49,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
},
|
||||
releaseDate: "2026-04-02",
|
||||
family: "gemma",
|
||||
},
|
||||
"zai-glm-4.7": {
|
||||
id: "zai-glm-4.7",
|
||||
name: "Z.AI GLM-4.7",
|
||||
@@ -4068,9 +4138,9 @@ export const GENERATED_PROVIDER_MODELS: {
|
||||
"cline-pass/deepseek-v4-flash": {
|
||||
name: "DeepSeek V4 Flash",
|
||||
id: "cline-pass/deepseek-v4-flash",
|
||||
contextWindow: 1048575,
|
||||
maxInputTokens: 1048575,
|
||||
maxTokens: 65536,
|
||||
contextWindow: 1048576,
|
||||
maxInputTokens: 1048576,
|
||||
maxTokens: 16384,
|
||||
capabilities: [
|
||||
"tools",
|
||||
"reasoning",
|
||||
@@ -4079,9 +4149,9 @@ export const GENERATED_PROVIDER_MODELS: {
|
||||
"prompt-cache",
|
||||
],
|
||||
pricing: {
|
||||
input: 0.098,
|
||||
output: 0.196,
|
||||
cacheRead: 0.02,
|
||||
input: 0.09,
|
||||
output: 0.18,
|
||||
cacheRead: 0.018,
|
||||
cacheWrite: 0,
|
||||
},
|
||||
releaseDate: "2026-04-24",
|
||||
@@ -4126,9 +4196,9 @@ export const GENERATED_PROVIDER_MODELS: {
|
||||
"prompt-cache",
|
||||
],
|
||||
pricing: {
|
||||
input: 0.55,
|
||||
output: 3.2,
|
||||
cacheRead: 0.11,
|
||||
input: 0.66,
|
||||
output: 3.41,
|
||||
cacheRead: 0.14,
|
||||
cacheWrite: 0,
|
||||
},
|
||||
releaseDate: "2026-04-21",
|
||||
@@ -12120,6 +12190,36 @@ export const GENERATED_PROVIDER_MODELS: {
|
||||
},
|
||||
},
|
||||
openrouter: {
|
||||
"poolside/laguna-xs-2.1": {
|
||||
id: "poolside/laguna-xs-2.1",
|
||||
name: "Laguna XS 2.1",
|
||||
contextWindow: 262144,
|
||||
maxInputTokens: 262144,
|
||||
maxTokens: 32768,
|
||||
capabilities: ["tools", "reasoning", "temperature", "prompt-cache"],
|
||||
pricing: {
|
||||
input: 0.06,
|
||||
output: 0.12,
|
||||
cacheRead: 0.03,
|
||||
cacheWrite: 0,
|
||||
},
|
||||
releaseDate: "2026-07-02",
|
||||
},
|
||||
"poolside/laguna-xs-2.1:free": {
|
||||
id: "poolside/laguna-xs-2.1:free",
|
||||
name: "Laguna XS 2.1 (free)",
|
||||
contextWindow: 262144,
|
||||
maxInputTokens: 262144,
|
||||
maxTokens: 32768,
|
||||
capabilities: ["tools", "reasoning", "temperature"],
|
||||
pricing: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
},
|
||||
releaseDate: "2026-07-02",
|
||||
},
|
||||
"anthropic/claude-sonnet-5": {
|
||||
id: "anthropic/claude-sonnet-5",
|
||||
name: "Claude Sonnet 5",
|
||||
@@ -12272,6 +12372,29 @@ export const GENERATED_PROVIDER_MODELS: {
|
||||
releaseDate: "2026-06-09",
|
||||
family: "claude-fable",
|
||||
},
|
||||
"anthropic/claude-fable-5": {
|
||||
id: "anthropic/claude-fable-5",
|
||||
name: "Claude Fable 5",
|
||||
contextWindow: 1000000,
|
||||
maxInputTokens: 1000000,
|
||||
maxTokens: 128000,
|
||||
capabilities: [
|
||||
"images",
|
||||
"files",
|
||||
"tools",
|
||||
"reasoning",
|
||||
"structured_output",
|
||||
"prompt-cache",
|
||||
],
|
||||
pricing: {
|
||||
input: 10,
|
||||
output: 50,
|
||||
cacheRead: 1,
|
||||
cacheWrite: 12.5,
|
||||
},
|
||||
releaseDate: "2026-06-09",
|
||||
family: "claude-fable",
|
||||
},
|
||||
"nvidia/nemotron-3-ultra-550b-a55b": {
|
||||
id: "nvidia/nemotron-3-ultra-550b-a55b",
|
||||
name: "Nemotron 3 Ultra 550B A55B",
|
||||
@@ -12785,9 +12908,9 @@ export const GENERATED_PROVIDER_MODELS: {
|
||||
"prompt-cache",
|
||||
],
|
||||
pricing: {
|
||||
input: 0.55,
|
||||
output: 3.2,
|
||||
cacheRead: 0.11,
|
||||
input: 0.66,
|
||||
output: 3.41,
|
||||
cacheRead: 0.14,
|
||||
cacheWrite: 0,
|
||||
},
|
||||
releaseDate: "2026-04-27",
|
||||
@@ -12888,9 +13011,9 @@ export const GENERATED_PROVIDER_MODELS: {
|
||||
"deepseek/deepseek-v4-flash": {
|
||||
id: "deepseek/deepseek-v4-flash",
|
||||
name: "DeepSeek V4 Flash",
|
||||
contextWindow: 1048575,
|
||||
maxInputTokens: 1048575,
|
||||
maxTokens: 65536,
|
||||
contextWindow: 1048576,
|
||||
maxInputTokens: 1048576,
|
||||
maxTokens: 16384,
|
||||
capabilities: [
|
||||
"tools",
|
||||
"reasoning",
|
||||
@@ -12899,9 +13022,9 @@ export const GENERATED_PROVIDER_MODELS: {
|
||||
"prompt-cache",
|
||||
],
|
||||
pricing: {
|
||||
input: 0.098,
|
||||
output: 0.196,
|
||||
cacheRead: 0.02,
|
||||
input: 0.09,
|
||||
output: 0.18,
|
||||
cacheRead: 0.018,
|
||||
cacheWrite: 0,
|
||||
},
|
||||
releaseDate: "2026-04-24",
|
||||
@@ -13007,11 +13130,12 @@ export const GENERATED_PROVIDER_MODELS: {
|
||||
"reasoning",
|
||||
"structured_output",
|
||||
"temperature",
|
||||
"prompt-cache",
|
||||
],
|
||||
pricing: {
|
||||
input: 0.285,
|
||||
output: 2.4,
|
||||
cacheRead: 0,
|
||||
cacheRead: 0.15,
|
||||
cacheWrite: 0,
|
||||
},
|
||||
releaseDate: "2026-04-22",
|
||||
@@ -13120,9 +13244,9 @@ export const GENERATED_PROVIDER_MODELS: {
|
||||
"prompt-cache",
|
||||
],
|
||||
pricing: {
|
||||
input: 0.55,
|
||||
output: 3.2,
|
||||
cacheRead: 0.11,
|
||||
input: 0.66,
|
||||
output: 3.41,
|
||||
cacheRead: 0.14,
|
||||
cacheWrite: 0,
|
||||
},
|
||||
releaseDate: "2026-04-21",
|
||||
@@ -13261,19 +13385,20 @@ export const GENERATED_PROVIDER_MODELS: {
|
||||
"z-ai/glm-5.1": {
|
||||
id: "z-ai/glm-5.1",
|
||||
name: "GLM-5.1",
|
||||
contextWindow: 65536,
|
||||
maxInputTokens: 65536,
|
||||
contextWindow: 200000,
|
||||
maxInputTokens: 200000,
|
||||
maxTokens: 128000,
|
||||
capabilities: [
|
||||
"tools",
|
||||
"reasoning",
|
||||
"structured_output",
|
||||
"temperature",
|
||||
"prompt-cache",
|
||||
],
|
||||
pricing: {
|
||||
input: 0.975,
|
||||
output: 4.3,
|
||||
cacheRead: 0,
|
||||
input: 0.966,
|
||||
output: 3.036,
|
||||
cacheRead: 0.1794,
|
||||
cacheWrite: 0,
|
||||
},
|
||||
releaseDate: "2026-04-07",
|
||||
@@ -14350,19 +14475,13 @@ export const GENERATED_PROVIDER_MODELS: {
|
||||
"minimax/minimax-m2.1": {
|
||||
id: "minimax/minimax-m2.1",
|
||||
name: "MiniMax-M2.1",
|
||||
contextWindow: 196608,
|
||||
maxInputTokens: 196608,
|
||||
maxTokens: 196608,
|
||||
capabilities: [
|
||||
"tools",
|
||||
"reasoning",
|
||||
"structured_output",
|
||||
"temperature",
|
||||
"prompt-cache",
|
||||
],
|
||||
contextWindow: 204800,
|
||||
maxInputTokens: 204800,
|
||||
maxTokens: 131072,
|
||||
capabilities: ["tools", "reasoning", "temperature", "prompt-cache"],
|
||||
pricing: {
|
||||
input: 0.29,
|
||||
output: 0.95,
|
||||
input: 0.3,
|
||||
output: 1.2,
|
||||
cacheRead: 0.03,
|
||||
cacheWrite: 0,
|
||||
},
|
||||
@@ -14859,7 +14978,7 @@ export const GENERATED_PROVIDER_MODELS: {
|
||||
name: "Kimi K2 Thinking",
|
||||
contextWindow: 262144,
|
||||
maxInputTokens: 262144,
|
||||
maxTokens: 262144,
|
||||
maxTokens: 100352,
|
||||
capabilities: [
|
||||
"tools",
|
||||
"reasoning",
|
||||
@@ -14870,7 +14989,7 @@ export const GENERATED_PROVIDER_MODELS: {
|
||||
pricing: {
|
||||
input: 0.6,
|
||||
output: 2.5,
|
||||
cacheRead: 0.6,
|
||||
cacheRead: 0.15,
|
||||
cacheWrite: 0,
|
||||
},
|
||||
releaseDate: "2025-11-06",
|
||||
@@ -14955,20 +15074,19 @@ export const GENERATED_PROVIDER_MODELS: {
|
||||
"minimax/minimax-m2": {
|
||||
id: "minimax/minimax-m2",
|
||||
name: "MiniMax-M2",
|
||||
contextWindow: 196608,
|
||||
maxInputTokens: 196608,
|
||||
maxTokens: 196608,
|
||||
contextWindow: 204800,
|
||||
maxInputTokens: 204800,
|
||||
maxTokens: 131072,
|
||||
capabilities: [
|
||||
"tools",
|
||||
"reasoning",
|
||||
"structured_output",
|
||||
"temperature",
|
||||
"prompt-cache",
|
||||
],
|
||||
pricing: {
|
||||
input: 0.255,
|
||||
output: 1,
|
||||
cacheRead: 0.03,
|
||||
output: 1.02,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
},
|
||||
releaseDate: "2025-10-27",
|
||||
@@ -15022,8 +15140,8 @@ export const GENERATED_PROVIDER_MODELS: {
|
||||
maxTokens: 32768,
|
||||
capabilities: ["images", "tools", "structured_output", "temperature"],
|
||||
pricing: {
|
||||
input: 0.08,
|
||||
output: 0.5,
|
||||
input: 0.117,
|
||||
output: 0.455,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
},
|
||||
@@ -15422,20 +15540,14 @@ export const GENERATED_PROVIDER_MODELS: {
|
||||
"qwen/qwen3-30b-a3b-thinking-2507": {
|
||||
id: "qwen/qwen3-30b-a3b-thinking-2507",
|
||||
name: "Qwen3 30B A3B Thinking 2507",
|
||||
contextWindow: 131072,
|
||||
maxInputTokens: 131072,
|
||||
maxTokens: 131072,
|
||||
capabilities: [
|
||||
"tools",
|
||||
"reasoning",
|
||||
"structured_output",
|
||||
"temperature",
|
||||
"prompt-cache",
|
||||
],
|
||||
contextWindow: 81920,
|
||||
maxInputTokens: 81920,
|
||||
maxTokens: 32768,
|
||||
capabilities: ["tools", "reasoning", "temperature"],
|
||||
pricing: {
|
||||
input: 0.08,
|
||||
output: 0.4,
|
||||
cacheRead: 0.08,
|
||||
input: 0.13,
|
||||
output: 1.56,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
},
|
||||
releaseDate: "2025-08-28",
|
||||
@@ -15825,12 +15937,7 @@ export const GENERATED_PROVIDER_MODELS: {
|
||||
contextWindow: 131072,
|
||||
maxInputTokens: 131072,
|
||||
maxTokens: 262144,
|
||||
capabilities: [
|
||||
"tools",
|
||||
"reasoning",
|
||||
"structured_output",
|
||||
"temperature",
|
||||
],
|
||||
capabilities: ["tools", "reasoning", "temperature"],
|
||||
pricing: {
|
||||
input: 0.1495,
|
||||
output: 1.495,
|
||||
@@ -16250,20 +16357,14 @@ export const GENERATED_PROVIDER_MODELS: {
|
||||
"qwen/qwen3-8b": {
|
||||
id: "qwen/qwen3-8b",
|
||||
name: "Qwen3 8B",
|
||||
contextWindow: 40960,
|
||||
maxInputTokens: 40960,
|
||||
contextWindow: 131072,
|
||||
maxInputTokens: 131072,
|
||||
maxTokens: 8192,
|
||||
capabilities: [
|
||||
"tools",
|
||||
"reasoning",
|
||||
"structured_output",
|
||||
"temperature",
|
||||
"prompt-cache",
|
||||
],
|
||||
capabilities: ["tools", "reasoning", "temperature"],
|
||||
pricing: {
|
||||
input: 0.05,
|
||||
output: 0.4,
|
||||
cacheRead: 0.05,
|
||||
input: 0.117,
|
||||
output: 0.455,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
},
|
||||
releaseDate: "2025-04-28",
|
||||
@@ -16505,8 +16606,8 @@ export const GENERATED_PROVIDER_MODELS: {
|
||||
"prompt-cache",
|
||||
],
|
||||
pricing: {
|
||||
input: 0.2,
|
||||
output: 0.77,
|
||||
input: 0.24,
|
||||
output: 0.9,
|
||||
cacheRead: 0.135,
|
||||
cacheWrite: 0,
|
||||
},
|
||||
@@ -18609,6 +18710,22 @@ export const GENERATED_PROVIDER_MODELS: {
|
||||
},
|
||||
},
|
||||
"tencent-tokenhub": {
|
||||
hy3: {
|
||||
id: "hy3",
|
||||
name: "Hy3",
|
||||
contextWindow: 256000,
|
||||
maxInputTokens: 256000,
|
||||
maxTokens: 64000,
|
||||
capabilities: ["tools", "reasoning", "temperature"],
|
||||
pricing: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
},
|
||||
releaseDate: "2026-07-06",
|
||||
family: "Hy",
|
||||
},
|
||||
"hy3-preview": {
|
||||
id: "hy3-preview",
|
||||
name: "Hy3 preview",
|
||||
@@ -18994,8 +19111,8 @@ export const GENERATED_PROVIDER_MODELS: {
|
||||
maxTokens: 131072,
|
||||
capabilities: ["tools", "temperature"],
|
||||
pricing: {
|
||||
input: 0.88,
|
||||
output: 0.88,
|
||||
input: 1.04,
|
||||
output: 1.04,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
},
|
||||
@@ -19070,6 +19187,29 @@ export const GENERATED_PROVIDER_MODELS: {
|
||||
},
|
||||
},
|
||||
"vercel-ai-gateway": {
|
||||
"anthropic/claude-fable-5": {
|
||||
id: "anthropic/claude-fable-5",
|
||||
name: "Claude Fable 5",
|
||||
contextWindow: 1000000,
|
||||
maxInputTokens: 1000000,
|
||||
maxTokens: 128000,
|
||||
capabilities: [
|
||||
"images",
|
||||
"files",
|
||||
"tools",
|
||||
"reasoning",
|
||||
"temperature",
|
||||
"prompt-cache",
|
||||
],
|
||||
pricing: {
|
||||
input: 10,
|
||||
output: 50,
|
||||
cacheRead: 1,
|
||||
cacheWrite: 12.5,
|
||||
},
|
||||
releaseDate: "2026-07-01",
|
||||
family: "claude-fable",
|
||||
},
|
||||
"anthropic/claude-sonnet-5": {
|
||||
id: "anthropic/claude-sonnet-5",
|
||||
name: "Claude Sonnet 5",
|
||||
@@ -19118,8 +19258,8 @@ export const GENERATED_PROVIDER_MODELS: {
|
||||
"zai/glm-5.2": {
|
||||
id: "zai/glm-5.2",
|
||||
name: "GLM 5.2",
|
||||
contextWindow: 1000000,
|
||||
maxInputTokens: 1000000,
|
||||
contextWindow: 1040000,
|
||||
maxInputTokens: 1040000,
|
||||
maxTokens: 128000,
|
||||
capabilities: [
|
||||
"tools",
|
||||
@@ -19129,9 +19269,9 @@ export const GENERATED_PROVIDER_MODELS: {
|
||||
"prompt-cache",
|
||||
],
|
||||
pricing: {
|
||||
input: 1.5,
|
||||
output: 4.5,
|
||||
cacheRead: 0.3,
|
||||
input: 1.4,
|
||||
output: 4.4,
|
||||
cacheRead: 0.26,
|
||||
cacheWrite: 0,
|
||||
},
|
||||
releaseDate: "2026-06-16",
|
||||
|
||||
@@ -0,0 +1,264 @@
|
||||
import type {
|
||||
AgentModelEvent,
|
||||
AgentToolDefinition,
|
||||
GatewayProviderContext,
|
||||
GatewayStreamRequest,
|
||||
} from "@cline/shared";
|
||||
import { NoSuchToolError } from "ai";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
createOpenAICompatibleProvider,
|
||||
repairMalformedToolCall,
|
||||
} from "./ai-sdk";
|
||||
|
||||
/**
|
||||
* Integration tests for malformed tool-call handling in the AI SDK adapter.
|
||||
*
|
||||
* Weaker models routinely emit tool calls with type mismatches (a bare string
|
||||
* where the schema wants an array) or arguments that are not valid JSON
|
||||
* (truncated payloads, single quotes). These tests drive the real adapter
|
||||
* with a fake OpenAI-compatible SSE response and assert that such calls are
|
||||
* coerced/repaired instead of being rejected before execution
|
||||
* (`metadata.inputParseError`), which surfaced as the
|
||||
* tool_call_type_validation / tool_call_invalid_json buckets under
|
||||
* task.provider_api_error.
|
||||
*/
|
||||
|
||||
const RUN_COMMANDS_TOOL: AgentToolDefinition = {
|
||||
name: "run_commands",
|
||||
description: "Run shell commands",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
commands: { type: "array", items: { type: "string" } },
|
||||
},
|
||||
required: ["commands"],
|
||||
},
|
||||
};
|
||||
|
||||
const READ_FILES_TOOL: AgentToolDefinition = {
|
||||
name: "read_files",
|
||||
description: "Read files",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
files: {
|
||||
type: "array",
|
||||
items: {
|
||||
type: "object",
|
||||
properties: { path: { type: "string" } },
|
||||
required: ["path"],
|
||||
},
|
||||
},
|
||||
},
|
||||
required: ["files"],
|
||||
},
|
||||
};
|
||||
|
||||
function sseToolCall(toolName: string, args: string): string {
|
||||
const chunk = (delta: unknown, finish: string | null = null) =>
|
||||
`data: ${JSON.stringify({
|
||||
id: "cmpl-1",
|
||||
object: "chat.completion.chunk",
|
||||
created: 1,
|
||||
model: "test-model",
|
||||
choices: [{ index: 0, delta, finish_reason: finish }],
|
||||
})}\n\n`;
|
||||
return (
|
||||
chunk({
|
||||
role: "assistant",
|
||||
tool_calls: [
|
||||
{
|
||||
index: 0,
|
||||
id: "call_1",
|
||||
type: "function",
|
||||
function: { name: toolName, arguments: "" },
|
||||
},
|
||||
],
|
||||
}) +
|
||||
chunk({ tool_calls: [{ index: 0, function: { arguments: args } }] }) +
|
||||
chunk({}, "tool_calls") +
|
||||
"data: [DONE]\n\n"
|
||||
);
|
||||
}
|
||||
|
||||
async function streamToolCallEvents(
|
||||
sseBody: string,
|
||||
tools: AgentToolDefinition[],
|
||||
): Promise<AgentModelEvent[]> {
|
||||
const config = {
|
||||
providerId: "openai-compatible",
|
||||
apiKey: "test-key",
|
||||
baseUrl: "http://fake.local/v1",
|
||||
fetch: (async () =>
|
||||
new Response(sseBody, {
|
||||
status: 200,
|
||||
headers: { "content-type": "text/event-stream" },
|
||||
})) as unknown as typeof fetch,
|
||||
};
|
||||
const provider = await createOpenAICompatibleProvider(config);
|
||||
const model = {
|
||||
id: "test-model",
|
||||
providerId: "openai-compatible",
|
||||
name: "test-model",
|
||||
};
|
||||
const context = {
|
||||
provider: {
|
||||
id: "openai-compatible",
|
||||
name: "OpenAI Compatible",
|
||||
defaultModelId: "test-model",
|
||||
models: [model],
|
||||
},
|
||||
model,
|
||||
config,
|
||||
} as unknown as GatewayProviderContext;
|
||||
const request = {
|
||||
providerId: "openai-compatible",
|
||||
modelId: "test-model",
|
||||
messages: [
|
||||
{
|
||||
id: "msg_user",
|
||||
role: "user",
|
||||
content: [{ type: "text", text: "do the thing" }],
|
||||
createdAt: new Date(),
|
||||
},
|
||||
],
|
||||
tools,
|
||||
} as unknown as GatewayStreamRequest;
|
||||
|
||||
const events: AgentModelEvent[] = [];
|
||||
for await (const event of await provider.stream(request, context)) {
|
||||
events.push(event);
|
||||
}
|
||||
return events;
|
||||
}
|
||||
|
||||
function findParseError(events: AgentModelEvent[]): string | undefined {
|
||||
for (const event of events) {
|
||||
if (event.type !== "tool-call-delta") continue;
|
||||
const metadata = event.metadata as Record<string, unknown> | undefined;
|
||||
if (typeof metadata?.inputParseError === "string") {
|
||||
return metadata.inputParseError;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function findToolInput(events: AgentModelEvent[]): unknown {
|
||||
for (const event of events) {
|
||||
if (event.type === "tool-call-delta" && event.input !== undefined) {
|
||||
return event.input;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
describe("ai-sdk adapter malformed tool calls", () => {
|
||||
it("passes schema-mismatched input through to the tool instead of rejecting", async () => {
|
||||
// The executor's lenient union schema accepts a bare string for a
|
||||
// string[] property; rejecting at the adapter would prevent that.
|
||||
const events = await streamToolCallEvents(
|
||||
sseToolCall("run_commands", '{"commands": "ls -la"}'),
|
||||
[RUN_COMMANDS_TOOL],
|
||||
);
|
||||
|
||||
expect(findParseError(events)).toBeUndefined();
|
||||
expect(findToolInput(events)).toEqual({ commands: "ls -la" });
|
||||
});
|
||||
|
||||
it("passes well-formed input through unchanged", async () => {
|
||||
const events = await streamToolCallEvents(
|
||||
sseToolCall("run_commands", '{"commands": ["ls", "pwd"]}'),
|
||||
[RUN_COMMANDS_TOOL],
|
||||
);
|
||||
|
||||
expect(findParseError(events)).toBeUndefined();
|
||||
expect(findToolInput(events)).toEqual({ commands: ["ls", "pwd"] });
|
||||
});
|
||||
|
||||
it("repairs truncated JSON arguments", async () => {
|
||||
const events = await streamToolCallEvents(
|
||||
sseToolCall("read_files", '{"files": [{"path": "/tmp/a.txt"}]'),
|
||||
[READ_FILES_TOOL],
|
||||
);
|
||||
|
||||
expect(findParseError(events)).toBeUndefined();
|
||||
expect(findToolInput(events)).toEqual({ files: [{ path: "/tmp/a.txt" }] });
|
||||
});
|
||||
|
||||
it("repairs single-quoted JSON arguments", async () => {
|
||||
const events = await streamToolCallEvents(
|
||||
sseToolCall("run_commands", "{'commands': ['ls']}"),
|
||||
[RUN_COMMANDS_TOOL],
|
||||
);
|
||||
|
||||
expect(findParseError(events)).toBeUndefined();
|
||||
expect(findToolInput(events)).toEqual({ commands: ["ls"] });
|
||||
});
|
||||
|
||||
it("still surfaces a parse error for unrepairable argument text", async () => {
|
||||
const events = await streamToolCallEvents(
|
||||
sseToolCall("run_commands", "run ls for me please"),
|
||||
[RUN_COMMANDS_TOOL],
|
||||
);
|
||||
|
||||
expect(findParseError(events)).toContain("Invalid input");
|
||||
});
|
||||
|
||||
it("keeps the unavailable-tool error for unknown tools", async () => {
|
||||
const events = await streamToolCallEvents(
|
||||
sseToolCall("editor", '{"path": "/tmp/a.txt", "new_text": "x"}'),
|
||||
[RUN_COMMANDS_TOOL, READ_FILES_TOOL],
|
||||
);
|
||||
|
||||
expect(findParseError(events)).toContain("unavailable tool 'editor'");
|
||||
});
|
||||
});
|
||||
|
||||
describe("repairMalformedToolCall", () => {
|
||||
const toolCall = (input: string) => ({
|
||||
toolCallId: "call_1",
|
||||
toolName: "run_commands",
|
||||
input,
|
||||
});
|
||||
|
||||
it("repairs truncated JSON", async () => {
|
||||
const repaired = await repairMalformedToolCall({
|
||||
toolCall: toolCall('{"commands": ["ls"'),
|
||||
error: new Error("JSON parsing failed"),
|
||||
});
|
||||
expect(repaired?.input).toBe('{"commands":["ls"]}');
|
||||
});
|
||||
|
||||
it("repairs single-quoted JSON", async () => {
|
||||
const repaired = await repairMalformedToolCall({
|
||||
toolCall: toolCall("{'commands': ['ls']}"),
|
||||
error: new Error("JSON parsing failed"),
|
||||
});
|
||||
expect(repaired?.input).toBe('{"commands":["ls"]}');
|
||||
});
|
||||
|
||||
it("returns null for already-valid JSON (schema failures are not repairable here)", async () => {
|
||||
const repaired = await repairMalformedToolCall({
|
||||
toolCall: toolCall('{"commands": "ls"}'),
|
||||
error: new Error("Type validation failed"),
|
||||
});
|
||||
expect(repaired).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for unknown-tool errors", async () => {
|
||||
const repaired = await repairMalformedToolCall({
|
||||
toolCall: toolCall('{"commands": ["ls"]}'),
|
||||
error: new NoSuchToolError({ toolName: "run_commands" }),
|
||||
});
|
||||
expect(repaired).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for unrepairable garbage", async () => {
|
||||
const repaired = await repairMalformedToolCall({
|
||||
toolCall: toolCall("run ls for me"),
|
||||
error: new Error("JSON parsing failed"),
|
||||
});
|
||||
expect(repaired).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -12,11 +12,11 @@ import {
|
||||
type AiSdkFormatterPart,
|
||||
captureSdkError,
|
||||
formatMessagesForAiSdk,
|
||||
parseJsonStream,
|
||||
sanitizeSurrogates,
|
||||
} from "@cline/shared";
|
||||
import { jsonSchema, streamText } from "ai";
|
||||
import { jsonSchema, NoSuchToolError, streamText } from "ai";
|
||||
import { nanoid } from "nanoid";
|
||||
import { z } from "zod";
|
||||
import { extractErrorMessage } from "./format";
|
||||
import {
|
||||
isAnthropicCompatibleModel,
|
||||
@@ -373,6 +373,11 @@ function toAiSdkTools(
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// No validate callback on purpose: schema validation belongs to the tools
|
||||
// themselves (core executors validate with lenient union schemas that
|
||||
// accept common weak-model shapes like a bare string for a string[]
|
||||
// property). Rejecting here would return an error to the model without
|
||||
// the tool's own input handling ever seeing the call.
|
||||
return Object.fromEntries(
|
||||
request.tools.map((definition) => [
|
||||
definition.name,
|
||||
@@ -380,22 +385,54 @@ function toAiSdkTools(
|
||||
description: definition.description,
|
||||
inputSchema: jsonSchema(
|
||||
normalizeAiSdkToolInputSchema(definition.inputSchema),
|
||||
{
|
||||
validate: async (value) => {
|
||||
const result = await z
|
||||
.fromJSONSchema(definition.inputSchema)
|
||||
.safeParseAsync(value);
|
||||
return result.success
|
||||
? { success: true, value: result.data }
|
||||
: { success: false, error: result.error };
|
||||
},
|
||||
},
|
||||
) as never,
|
||||
} as unknown,
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
interface RepairableToolCall {
|
||||
toolCallId: string;
|
||||
toolName: string;
|
||||
input: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Last-chance repair for tool calls whose arguments are not valid JSON
|
||||
* (truncated payloads, single quotes, unescaped newlines — common with
|
||||
* weaker models). Runs the raw argument text through the shared jsonrepair
|
||||
* strategies; unknown tool names and already-valid JSON are not repairable
|
||||
* here, and returning null preserves the AI SDK's original error behavior.
|
||||
*/
|
||||
export async function repairMalformedToolCall<T extends RepairableToolCall>({
|
||||
toolCall,
|
||||
error,
|
||||
}: {
|
||||
toolCall: T;
|
||||
error: unknown;
|
||||
}): Promise<T | null> {
|
||||
if (NoSuchToolError.isInstance(error)) {
|
||||
return null;
|
||||
}
|
||||
if (typeof toolCall.input !== "string" || toolCall.input.trim() === "") {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
JSON.parse(toolCall.input);
|
||||
// Valid JSON means the failure was a schema mismatch, not a parse
|
||||
// error. That is left to the tool executor's own lenient union
|
||||
// schemas; there is nothing to repair here.
|
||||
return null;
|
||||
} catch {
|
||||
// Not valid JSON — attempt repair below.
|
||||
}
|
||||
const repaired = parseJsonStream(toolCall.input);
|
||||
if (repaired === toolCall.input || typeof repaired === "string") {
|
||||
return null;
|
||||
}
|
||||
return { ...toolCall, input: JSON.stringify(repaired) };
|
||||
}
|
||||
|
||||
function normalizeAiSdkToolInputSchema(
|
||||
inputSchema: Record<string, unknown>,
|
||||
): Record<string, unknown> {
|
||||
@@ -1160,6 +1197,7 @@ function createAiSdkProvider(kind: ProviderModuleKind): GatewayProviderFactory {
|
||||
? { maxOutputTokens: request.maxTokens }
|
||||
: {}),
|
||||
abortSignal: request.signal,
|
||||
experimental_repairToolCall: repairMalformedToolCall as never,
|
||||
experimental_telemetry: {
|
||||
isEnabled: langfuse,
|
||||
},
|
||||
|
||||
@@ -57,8 +57,8 @@ describe("cline builtin models", () => {
|
||||
expect(models["zai/glm-5.2"]).toMatchObject({
|
||||
id: "zai/glm-5.2",
|
||||
name: "GLM 5.2",
|
||||
contextWindow: 1_000_000,
|
||||
maxInputTokens: 1_000_000,
|
||||
contextWindow: 1_040_000,
|
||||
maxInputTokens: 1_040_000,
|
||||
});
|
||||
expect(models["zai/glm-5.1"]).toMatchObject({
|
||||
id: "zai/glm-5.1",
|
||||
|
||||
@@ -41,7 +41,9 @@ describe("extractErrorMessage", () => {
|
||||
});
|
||||
|
||||
expect(
|
||||
extractErrorMessage(new TypeError("fetch failed", { cause: socketError })),
|
||||
extractErrorMessage(
|
||||
new TypeError("fetch failed", { cause: socketError }),
|
||||
),
|
||||
).toBe("fetch failed: SocketError: other side closed (UND_ERR_SOCKET)");
|
||||
});
|
||||
|
||||
|
||||
@@ -109,7 +109,9 @@ describe("createSapAiCoreProviderModule", () => {
|
||||
const firstModel = firstProvider.model("anthropic--claude-4.6-sonnet") as {
|
||||
doGenerate: () => Promise<string>;
|
||||
};
|
||||
const secondModel = secondProvider.model("anthropic--claude-4.6-sonnet") as {
|
||||
const secondModel = secondProvider.model(
|
||||
"anthropic--claude-4.6-sonnet",
|
||||
) as {
|
||||
doGenerate: () => Promise<string>;
|
||||
};
|
||||
const firstStarted = deferred();
|
||||
|
||||
+2
-1
@@ -264,6 +264,7 @@ export async function createSapAiCoreProviderModule(
|
||||
: {}),
|
||||
});
|
||||
return {
|
||||
model: (modelId) => wrapSapModelWithServiceKey(provider(modelId), serviceKey),
|
||||
model: (modelId) =>
|
||||
wrapSapModelWithServiceKey(provider(modelId), serviceKey),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@cline/sdk",
|
||||
"description": "Cline SDK - user-facing alias for @cline/core",
|
||||
"version": "0.0.54",
|
||||
"version": "0.0.56",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/cline/cline",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@cline/shared",
|
||||
"version": "0.0.54",
|
||||
"version": "0.0.56",
|
||||
"description": "Shared utilities, types, and schemas for Cline packages",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
||||
@@ -229,10 +229,12 @@ export { buildClineSystemPrompt } from "./prompt/cline";
|
||||
export {
|
||||
formatDisplayUserInput,
|
||||
formatFileContentBlock,
|
||||
formatModeSwitchNotice,
|
||||
formatUserCommandBlock,
|
||||
formatUserInputBlock,
|
||||
normalizeUserInput,
|
||||
parseUserCommandEnvelope,
|
||||
stripModeNotices,
|
||||
xmlTagsRemoval,
|
||||
} from "./prompt/format";
|
||||
export { isClineProvider } from "./providers/utils";
|
||||
|
||||
@@ -243,10 +243,12 @@ export { buildClineSystemPrompt, processWorkspaceInfo } from "./prompt/cline";
|
||||
export {
|
||||
formatDisplayUserInput,
|
||||
formatFileContentBlock,
|
||||
formatModeSwitchNotice,
|
||||
formatUserCommandBlock,
|
||||
formatUserInputBlock,
|
||||
normalizeUserInput,
|
||||
parseUserCommandEnvelope,
|
||||
stripModeNotices,
|
||||
xmlTagsRemoval,
|
||||
} from "./prompt/format";
|
||||
export { isClineProvider } from "./providers/utils";
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
formatDisplayUserInput,
|
||||
formatModeSwitchNotice,
|
||||
formatUserCommandBlock,
|
||||
formatUserInputBlock,
|
||||
normalizeUserInput,
|
||||
parseUserCommandEnvelope,
|
||||
stripModeNotices,
|
||||
} from "./format";
|
||||
|
||||
describe("prompt format helpers", () => {
|
||||
@@ -36,4 +39,49 @@ describe("prompt format helpers", () => {
|
||||
);
|
||||
expect(formatDisplayUserInput(wrapped)).toBe("/team inspect rpc startup");
|
||||
});
|
||||
|
||||
it("formats a mode switch notice", () => {
|
||||
expect(formatModeSwitchNotice("plan", "act")).toBe(
|
||||
"<mode_notice>The user switched from plan mode to act mode before sending this message.</mode_notice>",
|
||||
);
|
||||
});
|
||||
|
||||
it("hides mode switch notices from displayed user input", () => {
|
||||
const wrapped = formatUserInputBlock(
|
||||
`${formatModeSwitchNotice("act", "plan")}\nhow should we refactor this?`,
|
||||
"plan",
|
||||
);
|
||||
expect(formatDisplayUserInput(wrapped)).toBe(
|
||||
"how should we refactor this?",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps mode switch notices when normalizing outbound prompts", () => {
|
||||
// prepareTurnInput sanitizes prompts with normalizeUserInput before the
|
||||
// host wraps them; stripping notices here would delete the switch
|
||||
// signal before the model ever sees it.
|
||||
const prompt = `${formatModeSwitchNotice("plan", "act")}\ndo it`;
|
||||
expect(normalizeUserInput(prompt)).toBe(prompt);
|
||||
});
|
||||
|
||||
it("removes every mode notice and leaves unclosed ones intact", () => {
|
||||
expect(
|
||||
stripModeNotices(
|
||||
"<mode_notice>a</mode_notice>hello<mode_notice>b</mode_notice> there",
|
||||
),
|
||||
).toBe("hello there");
|
||||
expect(stripModeNotices("<mode_notice>dangling")).toBe(
|
||||
"<mode_notice>dangling",
|
||||
);
|
||||
});
|
||||
|
||||
it("strips adversarial repeated open tags in linear time", () => {
|
||||
// Regression guard for CodeQL js/polynomial-redos: many unmatched
|
||||
// opening tags must not trigger quadratic rescanning.
|
||||
const hostile = "<mode_notice>".repeat(50_000);
|
||||
const started = performance.now();
|
||||
const result = stripModeNotices(hostile);
|
||||
expect(performance.now() - started).toBeLessThan(1_000);
|
||||
expect(result).toBe(hostile);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -13,6 +13,20 @@ export function formatUserCommandBlock(input: string, slash: string): string {
|
||||
return `<user_command slash="${slash}">${input}</user_command>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Marks the exact point in the conversation where the user switched between
|
||||
* plan and act modes. Prepended to the first user message sent after the
|
||||
* switch. It survives normalizeUserInput (so the outbound sanitize in
|
||||
* prepareTurnInput delivers it to the model) and is hidden from transcript
|
||||
* display by stripModeNotices at display boundaries.
|
||||
*/
|
||||
export function formatModeSwitchNotice(
|
||||
from: "act" | "plan",
|
||||
to: "act" | "plan",
|
||||
): string {
|
||||
return `<mode_notice>The user switched from ${from} mode to ${to} mode before sending this message.</mode_notice>`;
|
||||
}
|
||||
|
||||
export type UserCommandEnvelope = {
|
||||
slash: string;
|
||||
content: string;
|
||||
@@ -75,8 +89,39 @@ export function normalizeUserInput(input?: string): string {
|
||||
return next;
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes runtime-generated <mode_notice> elements (content included): they
|
||||
* are not user-typed text and must not render as such. Deliberately NOT part
|
||||
* of normalizeUserInput -- that function also sanitizes outbound prompts
|
||||
* before the host wraps them (prepareTurnInput), and stripping there deletes
|
||||
* the notice before the model ever sees it.
|
||||
*/
|
||||
export function stripModeNotices(input?: string): string {
|
||||
if (!input?.trim()) return "";
|
||||
return removeTagElements(input, "mode_notice").trim();
|
||||
}
|
||||
|
||||
// indexOf-based rather than a regex: a lazy dot-all pattern re-scans to the
|
||||
// end of the string from every unmatched opening tag, which is polynomial on
|
||||
// adversarial transcript content (CodeQL js/polynomial-redos).
|
||||
function removeTagElements(input: string, tag: string): string {
|
||||
const open = `<${tag}>`;
|
||||
const close = `</${tag}>`;
|
||||
let result = input;
|
||||
let start = result.indexOf(open);
|
||||
while (start !== -1) {
|
||||
const end = result.indexOf(close, start + open.length);
|
||||
if (end === -1) {
|
||||
break;
|
||||
}
|
||||
result = result.slice(0, start) + result.slice(end + close.length);
|
||||
start = result.indexOf(open, start);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function formatDisplayUserInput(input?: string): string {
|
||||
const normalized = normalizeUserInput(input);
|
||||
const normalized = stripModeNotices(normalizeUserInput(input));
|
||||
const envelope = parseUserCommandEnvelope(input);
|
||||
if (!envelope) {
|
||||
return normalized;
|
||||
|
||||
Reference in New Issue
Block a user