Compare commits

..
Author SHA1 Message Date
Robin Newhouse ac2bdbfd5a fix(sdk): remove experimental parallel tool execution 2026-06-25 13:20:43 -07:00
Saoud Rizwan bebc581652 fix(core): add non-interactive command guidance (#11815)
* fix(core): add non-interactive command guidance

* fix(core): refine non-interactive command guidance
2026-06-25 12:34:32 -07:00
Saoud Rizwan 21ebee3638 fix(sdk): keep SAP model filtering in clients (#11837) 2026-06-25 12:31:59 -07:00
Saoud Rizwan 7d78c5f074 refactor(vscode): attach checkpoints to user edits (#11832)
* refactor(vscode): attach checkpoints to user edits

* fix(vscode): improve checkpoint edit controls

* fix(vscode): align checkpoint edit actions

* fix(vscode): gate checkpoint restore edits
2026-06-25 12:21:17 -07:00
Saoud RizwanandDavid Knaack 352a23a6da fix: stabilize SAP AI Core provider setup (#11833)
* fix: Filter SAP AI Core models based on mode-availibility

* chore: fix model picker test

* fix: harden SAP AI Core model filtering

* fix: pin SAP Cloud SDK to 4.6.0

* fix(vscode): simplify SAP AI Core model filtering

---------

Co-authored-by: David Knaack <david.knaack@sap.com>
2026-06-25 12:13:53 -07:00
Saoud Rizwan 176662ee20 fix(vscode): show pending state before queued prompt appears (#11836)
* fix(vscode): show pending state for chat sends

* fix(vscode): keep chat input editable during pending sends
2026-06-25 12:08:05 -07:00
Saoud Rizwan 309ad9da72 fix(vscode): show queued prompts while streaming (#11835)
* fix(vscode): show queued prompts while streaming

* fix(vscode): refine queued prompt updates
2026-06-25 11:54:44 -07:00
Saoud Rizwan 16a6926985 fix: improve ask option selection UI (#11824)
* fix: disable hover for selected ask options

* fix: mark ask options disabled after selection

* fix: hide duplicate ask option echoes
2026-06-25 11:51:43 -07:00
Saoud Rizwan d6db723c9d fix(vscode): keep command output pinned to bottom (#11825)
* fix(vscode): keep command output pinned to bottom

* fix(vscode): address command output scroll review
2026-06-25 11:31:54 -07:00
Tomás Barreiro ce1fc20a9f Limit the ClinePass CLI url to the CLI (#11811)
* Limit the ClinePass CLI url to the CLI

* fix tests

* Remove unused import

* Fix run-agent

* fix run-aent error message
2026-06-25 20:31:31 +02:00
Tomás Barreiro b780a5ec00 Fix ClinePass error mapping on VSCode (#11807)
* Fix ClinePass error mapping on VSCode

* refactor

* fix types

* remove unused constants

* refactor
2026-06-25 19:29:13 +02:00
Tomás Barreiro 285cd6d54c Fix vscode tests (#11831) 2026-06-25 10:16:39 -07:00
Tomás Barreiro ff845539e4 Fix createRequire (#11829) 2026-06-25 18:17:16 +02:00
61 changed files with 1361 additions and 1565 deletions
+1 -1
View File
@@ -121,7 +121,7 @@ const result = await Bun.build({
},
env: "OTEL_*",
banner:
'import { createRequire as __createRequire } from "node:module"; const require = __createRequire(import.meta.url);',
'import { createRequire as __clineCreateRequire } from "node:module"; const require = __clineCreateRequire(import.meta.url);',
});
if (result.logs.length > 0) {
+71 -5
View File
@@ -38,8 +38,11 @@ const sessionEventsMocks = vi.hoisted(() => ({
}));
const CLINE_PASS_SUBSCRIPTION_URL =
"https://app.cline.bot/dashboard/subscription?personal=true";
const CLI_SUBSCRIPTION_URL =
"https://app.cline.bot/promo?code=CLI-100&personal=true";
const CLINE_PASS_SUBSCRIPTION_MESSAGE = `No access to ClinePass subscription models yet. Subscribe to ClinePass, the low cost open weights model coding plan: ${CLINE_PASS_SUBSCRIPTION_URL}`;
const SDK_CLINE_PASS_SUBSCRIPTION_MESSAGE = `No access to ClinePass subscription models yet. Subscribe to ClinePass, the low cost open weights model coding plan: ${CLINE_PASS_SUBSCRIPTION_URL}`;
const CLI_CLINE_PASS_SUBSCRIPTION_MESSAGE = `No access to ClinePass subscription models yet. Subscribe to ClinePass, the low cost open weights model coding plan: ${CLI_SUBSCRIPTION_URL}`;
const CLINE_ORG_INDIVIDUAL_INFERENCE_SUBSCRIPTION_MESSAGE =
"Organization accounts cannot use ClinePass subscriptions. Go to /account -> change account to switch to your personal account for ClinePass";
@@ -549,7 +552,7 @@ describe("runAgent", () => {
});
it("renders ClinePass subscription errors with friendly copy when startup throws", async () => {
const error = new Error(CLINE_PASS_SUBSCRIPTION_MESSAGE);
const error = new Error(SDK_CLINE_PASS_SUBSCRIPTION_MESSAGE);
error.name = "ClineNotSubscribedError";
sessionManagerMocks.start.mockRejectedValue(error);
@@ -577,7 +580,7 @@ describe("runAgent", () => {
expect(process.exitCode).toBe(1);
expect(outputMocks.writeErr).toHaveBeenCalledWith(
CLINE_PASS_SUBSCRIPTION_MESSAGE,
CLI_CLINE_PASS_SUBSCRIPTION_MESSAGE,
);
});
@@ -655,7 +658,7 @@ describe("runAgent", () => {
messagesPath: "/tmp/messages.json",
manifest: { session_id: "session-1" },
result: {
text: CLINE_PASS_SUBSCRIPTION_MESSAGE,
text: SDK_CLINE_PASS_SUBSCRIPTION_MESSAGE,
usage: {
inputTokens: 0,
outputTokens: 0,
@@ -699,10 +702,73 @@ describe("runAgent", () => {
expect(process.exitCode).toBe(1);
expect(outputMocks.writeErr).toHaveBeenCalledWith(
CLINE_PASS_SUBSCRIPTION_MESSAGE,
CLI_CLINE_PASS_SUBSCRIPTION_MESSAGE,
);
});
it("does not duplicate ClinePass subscription errors already displayed by agent events", async () => {
const startedAt = new Date("2026-03-22T00:00:00.000Z");
const endedAt = new Date("2026-03-22T00:00:01.000Z");
sessionManagerMocks.start.mockImplementation(async () => {
sessionEventsMocks.listener?.({
type: "error",
error: new Error(SDK_CLINE_PASS_SUBSCRIPTION_MESSAGE),
recoverable: false,
});
return {
sessionId: "session-1",
manifestPath: "/tmp/manifest.json",
messagesPath: "/tmp/messages.json",
manifest: { session_id: "session-1" },
result: {
text: SDK_CLINE_PASS_SUBSCRIPTION_MESSAGE,
usage: {
inputTokens: 0,
outputTokens: 0,
cacheReadTokens: 0,
cacheWriteTokens: 0,
totalCost: 0,
},
messages: [],
toolCalls: [],
iterations: 1,
finishReason: "error",
model: { id: "premium-model", provider: "cline-pass", info: {} },
startedAt,
endedAt,
durationMs: 1000,
},
};
});
sessionManagerMocks.getAccumulatedUsage.mockResolvedValue(undefined);
const { runAgent } = await import("./run-agent");
await expect(
runAgent("test prompt", {
cwd: process.cwd(),
enableAgentTeams: false,
enableSpawnAgent: false,
enableTools: [],
execution: { maxConsecutiveMistakes: 3 },
logger: undefined,
mode: "yolo",
modelId: "premium-model",
outputMode: "text",
providerId: "cline-pass",
systemPrompt: "system",
thinking: false,
toolPolicies: { "*": { autoApprove: true } },
verbose: false,
workspaceRoot: process.cwd(),
} as never),
).resolves.toBeUndefined();
expect(process.exitCode).toBe(1);
expect(outputMocks.writeErr).not.toHaveBeenCalled();
});
it("surfaces post-run bookkeeping failures after a completed result", async () => {
const startedAt = new Date("2026-03-22T00:00:00.000Z");
const endedAt = new Date("2026-03-22T00:00:01.000Z");
+3 -1
View File
@@ -204,7 +204,9 @@ export async function runAgent(
(!event.recoverable || config.verbose) &&
event.error.message.trim()
) {
displayedErrorMessages.add(event.error.message.trim());
displayedErrorMessages.add(
formatCliErrorMessage(event.error.message).trim(),
);
}
handleEvent(event, config);
};
+5 -3
View File
@@ -3,8 +3,8 @@ import type React from "react";
import { useState } from "react";
import "opentui-spinner/react";
import {
getCliSubscriptionUrl,
getClineOrgIndividualInferenceSubscriptionMessage,
getClinePassSubscriptionUrl,
isClineOrgIndividualInferenceSubscriptionErrorMessage,
isClinePassSubscriptionError,
} from "../../utils/cline-pass-errors";
@@ -297,7 +297,7 @@ function ClineCreditsErrorView(props: { defaultFg?: string }) {
}
function ClinePassSubscriptionErrorView(props: { defaultFg?: string }) {
const subscriptionUrl = getClinePassSubscriptionUrl();
const subscriptionUrl = getCliSubscriptionUrl();
return (
<box flexDirection="row">
<text fg="yellow" content="* " />
@@ -455,7 +455,9 @@ export function ChatEntryView(props: {
}
if (isClineOrgIndividualInferenceSubscriptionErrorMessage(entry.text)) {
return (
<ClineOrgIndividualInferenceSubscriptionErrorView defaultFg={defaultFg} />
<ClineOrgIndividualInferenceSubscriptionErrorView
defaultFg={defaultFg}
/>
);
}
if (isClinePassSubscriptionError(entry.text)) {
+8 -3
View File
@@ -1,8 +1,9 @@
import { describe, expect, it } from "vitest";
import {
formatCliErrorMessage,
getCliNotSubscribedMessage,
getCliSubscriptionUrl,
getClineOrgIndividualInferenceSubscriptionMessage,
getClinePassSubscriptionUrl,
isClineOrgIndividualInferenceSubscriptionErrorMessage,
isClinePassSubscriptionError,
} from "./cline-pass-errors";
@@ -15,13 +16,17 @@ describe("cline-pass-errors", () => {
),
).toBe(true);
const formatted = `No access to ClinePass subscription models yet. Subscribe to ClinePass, the low cost open weights model coding plan: ${getClinePassSubscriptionUrl()}`;
const sdkFormatted =
"No access to ClinePass subscription models yet. Subscribe to ClinePass, the low cost open weights model coding plan: https://app.cline.bot/dashboard/subscription?personal=true";
const formatted = getCliNotSubscribedMessage();
expect(isClinePassSubscriptionError(sdkFormatted)).toBe(true);
expect(isClinePassSubscriptionError(formatted)).toBe(true);
expect(formatCliErrorMessage(new Error(sdkFormatted))).toBe(formatted);
expect(formatCliErrorMessage(new Error(formatted))).toBe(formatted);
});
it("formats the ClinePass subscription URL", () => {
expect(getClinePassSubscriptionUrl()).toBe(
expect(getCliSubscriptionUrl()).toBe(
"https://app.cline.bot/promo?code=CLI-100&personal=true",
);
});
+16 -2
View File
@@ -1,17 +1,28 @@
import {
getClineOrgIndividualInferenceSubscriptionMessage,
getClinePassSubscriptionUrl,
isClineNotSubscribedError,
isClineNotSubscribedMessage,
isClineOrgIndividualInferenceSubscriptionError,
isClineOrgIndividualInferenceSubscriptionMessage,
} from "@cline/core";
import { getClineEnvironmentConfig } from "@cline/shared";
export {
getClineOrgIndividualInferenceSubscriptionMessage,
getClinePassSubscriptionUrl,
};
export function getCliSubscriptionUrl(): string {
return `${new URL(
"/promo?code=CLI-100&personal=true",
getClineEnvironmentConfig().appBaseUrl,
).toString()}`;
}
export function getCliNotSubscribedMessage(): string {
return `No access to ClinePass subscription models yet. Subscribe to ClinePass, the low cost open weights model coding plan: ${getCliSubscriptionUrl()}`;
}
function isFormattedClinePassSubscriptionMessage(message: string): boolean {
const normalized = message.trim().toLowerCase();
return (
@@ -59,6 +70,9 @@ export function isClineOrgIndividualInferenceSubscriptionErrorMessage(
}
export function formatCliErrorMessage(error: unknown): string {
if (isClinePassSubscriptionError(error)) {
return getCliNotSubscribedMessage();
}
if (isClineOrgIndividualInferenceSubscriptionErrorMessage(error)) {
return getClineOrgIndividualInferenceSubscriptionMessage();
}
@@ -9,7 +9,6 @@ option java_multiple_files = true;
option java_package = "bot.cline.proto";
service CheckpointsService {
rpc checkpointDiff(Int64Request) returns (Empty);
rpc checkpointRestore(CheckpointRestoreRequest) returns (Empty);
}
@@ -1,16 +0,0 @@
import { Empty, Int64Request } from "@shared/proto/cline/common"
import { Controller } from ".."
export async function checkpointDiff(controller: Controller, request: Int64Request): Promise<Empty> {
const sdkCompareCheckpoint = (
controller as Controller & {
compareCheckpoint?: (input: { checkpointRunCount: number }) => Promise<void>
}
).compareCheckpoint
if (sdkCompareCheckpoint && request.value) {
await sdkCompareCheckpoint.call(controller, {
checkpointRunCount: Number(request.value),
})
}
return Empty.create()
}
+115 -141
View File
@@ -11,7 +11,6 @@ import {
createUserInstructionConfigService,
getProviderAuthStorageId,
type PreparedRemoteConfigCoreIntegration,
readSessionCheckpointHistory,
type SessionHistoryRecord,
setTelemetryOptOutGlobally,
type UserInstructionConfigService,
@@ -54,12 +53,12 @@ import { createProviderCatalog } from "./model-catalog/catalog"
import type { Disposable, ProviderCatalog, ProviderConfigChange, ProviderConfigStore } from "./model-catalog/contracts"
import { parseProviderId } from "./model-catalog/provider-id"
import { createProviderConfigStore } from "./model-catalog/store"
import { SdkCompactionCoordinator } from "./sdk-compaction-coordinator"
import {
buildSdkCheckpointRows,
findVisibleCheckpointUserMessageByRun,
getCheckpointRunCountForMessage,
isVisibleCheckpointUserMessage,
} from "./sdk-checkpoints"
import { SdkCompactionCoordinator } from "./sdk-compaction-coordinator"
import { SdkFollowupCoordinator } from "./sdk-followup-coordinator"
import { SdkInteractionCoordinator } from "./sdk-interaction-coordinator"
import { SdkMcpCoordinator } from "./sdk-mcp-coordinator"
@@ -488,7 +487,6 @@ export class Controller {
getTask: () => this.task,
postStateToWebview: () => this.postStateToWebview(),
setTurnPhase: (phase, anchorTs) => this.turnStateTracker.set(phase, anchorTs),
syncCheckpointRows: () => this.syncCheckpointRowsFromActiveSession(),
})
// Subscribe to MCP tool list changes so we can restart the SDK session
// when servers are added/removed/reconnected. The SDK's DefaultSessionBuilder
@@ -1071,147 +1069,114 @@ export class Controller {
throw new Error("Only user messages can be edited")
}
if (input.restoreWorkspace) {
HostProvider.window.showMessage({
type: ShowMessageType.INFORMATION,
message: "Workspace restore is not available for edited-message regeneration yet. Regenerating chat only.",
})
}
const userOrdinal = clineMessages
.slice(0, targetIndex + 1)
.filter((message) => message.type === "say" && (message.say === "task" || message.say === "user_feedback")).length
const checkpointRunCount = getCheckpointRunCountForMessage(clineMessages, targetIndex)
const sourceSessionId = activeSession?.sessionId ?? currentTask.taskId
let sdkMessages: SdkUserMessage[]
if (activeSession) {
sdkMessages = (await activeSession.sdkHost.readMessages(activeSession.sessionId)) as SdkUserMessage[]
} else {
const tempHost = await VscodeSessionHost.create({ mcpHub: this.mcpHub })
try {
sdkMessages = (await tempHost.readMessages(currentTask.taskId)) as SdkUserMessage[]
} finally {
await tempHost.dispose("editMessageAndRegenerate.readMessages")
let tempHost: VscodeSessionHost | undefined
const sessionHost = activeSession?.sdkHost ?? (tempHost = await VscodeSessionHost.create({ mcpHub: this.mcpHub }))
try {
sdkMessages = (await sessionHost.readMessages(sourceSessionId)) as SdkUserMessage[]
const sdkTargetIndex = findSdkUserMessageIndexByOrdinal(sdkMessages, userOrdinal)
if (sdkTargetIndex === -1) {
throw new Error("Could not map edited message to persisted conversation history")
}
const initialMessages = sdkMessages.slice(0, sdkTargetIndex) as Parameters<
VscodeSessionHost["start"]
>[0]["initialMessages"]
const firstUserMessage = sdkMessages.find(
(message) => message.role === "user" && !!extractSdkUserText(message) && !isSyntheticSdkUserMessage(message),
)
const historyTitle =
userOrdinal === 1
? editedText
: extractSdkUserText(firstUserMessage ?? {}) || clineMessages[0]?.text || editedText
const fallbackCwd = await this.getWorkspaceRoot()
const [sessionRecord, historyItem] = await Promise.all([
sessionHost.get(sourceSessionId).catch(() => undefined),
this.taskHistory.findHistoryItem(currentTask.taskId).catch(() => undefined),
])
const cwd =
sessionRecord?.cwd?.trim() ||
sessionRecord?.workspaceRoot?.trim() ||
historyItem?.cwdOnTaskInitialization?.trim() ||
fallbackCwd
const mode = this.stateManager.getGlobalSettingsKey("mode") === "plan" ? "plan" : "act"
const config = await this.sessionConfigBuilder.build({ cwd, mode, prompt: historyTitle })
if (usesClineAccountAuth(config.providerId) && !config.apiKey) {
this.emitClineAuthError(editedText)
return
}
const resolvedPrompt = await this.resolveContextMentions(editedText)
const startInput = {
...buildStartSessionInput(config, { prompt: historyTitle, cwd, mode }),
initialMessages,
sessionMetadata: {
title: historyTitle,
modelId: config.modelId,
},
}
if (input.restoreWorkspace) {
if (activeSession?.isRunning) {
throw new Error("Wait for the current run to finish before restoring workspace changes")
}
if (checkpointRunCount === undefined) {
throw new Error("Workspace restore is only available for messages that started an agent run")
}
await sessionHost.restore({
sessionId: sourceSessionId,
checkpointRunCount,
cwd,
restore: {
messages: false,
workspace: true,
omitCheckpointMessageFromSession: true,
},
})
}
const { startResult, sdkHost } = await this.sessions.startNewSession(startInput)
this.turnStateTracker.set("streaming")
this.messageTranslatorState.clearTurnOutcome()
this.resetMessageTranslatorAndFence()
const task = createTaskProxy(
startResult.sessionId,
(text?: string, images?: string[], files?: string[]) => this.askResponse(text, images, files),
() => this.cancelTask(),
)
this.task = task
const newHistoryItem = createHistoryItemFromSession(startResult.sessionId, historyTitle, config.modelId, cwd)
await this.taskHistory.updateTaskHistoryItem(newHistoryItem)
const visibleMessages = clineMessages.slice(0, targetIndex)
if (visibleMessages.length > 0) {
task.messageStateHandler.addMessages(visibleMessages)
}
task.messageStateHandler.addMessages([
{
ts: Date.now(),
type: "say",
say: userOrdinal === 1 ? "task" : "user_feedback",
text: editedText,
images: input.images,
files: input.files,
partial: false,
},
])
await this.postStateToWebview()
this.sessions.fireAndForgetSend(sdkHost, startResult.sessionId, resolvedPrompt, input.images, input.files)
} finally {
await tempHost?.dispose("editMessageAndRegenerate")
}
const sdkTargetIndex = findSdkUserMessageIndexByOrdinal(sdkMessages, userOrdinal)
if (sdkTargetIndex === -1) {
throw new Error("Could not map edited message to persisted conversation history")
}
const initialMessages = sdkMessages.slice(0, sdkTargetIndex) as Parameters<
VscodeSessionHost["start"]
>[0]["initialMessages"]
const firstUserMessage = sdkMessages.find(
(message) => message.role === "user" && !!extractSdkUserText(message) && !isSyntheticSdkUserMessage(message),
)
const historyTitle =
userOrdinal === 1 ? editedText : extractSdkUserText(firstUserMessage ?? {}) || clineMessages[0]?.text || editedText
const cwd = await this.getWorkspaceRoot()
const mode = this.stateManager.getGlobalSettingsKey("mode") === "plan" ? "plan" : "act"
const config = await this.sessionConfigBuilder.build({ cwd, mode, prompt: historyTitle })
if (usesClineAccountAuth(config.providerId) && !config.apiKey) {
this.emitClineAuthError(editedText)
return
}
this.turnStateTracker.set("streaming")
this.messageTranslatorState.clearTurnOutcome()
this.resetMessageTranslatorAndFence()
const startInput = {
...buildStartSessionInput(config, { prompt: historyTitle, cwd, mode }),
initialMessages,
sessionMetadata: {
title: historyTitle,
modelId: config.modelId,
},
}
const { startResult, sdkHost } = await this.sessions.startNewSession(startInput)
const task = createTaskProxy(
startResult.sessionId,
(text?: string, images?: string[], files?: string[]) => this.askResponse(text, images, files),
() => this.cancelTask(),
)
this.task = task
const newHistoryItem = createHistoryItemFromSession(startResult.sessionId, historyTitle, config.modelId, cwd)
await this.taskHistory.updateTaskHistoryItem(newHistoryItem)
const visibleMessages = clineMessages.slice(0, targetIndex)
if (visibleMessages.length > 0) {
task.messageStateHandler.addMessages(visibleMessages)
}
task.messageStateHandler.addMessages([
{
ts: Date.now(),
type: "say",
say: userOrdinal === 1 ? "task" : "user_feedback",
text: editedText,
images: input.images,
files: input.files,
partial: false,
},
])
await this.postStateToWebview()
const resolvedPrompt = await this.resolveContextMentions(editedText)
this.sessions.fireAndForgetSend(sdkHost, startResult.sessionId, resolvedPrompt, input.images, input.files)
}
private async syncCheckpointRowsFromActiveSession(): Promise<void> {
const task = this.task
const activeSession = this.sessions.getActiveSession()
if (!task || !activeSession) {
return
}
const session = await activeSession.sdkHost.get(activeSession.sessionId)
const checkpointHistory = readSessionCheckpointHistory(session)
const currentMessages = task.messageStateHandler.getClineMessages()
const nextMessages = buildSdkCheckpointRows({
messages: currentMessages,
checkpointHistory,
createTimestamp: () => this.messageTranslatorState.nextTs(),
})
if (
nextMessages.length === currentMessages.length &&
nextMessages.every((message, index) => message === currentMessages[index])
) {
return
}
this.messages.replaceMessages(nextMessages)
}
async compareCheckpoint(input: { checkpointRunCount: number }): Promise<void> {
const checkpointRunCount = Number(input.checkpointRunCount)
if (!Number.isInteger(checkpointRunCount) || checkpointRunCount < 1) {
throw new Error("checkpointRunCount must be a positive integer")
}
const activeSession = this.sessions.getActiveSession()
if (!activeSession) {
throw new Error("No active task to compare")
}
const cwd = await this.getWorkspaceRoot()
const { diffs } = await activeSession.sdkHost.compareCheckpoint({
sessionId: activeSession.sessionId,
checkpointRunCount,
cwd,
})
if (diffs.length === 0) {
HostProvider.window.showMessage({
type: ShowMessageType.INFORMATION,
message: "No changes found",
})
return
}
await HostProvider.diff.openMultiFileDiff({
title: "Changes since checkpoint",
diffs,
})
}
async restoreCheckpoint(input: { checkpointRunCount: number; restoreType: ClineCheckpointRestore }): Promise<void> {
@@ -1271,7 +1236,6 @@ export class Controller {
})
if (!restoreMessages) {
await this.syncCheckpointRowsFromActiveSession()
await this.postStateToWebview()
return
}
@@ -1305,7 +1269,6 @@ export class Controller {
files: target.message.files ?? [],
sessionId: restored.sessionId,
}
await this.syncCheckpointRowsFromActiveSession()
await this.postStateToWebview()
}
@@ -1720,6 +1683,16 @@ export class Controller {
.sort((a, b) => b.ts - a.ts)
.slice(0, 100)
let queuedPrompts: ExtensionState["queuedPrompts"] = []
const activeSession = this.sessions.getActiveSession()
if (activeSession) {
try {
queuedPrompts = await activeSession.sdkHost.pendingPrompts("list", { sessionId: activeSession.sessionId })
} catch (error) {
Logger.error("[SdkController] Failed to list pending prompts for webview state:", error)
}
}
// Stamp the snapshot with the current epoch and a fresh monotonic version, sampled
// from the SAME counter that stamps messages. This lets the webview ignore stale
// out-of-order state pushes and fence traffic from a previous task/render. Sampled
@@ -1732,6 +1705,7 @@ export class Controller {
: undefined,
taskHistory: processedTaskHistory,
turnState: this.turnStateTracker.get(),
queuedPrompts,
stateVersion: minter.nextSeq(),
epoch: minter.epoch,
}
@@ -112,11 +112,6 @@ const extrasFields: Partial<Record<string, Partial<Record<string, keyof ApiConfi
awsProfile: "awsProfile",
awsUseProfile: "awsUseProfile",
},
sapaicore: {
sapAiCoreTokenUrl: "sapAiCoreTokenUrl",
sapAiCoreUseOrchestrationMode: "sapAiCoreUseOrchestrationMode",
sapAiResourceGroup: "sapAiResourceGroup",
},
}
function isPlainRecord(value: unknown): value is Record<string, unknown> {
+39 -65
View File
@@ -1,9 +1,9 @@
import type { CheckpointEntry } from "@cline/core"
import type { ClineMessage } from "@shared/ExtensionMessage"
import { describe, expect, it } from "vitest"
import {
buildSdkCheckpointRows,
findVisibleCheckpointUserMessageByRun,
getCheckpointRunCountForMessage,
isCheckpointAnswerMessage,
isVisibleCheckpointUserMessage,
} from "./sdk-checkpoints"
@@ -31,6 +31,14 @@ const assistant = (text: string, ts: number): ClineMessage => ({
partial: false,
})
const followupAsk = (text: string, ts: number): ClineMessage => ({
ts,
type: "ask",
ask: "followup",
text,
partial: false,
})
const checkpointRow = (runCount: number, ts: number, ref = "old-ref"): ClineMessage => ({
ts,
type: "say",
@@ -40,14 +48,7 @@ const checkpointRow = (runCount: number, ts: number, ref = "old-ref"): ClineMess
lastCheckpointHash: ref,
})
const checkpoint = (runCount: number, ref: string): CheckpointEntry => ({
runCount,
ref,
createdAt: runCount,
kind: "commit",
})
describe("SDK checkpoint UI mapping", () => {
describe("SDK checkpoint user-run mapping", () => {
it("recognizes only visible user messages", () => {
expect(isVisibleCheckpointUserMessage(userTask("start", 1))).toBe(true)
expect(isVisibleCheckpointUserMessage(userFeedback("continue", 2))).toBe(true)
@@ -63,64 +64,37 @@ describe("SDK checkpoint UI mapping", () => {
expect(findVisibleCheckpointUserMessageByRun(messages, 3)).toBeUndefined()
})
it("inserts checkpoint rows after visible user messages", () => {
let ts = 100
const rows = buildSdkCheckpointRows({
messages: [userTask("start", 1), assistant("done", 2), userFeedback("next", 3)],
checkpointHistory: [checkpoint(1, "ref-a"), checkpoint(2, "ref-b")],
createTimestamp: () => ts++,
})
it("does not count ask_question answers as checkpoint runs", () => {
const messages = [
userTask("start", 1),
checkpointRow(1, 2),
followupAsk("which file?", 3),
userFeedback("src/index.ts", 4),
assistant("ok", 5),
userFeedback("next task", 6),
]
expect(rows.map((message) => [message.say, message.conversationHistoryIndex, message.lastCheckpointHash])).toEqual([
["task", undefined, undefined],
["checkpoint_created", 1, "ref-a"],
["text", undefined, undefined],
["user_feedback", undefined, undefined],
["checkpoint_created", 2, "ref-b"],
])
expect(rows[1].ts).toBe(100)
expect(rows[4].ts).toBe(101)
expect(isCheckpointAnswerMessage(messages, 3)).toBe(true)
expect(getCheckpointRunCountForMessage(messages, 0)).toBe(1)
expect(getCheckpointRunCountForMessage(messages, 3)).toBeUndefined()
expect(getCheckpointRunCountForMessage(messages, 5)).toBe(2)
expect(findVisibleCheckpointUserMessageByRun(messages, 2)?.message.text).toBe("next task")
})
it("maps deduped checkpoints to later user messages using the nearest earlier checkpoint", () => {
const rows = buildSdkCheckpointRows({
messages: [userTask("start", 1), userFeedback("no file changes", 2), userFeedback("new changes", 3)],
checkpointHistory: [checkpoint(1, "ref-a"), checkpoint(3, "ref-c")],
createTimestamp: () => 100,
})
it("keeps ask answers tied to the ask when assistant rows arrive between them", () => {
const messages = [
userTask("start", 1),
followupAsk("which file?", 2),
assistant("Let me know the file path.", 3),
checkpointRow(1, 4),
userFeedback("src/index.ts", 5),
userFeedback("next task", 6),
]
expect(rows.map((message) => [message.say, message.conversationHistoryIndex, message.lastCheckpointHash])).toEqual([
["task", undefined, undefined],
["checkpoint_created", 1, "ref-a"],
["user_feedback", undefined, undefined],
["checkpoint_created", 2, "ref-a"],
["user_feedback", undefined, undefined],
["checkpoint_created", 3, "ref-c"],
])
})
it("preserves existing checkpoint row identity fields while refreshing SDK metadata", () => {
const rows = buildSdkCheckpointRows({
messages: [userTask("start", 1), checkpointRow(1, 42, "stale-ref")],
checkpointHistory: [checkpoint(1, "fresh-ref")],
createTimestamp: () => 100,
})
expect(rows[1]).toMatchObject({
ts: 42,
say: "checkpoint_created",
conversationHistoryIndex: 1,
lastCheckpointHash: "fresh-ref",
})
})
it("removes stale checkpoint rows when the SDK session has no checkpoint history", () => {
const rows = buildSdkCheckpointRows({
messages: [userTask("start", 1), checkpointRow(1, 2), assistant("done", 3)],
checkpointHistory: [],
createTimestamp: () => 100,
})
expect(rows.map((message) => message.say)).toEqual(["task", "text"])
expect(isCheckpointAnswerMessage(messages, 4)).toBe(true)
expect(getCheckpointRunCountForMessage(messages, 4)).toBeUndefined()
expect(getCheckpointRunCountForMessage(messages, 5)).toBe(2)
expect(findVisibleCheckpointUserMessageByRun(messages, 1)?.message.text).toBe("start")
expect(findVisibleCheckpointUserMessageByRun(messages, 2)?.message.text).toBe("next task")
})
})
+41 -51
View File
@@ -1,10 +1,49 @@
import { findCheckpointForRun, type CheckpointEntry } from "@cline/core"
import type { ClineMessage } from "@shared/ExtensionMessage"
export function isVisibleCheckpointUserMessage(message: ClineMessage): boolean {
return message.type === "say" && (message.say === "task" || message.say === "user_feedback")
}
export function isCheckpointAnswerMessage(messages: ClineMessage[], index: number): boolean {
const message = messages[index]
if (message?.type !== "say" || message.say !== "user_feedback") {
return false
}
for (let cursor = index - 1; cursor >= 0; cursor -= 1) {
const previous = messages[cursor]
if (previous.say === "checkpoint_created") {
continue
}
if (previous.type === "ask") {
return previous.ask === "followup" || previous.ask === "mistake_limit_reached"
}
if (isVisibleCheckpointUserMessage(previous)) {
return false
}
}
return false
}
export function isCheckpointRunUserMessage(messages: ClineMessage[], index: number): boolean {
return isVisibleCheckpointUserMessage(messages[index]) && !isCheckpointAnswerMessage(messages, index)
}
export function getCheckpointRunCountForMessage(messages: ClineMessage[], targetIndex: number): number | undefined {
if (!isCheckpointRunUserMessage(messages, targetIndex)) {
return undefined
}
let runCount = 0
for (let index = 0; index <= targetIndex; index += 1) {
if (isCheckpointRunUserMessage(messages, index)) {
runCount += 1
}
}
return runCount
}
export function findVisibleCheckpointUserMessageByRun(
messages: ClineMessage[],
runCount: number,
@@ -12,7 +51,7 @@ export function findVisibleCheckpointUserMessageByRun(
let seenUsers = 0
for (let index = 0; index < messages.length; index += 1) {
const message = messages[index]
if (!isVisibleCheckpointUserMessage(message)) {
if (!isCheckpointRunUserMessage(messages, index)) {
continue
}
seenUsers += 1
@@ -22,52 +61,3 @@ export function findVisibleCheckpointUserMessageByRun(
}
return undefined
}
export function buildSdkCheckpointRows(input: {
messages: ClineMessage[]
checkpointHistory: readonly CheckpointEntry[]
createTimestamp: () => number
}): ClineMessage[] {
const { checkpointHistory, createTimestamp, messages } = input
if (checkpointHistory.length === 0) {
return messages.filter((message) => message.say !== "checkpoint_created")
}
const existingRowsByRun = new Map<number, ClineMessage>()
for (const message of messages) {
if (message.say !== "checkpoint_created") {
continue
}
const runCount = message.conversationHistoryIndex
if (typeof runCount === "number" && Number.isInteger(runCount) && runCount > 0) {
existingRowsByRun.set(runCount, message)
}
}
const withoutCheckpointRows = messages.filter((message) => message.say !== "checkpoint_created")
const result: ClineMessage[] = []
let userRunCount = 0
for (const message of withoutCheckpointRows) {
result.push(message)
if (!isVisibleCheckpointUserMessage(message)) {
continue
}
userRunCount += 1
const checkpoint = findCheckpointForRun(checkpointHistory, userRunCount)
if (!checkpoint) {
continue
}
const existing = existingRowsByRun.get(userRunCount)
result.push({
...(existing ?? {
ts: createTimestamp(),
type: "say" as const,
say: "checkpoint_created" as const,
partial: false,
}),
lastCheckpointHash: checkpoint.ref,
conversationHistoryIndex: userRunCount,
})
}
return result
}
@@ -28,7 +28,6 @@ export interface SdkSessionEventCoordinatorOptions {
taskHistory: SdkTaskHistory
getTask: () => TaskProxy | undefined
postStateToWebview: () => Promise<void>
syncCheckpointRows?: () => Promise<void>
stateManager?: StateManager
translateSessionEvent?: (event: CoreSessionEvent, state: MessageTranslatorState) => TranslationResult
isClineFreeModel?: () => Promise<boolean>
@@ -58,6 +57,12 @@ export class SdkSessionEventCoordinator {
return
}
if (event.type === "pending_prompts") {
this.options.postStateToWebview().catch((err) => {
Logger.error("[SdkController] Failed to post pending-prompt state update:", err)
})
}
const result = this.translateSessionEvent(event, this.options.messageTranslatorState)
const zeroCostPromise = this.zeroCostForFreeClineModel(result)
if (zeroCostPromise) {
@@ -120,10 +125,6 @@ export class SdkSessionEventCoordinator {
}
}
if (result.sessionEnded || result.turnComplete) {
await this.options.syncCheckpointRows?.()
}
// Post state when there are messages to ship OR when the turn ended. A clean turn end's
// `done` event carries no transcript message, yet the authoritative phase just changed to
// completed/awaiting_followup/error above; without posting here the webview would stay on
@@ -1,5 +1,5 @@
import { describe, expect, it } from "bun:test"
import { DEFAULT_AUTO_APPROVAL_SETTINGS } from "@shared/AutoApprovalSettings"
import { describe, expect, it } from "vitest"
import { isToolAutoApproved } from "./sdk-tool-policies"
describe("isToolAutoApproved", () => {
-3
View File
@@ -1,8 +1,6 @@
import type {
ClineCoreListHistoryOptions,
ClineCoreStartInput,
CompareCheckpointInput,
CompareCheckpointResult,
CoreSessionEvent,
HookEventPayload,
PendingPromptMutationResult,
@@ -36,7 +34,6 @@ export interface SdkSessionHost {
delete(sessionId: string): Promise<boolean>
readMessages(sessionId: string): Promise<SdkInitialMessages>
restore(input: RestoreInput): Promise<RestoreResult>
compareCheckpoint(input: CompareCheckpointInput): Promise<CompareCheckpointResult>
update(
sessionId: string,
updates: {
+1 -7
View File
@@ -8,8 +8,6 @@ import {
ClineCore,
type ClineCoreListHistoryOptions,
type ClineCoreStartInput,
type CompareCheckpointInput,
type CompareCheckpointResult,
type CoreSessionEvent,
type HookEventPayload,
type ITelemetryService,
@@ -17,9 +15,9 @@ import {
type PendingPromptsDeleteInput,
type PendingPromptsListInput,
type PendingPromptsUpdateInput,
type PreparedRemoteConfigCoreIntegration,
type RestoreInput,
type RestoreResult,
type PreparedRemoteConfigCoreIntegration,
type SendSessionInput,
type SessionAccumulatedUsage,
type SessionHistoryRecord,
@@ -205,10 +203,6 @@ export class VscodeSessionHost implements SdkSessionHost {
return this.inner.restore(input)
}
async compareCheckpoint(input: CompareCheckpointInput): Promise<CompareCheckpointResult> {
return this.inner.compareCheckpoint(input)
}
async update(
sessionId: string,
updates: {
+16 -11
View File
@@ -1,3 +1,8 @@
import {
getClineOrgIndividualInferenceSubscriptionMessage,
isClineNotSubscribedMessage,
isClineOrgIndividualInferenceSubscriptionMessage,
} from "@cline/llms"
import { serializeError } from "serialize-error"
import { CLINE_ACCOUNT_AUTH_ERROR_MESSAGE } from "../../shared/ClineAccount"
@@ -45,8 +50,6 @@ interface ErrorDetails {
}
const RATE_LIMIT_PATTERNS = [/status code 429/i, /rate limit/i, /too many requests/i, /quota exceeded/i, /resource exhausted/i]
const ORG_CLINE_PASS_RESTRICTION_MESSAGE = "organization accounts cannot use individual model inference subscriptions"
const ORG_CLINE_PASS_RESTRICTION_USER_MESSAGE = "organization accounts cannot use clinepass subscriptions"
export class ClineError extends Error {
readonly title = "ClineError"
@@ -142,7 +145,9 @@ export class ClineError extends Error {
*/
static getErrorType(err: ClineError): ClineErrorType | undefined {
const { code, status, details } = err._error
const message = (err._error?.message || err.message || JSON.stringify(err._error))?.toLowerCase()
const rawMessage = err._error?.message || err.message || JSON.stringify(err._error)
const message = rawMessage?.toLowerCase()
const detailMessage = typeof details?.message === "string" ? details.message : undefined
// Check balance error first (most specific)
if (code === "insufficient_credits" && typeof details?.current_balance === "number") {
@@ -155,18 +160,18 @@ export class ClineError extends Error {
return ClineErrorType.SpendLimit
}
// ClinePass entitlement errors are user-actionable and should not fall through to generic 403 auth.
// The organization-account variant gets separate copy because subscribing is not the right action.
const isEntitlementCode = code === "ENTITLEMENT_ERROR" || details?.code === "ENTITLEMENT_ERROR"
const entitlementText = `${message ?? ""} ${details?.message ?? ""}`.toLowerCase()
if (
isEntitlementCode &&
(entitlementText.includes(ORG_CLINE_PASS_RESTRICTION_MESSAGE) ||
entitlementText.includes(ORG_CLINE_PASS_RESTRICTION_USER_MESSAGE))
rawMessage === getClineOrgIndividualInferenceSubscriptionMessage() ||
(detailMessage ? isClineOrgIndividualInferenceSubscriptionMessage(detailMessage) : false) ||
(rawMessage ? isClineOrgIndividualInferenceSubscriptionMessage(rawMessage) : false)
) {
return ClineErrorType.OrgClinePassRestriction
}
if (isEntitlementCode && entitlementText.includes("not subscribed to required model plan")) {
if (
(detailMessage ? isClineNotSubscribedMessage(detailMessage) : false) ||
(rawMessage ? isClineNotSubscribedMessage(rawMessage) : false)
) {
return ClineErrorType.Entitlement
}
@@ -9,72 +9,40 @@ describe("ClineError", () => {
ClineError.getErrorType(err)!.should.equal(ClineErrorType.QuotaExceeded)
})
it("should return Entitlement when code is ENTITLEMENT_ERROR", () => {
const err = new ClineError({
message: "403 Error 403: the user is not subscribed to required model plan",
code: "ENTITLEMENT_ERROR",
status: 403,
})
ClineError.getErrorType(err)!.should.equal(ClineErrorType.Entitlement)
})
it("should return Entitlement when details.code is ENTITLEMENT_ERROR", () => {
const err = new ClineError({
message: "403 Error 403: the user is not subscribed to required model plan",
status: 403,
details: { code: "ENTITLEMENT_ERROR", message: "Error 403: the user is not subscribed to required model plan" },
})
ClineError.getErrorType(err)!.should.equal(ClineErrorType.Entitlement)
})
it("should prefer Entitlement over Auth for 403 ENTITLEMENT_ERROR", () => {
// status 403 would otherwise be classified as Auth; the entitlement code must win.
const err = new ClineError({
message: "403 Error 403: the user is not subscribed to required model plan",
code: "ENTITLEMENT_ERROR",
status: 403,
})
ClineError.getErrorType(err)!.should.not.equal(ClineErrorType.Auth)
ClineError.getErrorType(err)!.should.equal(ClineErrorType.Entitlement)
})
it("should return Entitlement for the real Cline 403 provider error shape (nested error object)", () => {
// ClineError maps `error.error` into `details`, so `details.code` drives classification.
it("should return Entitlement for the SDK ClinePass subscription message", () => {
const err = new ClineError(
{
status: 403,
error: {
code: "ENTITLEMENT_ERROR",
message: "Error 403: the user is not subscribed to required model plan",
},
},
"cline-pass/glm-5.1",
"cline-pass",
"No access to ClinePass subscription models yet. Subscribe to ClinePass, the low cost open weights model coding plan: https://app.cline.bot/promo?code=CLI-100&personal=true",
)
ClineError.getErrorType(err)!.should.equal(ClineErrorType.Entitlement)
})
it("should classify the organization ENTITLEMENT_ERROR variant separately from the ClinePass subscription card", () => {
// Org accounts can't use individual subs; this case should not show the personal ClinePass
// subscription card, but it should still get dedicated user-actionable copy.
const err = new ClineError({
message: "403 Error 403: organization accounts cannot use individual model inference subscriptions",
code: "ENTITLEMENT_ERROR",
status: 403,
})
const result = ClineError.getErrorType(err)
result!.should.equal(ClineErrorType.OrgClinePassRestriction)
;(result !== ClineErrorType.Entitlement).should.be.true()
it("should return Entitlement for the SDK ClinePass subscription message with a different app URL", () => {
const err = new ClineError(
"No access to ClinePass subscription models yet. Subscribe to ClinePass, the low cost open weights model coding plan: https://staging-app.cline.bot/promo?code=CLI-100&personal=true",
)
ClineError.getErrorType(err)!.should.equal(ClineErrorType.Entitlement)
})
it("should not classify organization restriction text without ENTITLEMENT_ERROR as OrgClinePassRestriction", () => {
const err = new ClineError({
message: "Network error: organization accounts cannot use individual model inference subscriptions",
code: "ERR_NETWORK",
})
it("should return Entitlement for the raw required-plan message", () => {
const err = new ClineError("403 Error 403: the user is not subscribed to required model plan")
const result = ClineError.getErrorType(err)
;(result !== ClineErrorType.OrgClinePassRestriction).should.be.true()
ClineError.getErrorType(err)!.should.equal(ClineErrorType.Entitlement)
})
it("should classify the SDK org individual subscription message separately", () => {
const err = new ClineError(
"Organization accounts cannot use ClinePass subscriptions. Go to /account -> change account to switch to your personal account for ClinePass",
)
ClineError.getErrorType(err)!.should.equal(ClineErrorType.OrgClinePassRestriction)
})
it("should classify the raw organization individual subscription message separately", () => {
const err = new ClineError("403 Error 403: organization accounts cannot use individual model inference subscriptions")
ClineError.getErrorType(err)!.should.equal(ClineErrorType.OrgClinePassRestriction)
})
})
})
@@ -58,6 +58,12 @@ export interface ExtensionState {
* Optional for classic/legacy (absent => webview falls back to legacy tail heuristics).
*/
turnState?: TurnState
/**
* Follow-up prompts submitted while the active agent turn is still running.
* These are owned by the SDK pending-prompt queue and are sent after the
* current turn reaches a safe continuation point.
*/
queuedPrompts?: QueuedPrompt[]
/**
* Monotonic version of this state snapshot. The webview applies a snapshot only if its
* stateVersion is newer than the last applied, so stale/out-of-order state pushes are
@@ -149,6 +155,13 @@ export interface TurnState {
seq: number
}
export interface QueuedPrompt {
id: string
prompt: string
delivery: "queue" | "steer"
attachmentCount: number
}
export interface ClineMessage {
ts: number
type: "ask" | "say"
@@ -34,8 +34,8 @@ import {
} from "lucide-react"
import { MouseEvent, memo, useCallback, useEffect, useMemo, useRef, useState } from "react"
import { useSize } from "react-use"
import { canRestoreWorkspaceFromMessage } from "@/components/chat/chat-view/utils/messageUtils"
import { OptionsButtons } from "@/components/chat/OptionsButtons"
import { CheckmarkControl } from "@/components/common/CheckmarkControl"
import { WithCopyButton } from "@/components/common/CopyButton"
import McpResponseDisplay from "@/components/mcp/chat-display/McpResponseDisplay"
import McpResourceRow from "@/components/mcp/configuration/tabs/installed/server-row/McpResourceRow"
@@ -71,9 +71,9 @@ interface ChatRowProps {
lastModifiedMessage?: ClineMessage
isLast: boolean
onHeightChange: (isTaller: boolean) => void
onLastRowContentChange: () => void
inputValue?: string
sendMessageFromChatRow?: (text: string, images: string[], files: string[]) => void
onOptimisticUserMessage?: (text: string, images?: string[], files?: string[]) => () => void
onSetQuote: (text: string) => void
onCancelCommand?: () => void
mode?: Mode
@@ -89,7 +89,9 @@ export interface QuoteButtonState {
selectedText: string
}
interface ChatRowContentProps extends Omit<ChatRowProps, "onHeightChange"> {}
interface ChatRowContentProps extends Omit<ChatRowProps, "onHeightChange" | "onLastRowContentChange"> {
onLastRowContentChange?: () => void
}
export const ProgressIndicator = () => <LoaderCircleIcon className="size-2 mr-2 animate-spin" />
const InvisibleSpacer = () => <div aria-hidden className="h-px" />
@@ -138,9 +140,9 @@ export const ChatRowContent = memo(
isLast,
inputValue,
sendMessageFromChatRow,
onOptimisticUserMessage,
onSetQuote,
onCancelCommand,
onLastRowContentChange,
mode,
isRequestInProgress,
reasoningContent,
@@ -745,6 +747,7 @@ export const ChatRowContent = memo(
isOutputFullyExpanded={isOutputFullyExpanded}
message={message}
onCancelCommand={onCancelCommand}
onOutputChange={isLast ? onLastRowContentChange : undefined}
setIsOutputFullyExpanded={setIsOutputFullyExpanded}
title={title}
/>
@@ -895,6 +898,7 @@ export const ChatRowContent = memo(
case "user_feedback":
return (
<UserMessage
canRestoreWorkspace={canRestoreWorkspaceFromMessage(clineMessages, message.ts)}
files={message.files}
images={message.images}
messageTs={message.ts}
@@ -920,14 +924,6 @@ export const ChatRowContent = memo(
return <ErrorRow errorType="diff_error" message={message} />
case "clineignore_error":
return <ErrorRow errorType="clineignore_error" message={message} />
case "checkpoint_created":
return (
<CheckmarkControl
checkpointNumber={message.conversationHistoryIndex}
isCheckpointCheckedOut={message.isCheckpointCheckedOut}
messageTs={message.ts}
/>
)
case "load_mcp_documentation":
return (
<div className="text-foreground flex items-center opacity-70 text-[12px] py-1 px-0">
@@ -1137,7 +1133,6 @@ export const ChatRowContent = memo(
(isLast && lastModifiedMessage?.ask === "followup") ||
(!selected && options && options.length > 0)
}
onOptimisticUserMessage={onOptimisticUserMessage}
options={options}
selected={selected}
/>
@@ -1199,7 +1194,6 @@ export const ChatRowContent = memo(
(isLast && lastModifiedMessage?.ask === "plan_mode_respond") ||
(!selected && options && options.length > 0)
}
onOptimisticUserMessage={onOptimisticUserMessage}
options={options}
selected={selected}
/>
@@ -3,9 +3,8 @@ import { combineCommandSequences } from "@shared/combineCommandSequences"
import { combineErrorRetryMessages } from "@shared/combineErrorRetryMessages"
import { combineHookSequences } from "@shared/combineHookSequences"
import { getApiMetrics, getLastApiReqTotalTokens } from "@shared/getApiMetrics"
import type { ClineMessage } from "@shared/ExtensionMessage"
import { BooleanRequest, StringRequest } from "@shared/proto/cline/common"
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
import { useCallback, useEffect, useMemo, useRef } from "react"
import { useMount } from "react-use"
import { useExtensionState } from "@/context/ExtensionStateContext"
import { useShowNavbar } from "@/context/PlatformContext"
@@ -24,6 +23,7 @@ import {
groupMessages,
InputSection,
MessagesArea,
QueuedPrompts,
TaskSection,
useChatState,
useMessageHandlers,
@@ -42,23 +42,6 @@ interface ChatViewProps {
const MAX_IMAGES_AND_FILES_PER_MESSAGE = CHAT_CONSTANTS.MAX_IMAGES_AND_FILES_PER_MESSAGE
const QUICK_WINS_HISTORY_THRESHOLD = 3
const sameStringList = (a?: string[], b?: string[]) => {
const left = a ?? []
const right = b ?? []
return left.length === right.length && left.every((value, index) => value === right[index])
}
const hasAuthoritativeUserMessage = (messages: ClineMessage[], optimisticMessage: ClineMessage) => {
return messages.some(
(message) =>
message.type === "say" &&
message.say === "user_feedback" &&
message.text === optimisticMessage.text &&
sameStringList(message.images, optimisticMessage.images) &&
sameStringList(message.files, optimisticMessage.files),
)
}
const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryView }: ChatViewProps) => {
const showNavbar = useShowNavbar()
const {
@@ -70,61 +53,19 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
userInfo,
hooksEnabled,
checkpointRestoreInput,
queuedPrompts,
} = useExtensionState()
const isProdHostedApp = userInfo?.apiBaseUrl === "https://app.cline.bot"
const shouldShowQuickWins = isProdHostedApp && (!taskHistory || taskHistory.length < QUICK_WINS_HISTORY_THRESHOLD)
const [optimisticUserMessages, setOptimisticUserMessages] = useState<ClineMessage[]>([])
const optimisticMessageIdRef = useRef(0)
//const task = messages.length > 0 ? (messages[0].say === "task" ? messages[0] : undefined) : undefined) : undefined
const task = useMemo(() => messages.at(0), [messages]) // leaving this less safe version here since if the first message is not a task, then the extension is in a bad state and needs to be debugged (see Cline.abort)
const addOptimisticUserMessage = useCallback((text: string, images?: string[], files?: string[]) => {
const hasText = !!text.trim()
const hasImages = !!images?.length
const hasFiles = !!files?.length
if (!hasText && !hasImages && !hasFiles) {
return () => {}
}
const optimisticMessage: ClineMessage = {
ts: -(Date.now() * 1000 + optimisticMessageIdRef.current++),
type: "say",
say: "user_feedback",
text,
images,
files,
partial: false,
}
setOptimisticUserMessages((current) => [...current, optimisticMessage])
return () => {
setOptimisticUserMessages((current) => current.filter((message) => message.ts !== optimisticMessage.ts))
}
}, [])
useEffect(() => {
setOptimisticUserMessages([])
}, [task?.ts])
useEffect(() => {
setOptimisticUserMessages((current) =>
current.filter((optimisticMessage) => !hasAuthoritativeUserMessage(messages, optimisticMessage)),
)
}, [messages])
const displayMessages = useMemo(() => {
if (!task || optimisticUserMessages.length === 0) {
return messages
}
return [...messages, ...optimisticUserMessages]
}, [messages, optimisticUserMessages, task])
const modifiedMessages = useMemo(() => {
const slicedMessages = displayMessages.slice(1)
const slicedMessages = messages.slice(1)
// Only combine hook sequences if hooks are enabled
const withHooks = hooksEnabled ? combineHookSequences(slicedMessages) : slicedMessages
return combineErrorRetryMessages(combineApiRequests(combineCommandSequences(withHooks)))
}, [displayMessages, hooksEnabled])
}, [messages, hooksEnabled])
// has to be after api_req_finished are all reduced into api_req_started messages
const apiMetrics = useMemo(() => getApiMetrics(modifiedMessages), [modifiedMessages])
@@ -250,7 +191,7 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
// handleFocusChange is already provided by chatState
// Use message handlers hook
const messageHandlers = useMessageHandlers(messages, chatState, { addOptimisticUserMessage })
const messageHandlers = useMessageHandlers(messages, chatState)
const { selectedModelInfo } = useNormalizedApiConfiguration(mode)
@@ -373,7 +314,7 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
}, [visibleMessages])
// Use scroll behavior hook
const scrollBehavior = useScrollBehavior(displayMessages, visibleMessages, groupedMessages, expandedRows, setExpandedRows)
const scrollBehavior = useScrollBehavior(messages, visibleMessages, groupedMessages, expandedRows, setExpandedRows)
const placeholderText = useMemo(() => {
const text = task ? "Type a message..." : "Type your task here..."
@@ -426,6 +367,7 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
mode={mode}
task={task}
/>
<QueuedPrompts items={queuedPrompts} />
<InputSection
chatState={chatState}
messageHandlers={messageHandlers}
@@ -0,0 +1,79 @@
import { act, render, waitFor } from "@testing-library/react"
import { describe, expect, it, vi } from "vitest"
import { CommandOutputContent } from "./CommandOutputRow"
vi.mock("../common/CodeBlock", () => ({
default: ({ source }: { source: string }) => <pre>{source}</pre>,
}))
describe("CommandOutputContent", () => {
it("notifies when visible output changes", async () => {
const onOutputChange = vi.fn()
const { rerender } = render(
<CommandOutputContent
isContainerExpanded={true}
isOutputFullyExpanded={false}
onOutputChange={onOutputChange}
onToggle={vi.fn()}
output="first line"
/>,
)
await waitFor(() => expect(onOutputChange).toHaveBeenCalledTimes(1))
rerender(
<CommandOutputContent
isContainerExpanded={true}
isOutputFullyExpanded={false}
onOutputChange={onOutputChange}
onToggle={vi.fn()}
output={"first line\nsecond line"}
/>,
)
await waitFor(() => expect(onOutputChange).toHaveBeenCalledTimes(2))
})
it("notifies when visible output expansion changes", async () => {
const onOutputChange = vi.fn()
const { rerender } = render(
<CommandOutputContent
isContainerExpanded={true}
isOutputFullyExpanded={false}
onOutputChange={onOutputChange}
onToggle={vi.fn()}
output={"1\n2\n3\n4\n5\n6"}
/>,
)
await waitFor(() => expect(onOutputChange).toHaveBeenCalledTimes(1))
rerender(
<CommandOutputContent
isContainerExpanded={true}
isOutputFullyExpanded={true}
onOutputChange={onOutputChange}
onToggle={vi.fn()}
output={"1\n2\n3\n4\n5\n6"}
/>,
)
await waitFor(() => expect(onOutputChange).toHaveBeenCalledTimes(2))
})
it("does not notify while the container is collapsed", async () => {
const onOutputChange = vi.fn()
render(
<CommandOutputContent
isContainerExpanded={false}
isOutputFullyExpanded={false}
onOutputChange={onOutputChange}
onToggle={vi.fn()}
output="hidden"
/>,
)
await act(async () => {})
expect(onOutputChange).not.toHaveBeenCalled()
})
})
@@ -14,11 +14,13 @@ export const CommandOutputContent = memo(
isOutputFullyExpanded,
onToggle,
isContainerExpanded,
onOutputChange,
}: {
output: string
isOutputFullyExpanded: boolean
onToggle: () => void
isContainerExpanded: boolean
onOutputChange?: () => void
}) => {
const outputLines = output.split("\n")
const lineCount = outputLines.length
@@ -40,6 +42,12 @@ export const CommandOutputContent = memo(
}
}, [output, isOutputFullyExpanded])
useEffect(() => {
if (isContainerExpanded) {
onOutputChange?.()
}
}, [output, isOutputFullyExpanded, isContainerExpanded, onOutputChange])
// Don't render anything if container is collapsed
if (!isContainerExpanded) {
return null
@@ -118,6 +126,7 @@ export const CommandOutputRow = memo(
title,
isOutputFullyExpanded,
setIsOutputFullyExpanded,
onOutputChange,
}: {
message: ClineMessage
isCommandExecuting?: boolean
@@ -129,6 +138,7 @@ export const CommandOutputRow = memo(
title?: JSX.Element | null
isOutputFullyExpanded: boolean
setIsOutputFullyExpanded: (expanded: boolean) => void
onOutputChange?: () => void
}) => {
const splitMessage = (text: string) => {
const outputIndex = text.indexOf(COMMAND_OUTPUT_STRING)
@@ -230,6 +240,7 @@ export const CommandOutputRow = memo(
isContainerExpanded={true}
isOutputFullyExpanded={isOutputFullyExpanded}
onToggle={() => setIsOutputFullyExpanded(!isOutputFullyExpanded)}
onOutputChange={onOutputChange}
output={output}
/>
)}
@@ -58,11 +58,13 @@ export const ErrorBlockTitle = ({
} else if (apiRequestFailedMessage) {
// Handle failed request
const clineError = ClineError.parse(apiRequestFailedMessage)
const titleText = clineError?.isErrorType(ClineErrorType.Balance)
? "Credit Limit Reached"
: clineError?.isErrorType(ClineErrorType.SpendLimit)
? "Spend Limit Reached"
: "API Request Failed"
const titleText = clineError?.isErrorType(ClineErrorType.Entitlement)
? "ClinePass Required"
: clineError?.isErrorType(ClineErrorType.Balance)
? "Credit Limit Reached"
: clineError?.isErrorType(ClineErrorType.SpendLimit)
? "Spend Limit Reached"
: "API Request Failed"
details.title = titleText
details.classNames.push("font-bold text-(--vscode-errorForeground)")
} else if (retryStatus) {
@@ -219,22 +219,13 @@ export const ClinePassEntitlementError: Story = {
args: {
message: createMockMessage(),
errorType: "error",
apiRequestFailedMessage: JSON.stringify({
message: "403 Error 403: the user is not subscribed to required model plan",
status: 403,
code: "ENTITLEMENT_ERROR",
modelId: "cline-pass/glm-5.1",
providerId: "cline-pass",
details: {
code: "ENTITLEMENT_ERROR",
message: "Error 403: the user is not subscribed to required model plan",
},
}),
apiRequestFailedMessage:
"No access to ClinePass subscription models yet. Subscribe to ClinePass, the low cost open weights model coding plan: https://app.cline.bot/promo?code=CLI-100&personal=true",
},
parameters: {
docs: {
description: {
story: "ClinePass model returns a 403 ENTITLEMENT_ERROR when the user is not subscribed. Instead of dumping the raw JSON blob, a human-readable message with a 'Get ClinePass' subscribe link and a retry button is shown.",
story: "ClinePass model returns the SDK ClineNotSubscribedError message when the user is not subscribed. A human-readable message with a 'Get ClinePass' subscribe link and a retry button is shown.",
},
},
},
@@ -43,6 +43,7 @@ vi.mock("../../../../src/services/error/ClineError", () => ({
Auth: "auth",
Entitlement: "entitlement",
OrgClinePassRestriction: "orgClinePassRestriction",
QuotaExceeded: "quotaExceeded",
},
}))
@@ -168,36 +169,46 @@ describe("ErrorRow", () => {
expect(screen.getByText("Inference cap reached")).toBeInTheDocument()
})
it("renders entitlement error with the detail message instead of a raw JSON blob", async () => {
it("renders entitlement error when ClineError detects ClineNotSubscribedError", async () => {
const cliMessage =
"No access to ClinePass subscription models yet. Subscribe to ClinePass, the low cost open weights model coding plan: https://app.cline.bot/promo?code=CLI-100&personal=true"
const mockClineError = {
message: "403 Error 403: the user is not subscribed to required model plan",
message: cliMessage,
isErrorType: vi.fn((type) => type === "entitlement"),
providerId: "cline-pass",
_error: {
code: "ENTITLEMENT_ERROR",
details: {
code: "ENTITLEMENT_ERROR",
message: "Error 403: the user is not subscribed to required model plan",
},
message: cliMessage,
},
}
const { ClineError } = await import("../../../../src/services/error/ClineError")
vi.mocked(ClineError.parse).mockReturnValue(mockClineError as any)
render(
<ErrorRow
apiRequestFailedMessage='{"message":"403 Error 403...","code":"ENTITLEMENT_ERROR"}'
errorType="error"
message={mockMessage}
/>,
)
render(<ErrorRow apiRequestFailedMessage={cliMessage} errorType="error" message={mockMessage} />)
// Renders the friendly EntitlementError component with the human-readable detail message...
expect(screen.getByTestId("entitlement-error")).toBeInTheDocument()
expect(screen.getByText("Error 403: the user is not subscribed to required model plan")).toBeInTheDocument()
// ...and does not dump the raw JSON blob or the [CLINE-PASS] ENTITLEMENT_ERROR header.
expect(screen.queryByText(/ENTITLEMENT_ERROR/)).not.toBeInTheDocument()
expect(screen.getByText(cliMessage)).toBeInTheDocument()
expect(screen.queryByText(/\[cline-pass\]/i)).not.toBeInTheDocument()
})
it("renders entitlement error when ClineError detects a raw required-plan message", async () => {
const rawMessage = "403 Error 403: the user is not subscribed to required model plan"
const mockClineError = {
message: rawMessage,
isErrorType: vi.fn((type) => type === "entitlement"),
providerId: "cline-pass",
_error: {
message: rawMessage,
},
}
const { ClineError } = await import("../../../../src/services/error/ClineError")
vi.mocked(ClineError.parse).mockReturnValue(mockClineError as any)
render(<ErrorRow apiRequestFailedMessage={rawMessage} errorType="error" message={mockMessage} />)
expect(screen.getByTestId("entitlement-error")).toBeInTheDocument()
expect(screen.getByText(rawMessage)).toBeInTheDocument()
})
it("renders organization account ClinePass restriction with friendly account switching copy", async () => {
@@ -207,7 +218,6 @@ describe("ErrorRow", () => {
isErrorType: vi.fn((type) => type === "orgClinePassRestriction"),
providerId: "cline",
_error: {
code: "ENTITLEMENT_ERROR",
message: rawMessage,
},
}
@@ -227,6 +237,27 @@ describe("ErrorRow", () => {
expect(screen.getByText("Switched to personal account")).toBeInTheDocument()
})
it("renders organization ClinePass restriction when ClineError detects the SDK formatted message", async () => {
const formattedMessage =
"Organization accounts cannot use ClinePass subscriptions. Go to /account -> change account to switch to your personal account for ClinePass"
const mockClineError = {
message: formattedMessage,
isErrorType: vi.fn((type) => type === "orgClinePassRestriction"),
providerId: "cline-pass",
_error: {
message: formattedMessage,
},
}
const { ClineError } = await import("../../../../src/services/error/ClineError")
vi.mocked(ClineError.parse).mockReturnValue(mockClineError as any)
render(<ErrorRow apiRequestFailedMessage={formattedMessage} errorType="error" message={mockMessage} />)
expect(screen.getByTestId("org-cline-pass-restriction-error")).toBeInTheDocument()
expect(screen.queryByText(formattedMessage)).not.toBeInTheDocument()
})
it("renders friendly logged-out message and sign in button when user is not signed in", async () => {
const mockClineError = {
message: "Authentication failed",
@@ -1,89 +1,61 @@
import { act, fireEvent, render, screen } from "@testing-library/react"
import { fireEvent, render, screen, waitFor } from "@testing-library/react"
import { beforeEach, describe, expect, it, vi } from "vitest"
import { OptionsButtons } from "./OptionsButtons"
const askResponse = vi.fn().mockResolvedValue(undefined)
const askResponseMock = vi.hoisted(() => vi.fn())
vi.mock("@/services/grpc-client", () => ({
TaskServiceClient: {
askResponse: (req: unknown) => askResponse(req),
askResponse: askResponseMock,
},
}))
vi.mock("@shared/proto/cline/task", () => ({
AskResponseRequest: { create: (x: unknown) => x },
}))
describe("OptionsButtons", () => {
beforeEach(() => {
askResponse.mockReset()
askResponse.mockResolvedValue(undefined)
vi.clearAllMocks()
})
it("latches the selected option and renders optimistic feedback while askResponse is pending", async () => {
let resolveAskResponse: () => void = () => {}
const removeOptimisticUserMessage = vi.fn()
const onOptimisticUserMessage = vi.fn(() => removeOptimisticUserMessage)
askResponse.mockImplementationOnce(
() =>
new Promise<void>((resolve) => {
resolveAskResponse = resolve
}),
)
it("removes hover affordance from the other options immediately after a selection", async () => {
askResponseMock.mockReturnValue(new Promise(() => undefined))
render(
<OptionsButtons
inputValue="extra detail"
isActive
onOptimisticUserMessage={onOptimisticUserMessage}
options={["First", "Second"]}
/>,
)
render(<OptionsButtons isActive options={["Use this", "Use that"]} />)
await act(async () => {
fireEvent.click(screen.getByRole("button", { name: "First" }))
await Promise.resolve()
const selectedButton = screen.getByRole("button", { name: "Use this" })
const otherButton = screen.getByRole("button", { name: "Use that" })
expect(getComputedStyle(otherButton).cursor).toBe("pointer")
fireEvent.click(selectedButton)
expect(askResponseMock).toHaveBeenCalledTimes(1)
await waitFor(() => {
expect(getComputedStyle(selectedButton).cursor).toBe("default")
expect(getComputedStyle(otherButton).cursor).toBe("default")
})
fireEvent.click(screen.getByRole("button", { name: "Second" }))
expect(askResponse).toHaveBeenCalledTimes(1)
expect(askResponse).toHaveBeenCalledWith(
expect.objectContaining({
responseType: "messageResponse",
text: "First: extra detail",
}),
)
expect(onOptimisticUserMessage).toHaveBeenCalledWith("First: extra detail", [], [])
expect(removeOptimisticUserMessage).not.toHaveBeenCalled()
fireEvent.click(otherButton)
await act(async () => {
resolveAskResponse()
})
expect(askResponseMock).toHaveBeenCalledTimes(1)
})
it("removes optimistic feedback and unlatches the option when askResponse fails", async () => {
vi.spyOn(console, "error").mockImplementationOnce(() => {})
const removeOptimisticUserMessage = vi.fn()
const onOptimisticUserMessage = vi.fn(() => removeOptimisticUserMessage)
askResponse.mockRejectedValueOnce(new Error("transport down")).mockResolvedValueOnce(undefined)
it("re-enables options after askResponse rejects", async () => {
const consoleError = vi.spyOn(console, "error").mockImplementation(() => undefined)
askResponseMock.mockRejectedValue(new Error("failed"))
render(<OptionsButtons isActive onOptimisticUserMessage={onOptimisticUserMessage} options={["First", "Second"]} />)
render(<OptionsButtons isActive options={["Use this", "Use that"]} />)
await act(async () => {
fireEvent.click(screen.getByRole("button", { name: "First" }))
await Promise.resolve()
})
await act(async () => {
fireEvent.click(screen.getByRole("button", { name: "Second" }))
const selectedButton = screen.getByRole("button", { name: "Use this" })
const otherButton = screen.getByRole("button", { name: "Use that" })
fireEvent.click(selectedButton)
expect(askResponseMock).toHaveBeenCalledTimes(1)
await waitFor(() => {
expect(selectedButton).not.toBeDisabled()
expect(otherButton).not.toBeDisabled()
expect(getComputedStyle(otherButton).cursor).toBe("pointer")
})
expect(removeOptimisticUserMessage).toHaveBeenCalledTimes(1)
expect(askResponse).toHaveBeenCalledTimes(2)
expect(askResponse).toHaveBeenLastCalledWith(
expect.objectContaining({
responseType: "messageResponse",
text: "Second",
}),
)
consoleError.mockRestore()
})
})
@@ -1,5 +1,5 @@
import { AskResponseRequest } from "@shared/proto/cline/task"
import { useEffect, useMemo, useState } from "react"
import { useState } from "react"
import styled from "styled-components"
import { CODE_BLOCK_BG_COLOR } from "@/components/common/CodeBlock"
import { TaskServiceClient } from "@/services/grpc-client"
@@ -29,27 +29,29 @@ export const OptionsButtons = ({
selected,
isActive,
inputValue,
onOptimisticUserMessage,
}: {
options?: string[]
selected?: string
isActive?: boolean
inputValue?: string
onOptimisticUserMessage?: (text: string, images?: string[], files?: string[]) => () => void
}) => {
const [pendingSelected, setPendingSelected] = useState<string | undefined>()
const optionsKey = useMemo(() => options?.join("\0") ?? "", [options])
const effectiveSelected = selected ?? pendingSelected
const hasSelected = effectiveSelected !== undefined && !!options?.includes(effectiveSelected)
const optionItems = options ?? []
const optionsKey = optionItems.join("\u0000")
const optimisticSelectionKey = `${selected ?? ""}\u0001${optionsKey}`
const [optimisticSelection, setOptimisticSelection] = useState<{ key: string; option: string }>()
useEffect(() => {
setPendingSelected(undefined)
}, [isActive, selected, optionsKey])
if (!options?.length) {
if (!optionItems.length) {
return null
}
const selectedOption =
selected !== undefined && optionItems.includes(selected)
? selected
: optimisticSelection?.key === optimisticSelectionKey
? optimisticSelection.option
: undefined
const hasSelected = selectedOption !== undefined && optionItems.includes(selectedOption)
return (
<div
style={{
@@ -60,31 +62,29 @@ export const OptionsButtons = ({
{/* <div style={{ color: "var(--vscode-descriptionForeground)", fontSize: "11px", textTransform: "uppercase" }}>
SELECT ONE:
</div> */}
{options.map((option, index) => (
{optionItems.map((option, index) => (
<OptionButton
$isNotSelectable={hasSelected || !isActive}
$isSelected={option === effectiveSelected}
$isSelected={option === selectedOption}
className="options-button"
disabled={hasSelected || !isActive}
id={`options-button-${index}`}
key={option}
onClick={async () => {
if (hasSelected || !isActive) {
return
}
const responseText = option + (inputValue ? `: ${inputValue?.trim()}` : "")
setPendingSelected(option)
const removeOptimisticMessage = onOptimisticUserMessage?.(responseText, [], []) ?? (() => {})
setOptimisticSelection({ key: optimisticSelectionKey, option })
try {
await TaskServiceClient.askResponse(
AskResponseRequest.create({
responseType: "messageResponse",
text: responseText,
text: option + (inputValue ? `: ${inputValue?.trim()}` : ""),
images: [],
}),
)
} catch (error) {
removeOptimisticMessage()
setPendingSelected(undefined)
setOptimisticSelection(undefined)
console.error("Error sending option response:", error)
}
}}>
@@ -2,6 +2,7 @@ import { EditMessageAndRegenerateRequest } from "@shared/proto/cline/task"
import type React from "react"
import { useMemo, useState } from "react"
import Thumbnails from "@/components/common/Thumbnails"
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
import { TaskServiceClient } from "@/services/grpc-client"
import { highlightText } from "./task-header/Highlights"
@@ -11,20 +12,28 @@ interface UserMessageProps {
images?: string[]
messageTs?: number
sendMessageFromChatRow?: (text: string, images: string[], files: string[]) => void
canRestoreWorkspace?: boolean
}
const UserMessage: React.FC<UserMessageProps> = ({ text, images, files, messageTs }) => {
const UserMessage: React.FC<UserMessageProps> = ({ text, images, files, messageTs, canRestoreWorkspace = true }) => {
const [isEditing, setIsEditing] = useState(false)
const [editedText, setEditedText] = useState(text ?? "")
const [isSaving, setIsSaving] = useState(false)
const [savingMode, setSavingMode] = useState<"chat" | "workspace" | undefined>()
const [errorMessage, setErrorMessage] = useState<string | undefined>()
const highlightedText = useMemo(() => highlightText(text), [text])
const canEditMessage = !!messageTs && messageTs > 0
const handleSave = async () => {
if (!canEditMessage || !messageTs || isSaving) {
const startEditing = () => {
setEditedText(text ?? "")
setErrorMessage(undefined)
setIsEditing(true)
}
const handleSave = async (restoreWorkspace: boolean) => {
if (!messageTs || savingMode) {
return
}
setIsSaving(true)
setSavingMode(restoreWorkspace ? "workspace" : "chat")
setErrorMessage(undefined)
try {
await TaskServiceClient.editMessageAndRegenerate(
EditMessageAndRegenerateRequest.create({
@@ -32,60 +41,113 @@ const UserMessage: React.FC<UserMessageProps> = ({ text, images, files, messageT
text: editedText,
images: images ?? [],
files: files ?? [],
restoreWorkspace,
}),
)
setIsEditing(false)
setSavingMode(undefined)
} catch (error) {
console.error("Failed to edit and regenerate message:", error)
setIsSaving(false)
setErrorMessage(error instanceof Error ? error.message : "Failed to edit and regenerate message")
setSavingMode(undefined)
}
}
return (
<div
className="group relative p-2.5 pr-8 my-1 text-badge-foreground rounded-xs"
className={`group relative p-2.5 my-1 text-badge-foreground rounded-xs ${
messageTs && !isEditing ? "cursor-pointer pr-8" : ""
}`}
onClick={messageTs && !isEditing ? startEditing : undefined}
onKeyDown={
messageTs && !isEditing
? (event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault()
startEditing()
}
}
: undefined
}
role={messageTs && !isEditing ? "button" : undefined}
style={{
backgroundColor: "var(--vscode-badge-background)",
whiteSpace: "pre-line",
wordWrap: "break-word",
}}>
{canEditMessage && !isEditing && (
<button
aria-label="Edit and regenerate from this message"
className="absolute right-1.5 top-1.5 opacity-0 group-hover:opacity-80 hover:opacity-100 bg-transparent border-0 text-badge-foreground cursor-pointer p-1"
onClick={() => {
setEditedText(text ?? "")
setIsEditing(true)
}}
title="Edit and regenerate from here"
type="button">
<i className="codicon codicon-edit" />
</button>
}}
tabIndex={messageTs && !isEditing ? 0 : undefined}
title={messageTs && !isEditing ? "Edit and regenerate from here" : undefined}>
{messageTs && !isEditing && (
<Tooltip>
<TooltipContent side="left">Edit and regenerate from here</TooltipContent>
<TooltipTrigger asChild>
<button
aria-label="Edit and regenerate from this message"
className="absolute right-1.5 top-1.5 opacity-0 group-hover:opacity-80 hover:opacity-100 bg-transparent border-0 text-badge-foreground cursor-pointer p-1"
onClick={(event) => {
event.stopPropagation()
startEditing()
}}
type="button">
<i className="codicon codicon-edit" />
</button>
</TooltipTrigger>
</Tooltip>
)}
{isEditing ? (
<div className="flex flex-col gap-2">
<textarea
className="w-full box-border rounded-xs border border-vscode-input-border bg-vscode-input-background text-vscode-input-foreground p-2 text-sm resize-vertical"
disabled={isSaving}
disabled={!!savingMode}
onChange={(event) => setEditedText(event.target.value)}
rows={Math.max(3, editedText.split("\n").length)}
value={editedText}
/>
<div className="flex gap-2 justify-end">
{errorMessage && <div className="text-xs text-(--vscode-errorForeground)">{errorMessage}</div>}
<div className="flex items-center justify-between gap-1.5">
<button
className="px-2 py-1 rounded-xs border border-vscode-button-border bg-transparent text-badge-foreground cursor-pointer"
disabled={isSaving}
className="shrink-0 whitespace-nowrap px-1 py-1 rounded-xs border-0 bg-transparent text-badge-foreground/80 hover:text-badge-foreground cursor-pointer text-xs"
disabled={!!savingMode}
onClick={() => setIsEditing(false)}
type="button">
Cancel
</button>
<button
className="px-2 py-1 rounded-xs border-0 bg-vscode-button-background text-vscode-button-foreground cursor-pointer disabled:opacity-60"
disabled={isSaving}
onClick={handleSave}
type="button">
{isSaving ? "Regenerating..." : "Save & Regenerate"}
</button>
<div className="flex items-center gap-1.5">
<Tooltip>
<TooltipContent side="top">
Regenerate from this edited message without changing files.
</TooltipContent>
<TooltipTrigger asChild>
<span className="inline-flex shrink-0">
<button
className="whitespace-nowrap px-2 py-1 rounded-xs border border-vscode-button-border bg-transparent text-badge-foreground cursor-pointer disabled:opacity-60 text-xs"
disabled={!!savingMode}
onClick={() => handleSave(false)}
type="button">
{savingMode === "chat" ? "Running..." : "Regenerate"}
</button>
</span>
</TooltipTrigger>
</Tooltip>
{canRestoreWorkspace && (
<Tooltip>
<TooltipContent side="top">
Restore workspace files to this checkpoint, then regenerate.
</TooltipContent>
<TooltipTrigger asChild>
<span className="inline-flex shrink-0">
<button
className="whitespace-nowrap px-2 py-1 rounded-xs border border-vscode-button-border bg-transparent text-badge-foreground cursor-pointer disabled:opacity-60 text-xs"
disabled={!!savingMode}
onClick={() => handleSave(true)}
type="button">
{savingMode === "workspace" ? "Restoring..." : "Restore + Run"}
</button>
</span>
</TooltipTrigger>
</Tooltip>
)}
</div>
</div>
</div>
) : (
@@ -49,6 +49,7 @@ export const MessagesArea: React.FC<MessagesAreaProps> = ({
scrollToMessage,
scrollToBottomSmooth,
scrollToBottomAuto,
handleLastRowContentChange,
} = scrollBehavior
// Find the index of the scrolled past user message for scrolling
@@ -247,6 +248,7 @@ export const MessagesArea: React.FC<MessagesAreaProps> = ({
expandedRows,
toggleRowExpansion,
handleRowHeightChange,
handleLastRowContentChange,
setActiveQuote,
inputValue,
messageHandlers,
@@ -258,6 +260,7 @@ export const MessagesArea: React.FC<MessagesAreaProps> = ({
expandedRows,
toggleRowExpansion,
handleRowHeightChange,
handleLastRowContentChange,
setActiveQuote,
inputValue,
messageHandlers,
@@ -0,0 +1,61 @@
import type { QueuedPrompt } from "@shared/ExtensionMessage"
function truncatePrompt(prompt: string): string {
const trimmed = prompt.trim()
return trimmed.length > 96 ? `${trimmed.slice(0, 96)}...` : trimmed
}
function attachmentLabel(count: number): string | undefined {
if (count <= 0) {
return undefined
}
return count === 1 ? "1 attachment" : `${count} attachments`
}
function queueSummary(items: QueuedPrompt[]): string {
const steerCount = items.filter((item) => item.delivery === "steer").length
const queueCount = items.length - steerCount
if (steerCount === 0) {
return items.length === 1 ? "Queued message" : `${items.length} queued messages`
}
if (queueCount === 0) {
return items.length === 1 ? "Steering message" : `${items.length} steering messages`
}
return `${queueCount} queued, ${steerCount} steering`
}
interface QueuedPromptsProps {
items?: QueuedPrompt[]
}
export function QueuedPrompts({ items = [] }: QueuedPromptsProps) {
if (items.length === 0) {
return null
}
return (
<div className="mx-3 mb-2 rounded-xs border border-editor-group-border bg-code px-2 py-1.5">
<div className="mb-1 flex items-center gap-1.5 text-xs font-medium text-description">
<span className="codicon codicon-clock text-[12px]" />
<span>{queueSummary(items)}</span>
</div>
<div className="flex max-h-24 flex-col gap-1 overflow-y-auto [scrollbar-width:none] [-ms-overflow-style:none] [&::-webkit-scrollbar]:hidden">
{items.map((item) => {
const attachments = attachmentLabel(item.attachmentCount)
const isSteer = item.delivery === "steer"
return (
<div className="flex items-start gap-1.5 text-xs leading-snug" key={item.id}>
<span
className={`codicon ${isSteer ? "codicon-debug-continue" : "codicon-chevron-right"} mt-[1px] shrink-0 text-[11px] text-description`}
title={isSteer ? "Steering message" : "Queued message"}
/>
<span className="min-w-0 flex-1 break-words text-foreground">{truncatePrompt(item.prompt)}</span>
{isSteer && <span className="shrink-0 text-description">Steer</span>}
{attachments && <span className="shrink-0 text-description">{attachments}</span>}
</div>
)
})}
</div>
</div>
)
}
@@ -6,5 +6,6 @@ export { ActionButtons } from "./ActionButtons"
export { ChatLayout } from "./ChatLayout"
export { InputSection } from "./InputSection"
export { MessagesArea } from "./MessagesArea"
export { QueuedPrompts } from "./QueuedPrompts"
export { TaskSection } from "./TaskSection"
export { WelcomeSection } from "./WelcomeSection"
@@ -17,6 +17,7 @@ interface MessageRendererProps {
expandedRows: Record<number, boolean>
onToggleExpand: (ts: number) => void
onHeightChange: (isTaller: boolean) => void
onLastRowContentChange: () => void
onSetQuote: (quote: string | null) => void
inputValue: string
messageHandlers: MessageHandlers
@@ -35,6 +36,7 @@ const MessageRenderer: React.FC<MessageRendererProps> = ({
expandedRows,
onToggleExpand,
onHeightChange,
onLastRowContentChange,
onSetQuote,
inputValue,
messageHandlers,
@@ -115,7 +117,7 @@ const MessageRenderer: React.FC<MessageRendererProps> = ({
mode={mode}
onCancelCommand={() => messageHandlers.executeButtonAction("cancel")}
onHeightChange={onHeightChange}
onOptimisticUserMessage={messageHandlers.addOptimisticUserMessage}
onLastRowContentChange={onLastRowContentChange}
onSetQuote={onSetQuote}
onToggleExpand={onToggleExpand}
reasoningContent={reasoningData.reasoning}
@@ -136,6 +138,7 @@ export const createMessageRenderer = (
expandedRows: Record<number, boolean>,
onToggleExpand: (ts: number) => void,
onHeightChange: (isTaller: boolean) => void,
onLastRowContentChange: () => void,
onSetQuote: (quote: string | null) => void,
inputValue: string,
messageHandlers: MessageHandlers,
@@ -152,6 +155,7 @@ export const createMessageRenderer = (
messageOrGroup={messageOrGroup}
modifiedMessages={modifiedMessages}
onHeightChange={onHeightChange}
onLastRowContentChange={onLastRowContentChange}
onSetQuote={onSetQuote}
onToggleExpand={onToggleExpand}
/>
@@ -148,7 +148,7 @@ describe("useMessageHandlers — send routing", () => {
)
})
it("clears and renders a follow-up optimistically before askResponse resolves", async () => {
it("shows pending composer state before a follow-up askResponse resolves", async () => {
mockTurnState = { phase: "completed", seq: 7 }
let resolveAskResponse: () => void = () => {}
askResponse.mockImplementationOnce(
@@ -163,8 +163,6 @@ describe("useMessageHandlers — send routing", () => {
const setSelectedImages = vi.fn()
const setSelectedFiles = vi.fn()
const setEnableButtons = vi.fn()
const removeOptimisticUserMessage = vi.fn()
const addOptimisticUserMessage = vi.fn(() => removeOptimisticUserMessage)
const chatState = makeChatState(completedConversation, {
activeQuote: "selected context",
sendingDisabled: false,
@@ -176,7 +174,7 @@ describe("useMessageHandlers — send routing", () => {
setSelectedFiles,
setEnableButtons,
})
const { result } = renderHook(() => useMessageHandlers(completedConversation, chatState, { addOptimisticUserMessage }))
const { result } = renderHook(() => useMessageHandlers(completedConversation, chatState))
let sendPromise: Promise<void> = Promise.resolve()
await act(async () => {
@@ -184,18 +182,20 @@ describe("useMessageHandlers — send routing", () => {
await Promise.resolve()
})
expect(askResponse).toHaveBeenCalledWith(
expect.objectContaining({
responseType: "messageResponse",
text: expect.stringContaining("another question"),
images: ["image.png"],
files: ["a.ts"],
}),
)
expect(setInputValue).toHaveBeenCalledWith("")
expect(setActiveQuote).toHaveBeenCalledWith(null)
expect(setSendingDisabled).toHaveBeenCalledWith(true)
expect(setSelectedImages).toHaveBeenCalledWith([])
expect(setSelectedFiles).toHaveBeenCalledWith([])
expect(setEnableButtons).toHaveBeenCalledWith(false)
expect(addOptimisticUserMessage).toHaveBeenCalledWith(
expect.stringContaining("another question"),
["image.png"],
["a.ts"],
)
expect(removeOptimisticUserMessage).not.toHaveBeenCalled()
await act(async () => {
resolveAskResponse()
@@ -203,7 +203,7 @@ describe("useMessageHandlers — send routing", () => {
})
})
it("removes the optimistic follow-up and restores input when askResponse fails", async () => {
it("restores pending follow-up UI state when askResponse fails", async () => {
mockTurnState = { phase: "completed", seq: 7 }
const error = new Error("transport down")
const setInputValue = vi.fn()
@@ -212,8 +212,6 @@ describe("useMessageHandlers — send routing", () => {
const setSelectedImages = vi.fn()
const setSelectedFiles = vi.fn()
const setEnableButtons = vi.fn()
const removeOptimisticUserMessage = vi.fn()
const addOptimisticUserMessage = vi.fn(() => removeOptimisticUserMessage)
const chatState = makeChatState(completedConversation, {
activeQuote: "selected context",
sendingDisabled: false,
@@ -225,7 +223,7 @@ describe("useMessageHandlers — send routing", () => {
setSelectedFiles,
setEnableButtons,
})
const { result } = renderHook(() => useMessageHandlers(completedConversation, chatState, { addOptimisticUserMessage }))
const { result } = renderHook(() => useMessageHandlers(completedConversation, chatState))
askResponse.mockRejectedValueOnce(error)
let caught: unknown
@@ -238,7 +236,6 @@ describe("useMessageHandlers — send routing", () => {
})
expect(caught).toBe(error)
expect(removeOptimisticUserMessage).toHaveBeenCalledTimes(1)
expect(setInputValue).toHaveBeenNthCalledWith(1, "")
expect(setInputValue).toHaveBeenLastCalledWith("another question")
expect(setActiveQuote).toHaveBeenNthCalledWith(1, null)
@@ -253,63 +250,6 @@ describe("useMessageHandlers — send routing", () => {
expect(setEnableButtons).toHaveBeenLastCalledWith(true)
})
it("clears action response UI state before askResponse resolves", async () => {
mockTurnState = { phase: "awaiting_approval", seq: 8 }
let resolveAskResponse: () => void = () => {}
askResponse.mockImplementationOnce(
() =>
new Promise<void>((resolve) => {
resolveAskResponse = resolve
}),
)
const setInputValue = vi.fn()
const setActiveQuote = vi.fn()
const setSendingDisabled = vi.fn()
const setSelectedImages = vi.fn()
const setSelectedFiles = vi.fn()
const setEnableButtons = vi.fn()
const addOptimisticUserMessage = vi.fn(() => vi.fn())
const chatState = makeChatState(completedConversation, {
activeQuote: "selected context",
sendingDisabled: false,
enableButtons: true,
setInputValue,
setActiveQuote,
setSendingDisabled,
setSelectedImages,
setSelectedFiles,
setEnableButtons,
})
const { result } = renderHook(() => useMessageHandlers(completedConversation, chatState, { addOptimisticUserMessage }))
let actionPromise: Promise<void> = Promise.resolve()
await act(async () => {
actionPromise = result.current.executeButtonAction("reject", "not yet", ["image.png"], ["a.ts"])
await Promise.resolve()
})
expect(askResponse).toHaveBeenCalledWith(
expect.objectContaining({
responseType: "noButtonClicked",
text: "not yet",
images: ["image.png"],
files: ["a.ts"],
}),
)
expect(setInputValue).toHaveBeenCalledWith("")
expect(setActiveQuote).toHaveBeenCalledWith(null)
expect(setSendingDisabled).toHaveBeenCalledWith(true)
expect(setSelectedImages).toHaveBeenCalledWith([])
expect(setSelectedFiles).toHaveBeenCalledWith([])
expect(setEnableButtons).toHaveBeenCalledWith(false)
expect(addOptimisticUserMessage).toHaveBeenCalledWith("not yet", ["image.png"], ["a.ts"])
await act(async () => {
resolveAskResponse()
await actionPromise
})
})
it("phase awaiting_followup also routes a follow-up to askResponse", async () => {
mockTurnState = { phase: "awaiting_followup", seq: 3 }
const { result } = renderHook(() => useMessageHandlers(completedConversation, makeChatState(completedConversation)))
@@ -7,19 +7,11 @@ import { SlashServiceClient, TaskServiceClient } from "@/services/grpc-client"
import type { ButtonActionType } from "../shared/buttonConfig"
import type { ChatState, MessageHandlers } from "../types/chatTypes"
interface MessageHandlerOptions {
addOptimisticUserMessage?: (text: string, images?: string[], files?: string[]) => () => void
}
/**
* Custom hook for managing message handlers
* Handles sending messages, button clicks, and task management
*/
export function useMessageHandlers(
messages: ClineMessage[],
chatState: ChatState,
options: MessageHandlerOptions = {},
): MessageHandlers {
export function useMessageHandlers(messages: ClineMessage[], chatState: ChatState): MessageHandlers {
const { backgroundCommandRunning, turnState } = useExtensionState()
const {
setInputValue,
@@ -35,17 +27,6 @@ export function useMessageHandlers(
lastMessage,
} = chatState
const cancelInFlightRef = useRef(false)
const addOptimisticUserMessage = useCallback(
(text: string, images?: string[], files?: string[]) =>
options.addOptimisticUserMessage?.(text, images, files) ?? (() => {}),
[options.addOptimisticUserMessage],
)
const resetAutoScroll = useCallback(() => {
if ("disableAutoScrollRef" in chatState) {
;(chatState as any).disableAutoScrollRef.current = false
}
}, [chatState])
// Handle sending a message
const handleSendMessage = useCallback(
@@ -89,7 +70,6 @@ export function useMessageHandlers(
setSelectedImages([])
setSelectedFiles([])
setEnableButtons(false)
resetAutoScroll()
}
const restorePendingMessageState = () => {
setInputValue(text)
@@ -99,13 +79,11 @@ export function useMessageHandlers(
setSelectedFiles(files)
setEnableButtons(enableButtons)
}
const sendAskResponseOptimistically = async (request: ReturnType<typeof AskResponseRequest.create>) => {
const sendAskResponseWithPendingState = async (request: ReturnType<typeof AskResponseRequest.create>) => {
clearSentMessageState()
const removeOptimisticMessage = addOptimisticUserMessage(messageToSend, images, files)
try {
await TaskServiceClient.askResponse(request)
} catch (error) {
removeOptimisticMessage()
restorePendingMessageState()
throw error
}
@@ -129,7 +107,7 @@ export function useMessageHandlers(
// For resume_task and resume_completed_task, use yesButtonClicked to match Resume button behavior
// This ensures Enter key and Resume button work identically
if (clineAsk === "resume_task" || clineAsk === "resume_completed_task") {
await sendAskResponseOptimistically(
await sendAskResponseWithPendingState(
AskResponseRequest.create({
responseType: "yesButtonClicked",
text: messageToSend,
@@ -155,7 +133,7 @@ export function useMessageHandlers(
case "new_task":
case "condense":
case "report_bug":
await sendAskResponseOptimistically(
await sendAskResponseWithPendingState(
AskResponseRequest.create({
responseType: "messageResponse",
text: messageToSend,
@@ -189,7 +167,7 @@ export function useMessageHandlers(
if (turnAllowsFollowup || isTaskRunning) {
// Continue the conversation / interrupt with feedback.
await sendAskResponseOptimistically(
await sendAskResponseWithPendingState(
AskResponseRequest.create({
responseType: "messageResponse",
text: messageToSend,
@@ -204,6 +182,11 @@ export function useMessageHandlers(
// New tasks clear optimistically before the RPC; the repeated success cleanup is idempotent.
if (messageSent) {
clearSentMessageState()
// Reset auto-scroll
if ("disableAutoScrollRef" in chatState) {
;(chatState as any).disableAutoScrollRef.current = false
}
}
}
},
@@ -221,8 +204,6 @@ export function useMessageHandlers(
enableButtons,
setEnableButtons,
chatState,
addOptimisticUserMessage,
resetAutoScroll,
],
)
@@ -245,104 +226,75 @@ export function useMessageHandlers(
async (actionType: ButtonActionType, text?: string, images?: string[], files?: string[]) => {
const trimmedInput = text?.trim()
const hasContent = trimmedInput || (images && images.length > 0) || (files && files.length > 0)
const clearActionResponseState = () => {
clearInputState()
setSendingDisabled(true)
setEnableButtons(false)
resetAutoScroll()
}
const restoreActionResponseState = () => {
setInputValue(text ?? "")
setActiveQuote(activeQuote)
setSelectedImages(images ?? [])
setSelectedFiles(files ?? [])
setSendingDisabled(sendingDisabled)
setEnableButtons(enableButtons)
}
const sendButtonAskResponseOptimistically = async (
request: ReturnType<typeof AskResponseRequest.create>,
optimisticText?: string,
) => {
clearActionResponseState()
const removeOptimisticMessage = optimisticText
? addOptimisticUserMessage(optimisticText, images, files)
: () => {}
try {
await TaskServiceClient.askResponse(request)
} catch (error) {
removeOptimisticMessage()
restoreActionResponseState()
throw error
}
}
switch (actionType) {
case "retry":
// For API retry (api_req_failed), always send simple approval without content
await sendButtonAskResponseOptimistically(
await TaskServiceClient.askResponse(
AskResponseRequest.create({
responseType: "yesButtonClicked",
}),
)
clearInputState()
break
case "approve":
if (hasContent) {
await sendButtonAskResponseOptimistically(
await TaskServiceClient.askResponse(
AskResponseRequest.create({
responseType: "yesButtonClicked",
text: trimmedInput,
images: images,
files: files,
}),
trimmedInput,
)
} else {
await sendButtonAskResponseOptimistically(
await TaskServiceClient.askResponse(
AskResponseRequest.create({
responseType: "yesButtonClicked",
}),
)
}
clearInputState()
break
case "reject":
if (hasContent) {
await sendButtonAskResponseOptimistically(
await TaskServiceClient.askResponse(
AskResponseRequest.create({
responseType: "noButtonClicked",
text: trimmedInput,
images: images,
files: files,
}),
trimmedInput,
)
} else {
await sendButtonAskResponseOptimistically(
await TaskServiceClient.askResponse(
AskResponseRequest.create({
responseType: "noButtonClicked",
}),
)
}
clearInputState()
break
case "proceed":
if (hasContent) {
await sendButtonAskResponseOptimistically(
await TaskServiceClient.askResponse(
AskResponseRequest.create({
responseType: "yesButtonClicked",
text: trimmedInput,
images: images,
files: files,
}),
trimmedInput,
)
} else {
await sendButtonAskResponseOptimistically(
await TaskServiceClient.askResponse(
AskResponseRequest.create({
responseType: "yesButtonClicked",
}),
)
}
clearInputState()
break
case "new_task":
@@ -398,28 +350,21 @@ export function useMessageHandlers(
break
}
resetAutoScroll()
if ("disableAutoScrollRef" in chatState) {
;(chatState as any).disableAutoScrollRef.current = false
}
},
[
clineAsk,
lastMessage,
messages,
activeQuote,
clearInputState,
handleSendMessage,
startNewTask,
chatState,
backgroundCommandRunning,
setInputValue,
setActiveQuote,
setSelectedImages,
setSelectedFiles,
sendingDisabled,
setSendingDisabled,
enableButtons,
setEnableButtons,
addOptimisticUserMessage,
resetAutoScroll,
],
)
@@ -429,7 +374,6 @@ export function useMessageHandlers(
}, [startNewTask])
return {
addOptimisticUserMessage,
handleSendMessage,
executeButtonAction,
handleTaskCloseButtonClick,
@@ -30,6 +30,7 @@ export function useScrollBehavior(
const virtuosoRef = useRef<VirtuosoHandle>(null)
const scrollContainerRef = useRef<HTMLDivElement>(null)
const disableAutoScrollRef = useRef(false)
const lastRowContentScrollTimersRef = useRef<ReturnType<typeof setTimeout>[]>([])
// State
const [isAtBottom, setIsAtBottom] = useState(false)
@@ -273,6 +274,29 @@ export function useScrollBehavior(
[scrollToBottomSmooth, scrollToBottomAuto],
)
const clearLastRowContentScrollTimers = useCallback(() => {
lastRowContentScrollTimersRef.current.forEach((timer) => clearTimeout(timer))
lastRowContentScrollTimersRef.current = []
}, [])
const handleLastRowContentChange = useCallback(() => {
if (disableAutoScrollRef.current) {
return
}
clearLastRowContentScrollTimers()
scrollToBottomSmooth()
lastRowContentScrollTimersRef.current = [0, 50].map((delay) =>
setTimeout(() => {
if (!disableAutoScrollRef.current) {
scrollToBottomAuto()
}
}, delay),
)
}, [clearLastRowContentScrollTimers, scrollToBottomSmooth, scrollToBottomAuto])
useEffect(() => clearLastRowContentScrollTimers, [clearLastRowContentScrollTimers])
useEffect(() => {
if (!disableAutoScrollRef.current) {
scrollToBottomSmooth()
@@ -316,6 +340,7 @@ export function useScrollBehavior(
scrollToMessage,
toggleRowExpansion,
handleRowHeightChange,
handleLastRowContentChange,
isAtBottom,
setIsAtBottom,
pendingScrollToMessage,
@@ -55,7 +55,6 @@ export interface ChatState {
* Message handlers interface
*/
export interface MessageHandlers {
addOptimisticUserMessage: (text: string, images?: string[], files?: string[]) => () => void
executeButtonAction: (action: ButtonActionType, text?: string, images?: string[], files?: string[]) => Promise<void>
handleSendMessage: (text: string, images: string[], files: string[]) => Promise<void>
handleTaskCloseButtonClick: () => void
@@ -74,6 +73,7 @@ export interface ScrollBehavior {
scrollToMessage: (messageIndex: number) => void
toggleRowExpansion: (ts: number) => void
handleRowHeightChange: (isTaller: boolean) => void
handleLastRowContentChange: () => void
isAtBottom: boolean
setIsAtBottom: React.Dispatch<React.SetStateAction<boolean>>
pendingScrollToMessage: number | null
@@ -1,6 +1,6 @@
import type { ClineMessage } from "@shared/ExtensionMessage"
import { describe, expect, it } from "vitest"
import { groupLowStakesTools, isToolGroup } from "./messageUtils"
import { canRestoreWorkspaceFromMessage, filterVisibleMessages, groupLowStakesTools, isToolGroup } from "./messageUtils"
const createTextMessage = (ts: number, text: string): ClineMessage => ({
type: "say",
@@ -23,6 +23,93 @@ const createReasoningMessage = (ts: number, text: string): ClineMessage => ({
ts,
})
const createUserFeedbackMessage = (ts: number, text: string): ClineMessage => ({
type: "say",
say: "user_feedback",
text,
ts,
})
const createTaskMessage = (ts: number, text: string): ClineMessage => ({
type: "say",
say: "task",
text,
ts,
})
const createAskMessage = (
ts: number,
ask: "followup" | "plan_mode_respond",
options: string[],
selected?: string,
): ClineMessage => ({
type: "ask",
ask,
text: JSON.stringify(
ask === "followup" ? { question: "Pick one", options, selected } : { response: "Pick one", options, selected },
),
ts,
})
describe("filterVisibleMessages", () => {
it("hides exact user feedback echoes for selected follow-up options", () => {
const askMessage = createAskMessage(1, "followup", ["Use this", "Use that"], "Use this")
const visible = filterVisibleMessages([askMessage, createUserFeedbackMessage(2, "Use this")])
expect(visible).toEqual([askMessage])
})
it("hides exact option echoes when selected has not been persisted on the ask row yet", () => {
const askMessage = createAskMessage(1, "followup", ["Use this", "Use that"])
const visible = filterVisibleMessages([askMessage, createUserFeedbackMessage(2, "Use this")])
expect(visible).toEqual([askMessage])
})
it("hides exact user feedback echoes for plan-mode response options", () => {
const askMessage = createAskMessage(1, "plan_mode_respond", ["Plan it", "Do it"], "Plan it")
const visible = filterVisibleMessages([askMessage, createUserFeedbackMessage(2, "Plan it")])
expect(visible).toEqual([askMessage])
})
it("keeps custom user feedback that extends a selected option", () => {
const askMessage = createAskMessage(1, "followup", ["Use this", "Use that"], "Use this")
const userMessage = createUserFeedbackMessage(2, "Use this: include tests")
const visible = filterVisibleMessages([askMessage, userMessage])
expect(visible).toEqual([askMessage, userMessage])
})
it("keeps exact option feedback when it includes attachments", () => {
const askMessage = createAskMessage(1, "followup", ["Use this", "Use that"], "Use this")
const userMessage: ClineMessage = {
...createUserFeedbackMessage(2, "Use this"),
images: ["data:image/png;base64,abc"],
}
const visible = filterVisibleMessages([askMessage, userMessage])
expect(visible).toEqual([askMessage, userMessage])
})
})
describe("canRestoreWorkspaceFromMessage", () => {
it("allows restore for user messages that start runs, but not ask answers", () => {
const messages = [
createTaskMessage(1, "start"),
createAskMessage(2, "followup", ["src/index.ts"]),
createTextMessage(3, "Which file should I inspect?"),
createUserFeedbackMessage(4, "src/index.ts"),
createUserFeedbackMessage(5, "next task"),
]
expect(canRestoreWorkspaceFromMessage(messages, 1)).toBe(true)
expect(canRestoreWorkspaceFromMessage(messages, 4)).toBe(false)
expect(canRestoreWorkspaceFromMessage(messages, 5)).toBe(true)
expect(canRestoreWorkspaceFromMessage(messages, 999)).toBe(false)
})
})
describe("groupLowStakesTools", () => {
it("keeps text that arrives after a low-stakes tool group by finalizing the group first", () => {
const grouped = groupLowStakesTools([
@@ -2,7 +2,13 @@
* Utility functions for message filtering, grouping, and manipulation
*/
import type { ClineMessage, ClineSayBrowserAction, ClineSayTool } from "@shared/ExtensionMessage"
import type {
ClineAskQuestion,
ClineMessage,
ClinePlanModeResponse,
ClineSayBrowserAction,
ClineSayTool,
} from "@shared/ExtensionMessage"
import { FileIcon, FolderOpenDotIcon, FolderOpenIcon, SearchIcon, ShapesIcon, WrenchIcon } from "lucide-react"
/**
@@ -35,7 +41,73 @@ export function isLowStakesTool(message: ClineMessage): boolean {
* Check if a message group is a tool group (array with _isToolGroup marker)
*/
export function isToolGroup(item: ClineMessage | ClineMessage[]): item is ClineMessage[] & { _isToolGroup: true } {
return Array.isArray(item) && (item as any)._isToolGroup === true
return Array.isArray(item) && (item as ClineMessage[] & { _isToolGroup?: boolean })._isToolGroup === true
}
function isDuplicateAskOptionEcho(message: ClineMessage, previousMessage: ClineMessage | undefined): boolean {
if (
message.type !== "say" ||
message.say !== "user_feedback" ||
(message.images?.length ?? 0) > 0 ||
(message.files?.length ?? 0) > 0 ||
previousMessage?.type !== "ask" ||
(previousMessage.ask !== "followup" && previousMessage.ask !== "plan_mode_respond")
) {
return false
}
const responseText = message.text ?? ""
if (!responseText) {
return false
}
try {
const parsed = JSON.parse(previousMessage.text || "{}") as ClineAskQuestion | ClinePlanModeResponse
if (!parsed.options?.includes(responseText)) {
return false
}
return parsed.selected === undefined || parsed.selected === responseText
} catch {
return false
}
}
function isVisibleCheckpointUserMessage(message: ClineMessage): boolean {
return message.type === "say" && (message.say === "task" || message.say === "user_feedback")
}
function isCheckpointAnswerMessage(messages: ClineMessage[], index: number): boolean {
const message = messages[index]
if (message?.type !== "say" || message.say !== "user_feedback") {
return false
}
for (let cursor = index - 1; cursor >= 0; cursor -= 1) {
const previous = messages[cursor]
if (previous.say === "checkpoint_created") {
continue
}
if (previous.type === "ask") {
return previous.ask === "followup" || previous.ask === "mistake_limit_reached"
}
if (isVisibleCheckpointUserMessage(previous)) {
return false
}
}
return false
}
export function canRestoreWorkspaceFromMessage(messages: ClineMessage[], messageTs: number | undefined): boolean {
if (messageTs === undefined) {
return false
}
const index = messages.findIndex((message) => message.ts === messageTs)
if (index === -1) {
return false
}
return isVisibleCheckpointUserMessage(messages[index]) && !isCheckpointAnswerMessage(messages, index)
}
/**
@@ -43,6 +115,10 @@ export function isToolGroup(item: ClineMessage | ClineMessage[]): item is ClineM
*/
export function filterVisibleMessages(messages: ClineMessage[]): ClineMessage[] {
return messages.filter((message, index, arr) => {
if (isDuplicateAskOptionEcho(message, arr[index - 1])) {
return false
}
switch (message.ask) {
case "completion_result":
// don't show a chat row for a completion_result ask without text. This specific type of message only occurs if cline wants to execute a command as part of its completion result, in which case we interject the completion_result tool with the execute_command tool.
@@ -66,6 +142,7 @@ export function filterVisibleMessages(messages: ClineMessage[]): ClineMessage[]
case "deleted_api_reqs": // aggregated api_req metrics from deleted messages
case "subagent_usage": // aggregated subagent usage metrics for task-level accounting
case "task_progress": // task progress messages are displayed in TaskHeader, not in main chat
case "checkpoint_created": // checkpoint restore is exposed from user-message edit controls
return false
// NOTE: reasoning passes through to be included in tool groups
case "api_req_started": {
@@ -105,7 +182,7 @@ export function filterVisibleMessages(messages: ClineMessage[]): ClineMessage[]
*/
function isBrowserSessionMessage(message: ClineMessage): boolean {
if (message.type === "ask") {
return ["browser_action_launch"].includes(message.ask!)
return message.ask === "browser_action_launch"
}
if (message.type === "say") {
return [
@@ -114,10 +191,9 @@ function isBrowserSessionMessage(message: ClineMessage): boolean {
"text",
"browser_action",
"browser_action_result",
"checkpoint_created",
"reasoning",
"error_retry",
].includes(message.say!)
].includes(message.say ?? "")
}
return false
}
@@ -412,11 +488,6 @@ export function isApiReqAbsorbable(apiReqTs: number, allMessages: ClineMessage[]
continue
}
// Checkpoints do not affect absorbability
if (msg.say === "checkpoint_created") {
continue
}
// Text is allowed (we still want to absorb api_req into the tool group)
if (msg.say === "text") {
continue
@@ -468,10 +539,6 @@ function isApiReqFollowedOnlyByLowStakesTools(index: number, messages: (ClineMes
hasLowStakesTool = true
continue
}
// Checkpoint is OK
if (msg.say === "checkpoint_created") {
continue
}
// Text is OK - it will render separately, but we still absorb api_req
if (msg.say === "text") {
continue
@@ -501,8 +568,12 @@ export function groupLowStakesTools(groupedMessages: (ClineMessage | ClineMessag
const pendingTools: ClineMessage[] = []
const flushPending = () => {
pendingApiReq.forEach((m) => result.push(m))
pendingReasoning.forEach((m) => result.push(m))
pendingApiReq.forEach((m) => {
result.push(m)
})
pendingReasoning.forEach((m) => {
result.push(m)
})
pendingApiReq = []
pendingReasoning = []
}
@@ -582,12 +653,6 @@ export function groupLowStakesTools(groupedMessages: (ClineMessage | ClineMessag
continue
}
// Checkpoint - absorb into active tool group
if (messageType === "checkpoint_created" && hasTools) {
toolGroup.push(message)
continue
}
// Text - if a low-stakes tool group is active, finalize it first,
// then render the text as a normal chat row. This ensures post-tool
// summaries (common in SDK/native-tool-call flows) are visible.
@@ -1,540 +0,0 @@
import { flip, offset, shift, useFloating } from "@floating-ui/react"
import { CheckpointRestoreRequest } from "@shared/proto/cline/checkpoints"
import { Int64Request } from "@shared/proto/cline/common"
import { ClineCheckpointRestore } from "@shared/WebviewMessage"
import { BookmarkIcon } from "lucide-react"
import { useCallback, useEffect, useRef, useState } from "react"
import { createPortal } from "react-dom"
import styled from "styled-components"
import { CODE_BLOCK_BG_COLOR } from "@/components/common/CodeBlock"
import { Button } from "@/components/ui/button"
import { useExtensionState } from "@/context/ExtensionStateContext"
import { cn } from "@/lib/utils"
import { CheckpointsServiceClient } from "@/services/grpc-client"
interface CheckmarkControlProps {
messageTs?: number
checkpointNumber?: number
isCheckpointCheckedOut?: boolean
}
export const CheckmarkControl = ({ messageTs, checkpointNumber, isCheckpointCheckedOut }: CheckmarkControlProps) => {
const [compareDisabled, setCompareDisabled] = useState(false)
const [restoreTaskDisabled, setRestoreTaskDisabled] = useState(false)
const [restoreWorkspaceDisabled, setRestoreWorkspaceDisabled] = useState(false)
const [restoreBothDisabled, setRestoreBothDisabled] = useState(false)
const [showRestoreConfirm, setShowRestoreConfirm] = useState(false)
const [showMoreOptions, setShowMoreOptions] = useState(false)
const { onRelinquishControl } = useExtensionState()
// Debounce
const closeMenuTimeoutRef = useRef<NodeJS.Timeout | null>(null)
const scheduleCloseRestore = useCallback(() => {
if (closeMenuTimeoutRef.current) {
clearTimeout(closeMenuTimeoutRef.current)
}
closeMenuTimeoutRef.current = setTimeout(() => {
setShowRestoreConfirm(false)
}, 350)
}, [])
const cancelCloseRestore = useCallback(() => {
if (closeMenuTimeoutRef.current) {
clearTimeout(closeMenuTimeoutRef.current)
closeMenuTimeoutRef.current = null
}
}, [])
// Debounce cleanup
useEffect(() => {
return () => {
if (closeMenuTimeoutRef.current) {
clearTimeout(closeMenuTimeoutRef.current)
closeMenuTimeoutRef.current = null
}
}
}, [showRestoreConfirm])
// Clear "Restore Files" button when checkpoint is no longer checked out
useEffect(() => {
if (!isCheckpointCheckedOut && restoreWorkspaceDisabled) {
setRestoreWorkspaceDisabled(false)
}
}, [isCheckpointCheckedOut, restoreWorkspaceDisabled])
const { refs, floatingStyles, update, placement } = useFloating({
placement: "bottom-end",
middleware: [
offset({
mainAxis: 8,
crossAxis: 10,
}),
flip(),
shift(),
],
})
useEffect(() => {
const handleScroll = () => {
update()
}
window.addEventListener("scroll", handleScroll, true)
return () => window.removeEventListener("scroll", handleScroll, true)
}, [update])
useEffect(() => {
if (showRestoreConfirm) {
update()
}
}, [showRestoreConfirm, update])
// Use the onRelinquishControl hook instead of message event
useEffect(() => {
return onRelinquishControl(() => {
setCompareDisabled(false)
setRestoreTaskDisabled(false)
setRestoreWorkspaceDisabled(false)
setRestoreBothDisabled(false)
setShowRestoreConfirm(false)
setShowMoreOptions(false)
})
}, [onRelinquishControl])
const handleCompare = async () => {
setCompareDisabled(true)
try {
await CheckpointsServiceClient.checkpointDiff(
Int64Request.create({
value: checkpointNumber ?? messageTs,
}),
)
} catch (err) {
console.error("Checkpoint diff error:", err)
} finally {
setCompareDisabled(false)
}
}
const handleRestoreTask = async () => {
setRestoreTaskDisabled(true)
try {
const restoreType: ClineCheckpointRestore = "task"
await CheckpointsServiceClient.checkpointRestore(
CheckpointRestoreRequest.create({
number: checkpointNumber ?? messageTs,
restoreType,
}),
)
} catch (err) {
console.error("Checkpoint restore task error:", err)
} finally {
setRestoreTaskDisabled(false)
}
}
const handleRestoreWorkspace = async () => {
setRestoreWorkspaceDisabled(true)
try {
const restoreType: ClineCheckpointRestore = "workspace"
await CheckpointsServiceClient.checkpointRestore(
CheckpointRestoreRequest.create({
number: checkpointNumber ?? messageTs,
restoreType,
}),
)
} catch (err) {
console.error("Checkpoint restore workspace error:", err)
} finally {
setRestoreWorkspaceDisabled(false)
}
}
const handleRestoreBoth = async () => {
setRestoreBothDisabled(true)
try {
const restoreType: ClineCheckpointRestore = "taskAndWorkspace"
await CheckpointsServiceClient.checkpointRestore(
CheckpointRestoreRequest.create({
number: checkpointNumber ?? messageTs,
restoreType,
}),
)
} catch (err) {
console.error("Checkpoint restore both error:", err)
} finally {
setRestoreBothDisabled(false)
}
}
const handleMouseEnter = () => {
cancelCloseRestore()
}
const handleMouseLeave = () => {
scheduleCloseRestore()
}
const handleControlsMouseEnter = () => {
cancelCloseRestore()
}
const handleControlsMouseLeave = () => {
scheduleCloseRestore()
}
return (
<Container
$isCheckedOut={isCheckpointCheckedOut}
isMenuOpen={showRestoreConfirm}
onMouseEnter={handleControlsMouseEnter}
onMouseLeave={handleControlsMouseLeave}>
<BookmarkIcon
className={cn("text-xs text-description shrink-0 size-2", {
"text-link": isCheckpointCheckedOut,
})}
/>
<DottedLine $isCheckedOut={isCheckpointCheckedOut} className="hover-show-inverse" />
<div className="hover-content">
<span
className={cn("text-[9px] text-description shrink-0", {
"text-link": isCheckpointCheckedOut,
})}>
{isCheckpointCheckedOut ? "Checkpoint (restored)" : "Checkpoint"}
</span>
<DottedLine $isCheckedOut={isCheckpointCheckedOut} />
<ButtonGroup>
<CustomButton
$isCheckedOut={isCheckpointCheckedOut}
disabled={compareDisabled}
onClick={handleCompare}
style={{ cursor: compareDisabled ? "wait" : "pointer" }}>
Compare
</CustomButton>
<DottedLine $isCheckedOut={isCheckpointCheckedOut} small />
<div ref={refs.setReference} style={{ position: "relative", marginTop: -2 }}>
<CustomButton
$isCheckedOut={isCheckpointCheckedOut}
isActive={showRestoreConfirm}
onClick={() => setShowRestoreConfirm(true)}>
Restore
</CustomButton>
{showRestoreConfirm &&
createPortal(
<RestoreConfirmTooltip
data-placement={placement}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
ref={refs.setFloating}
style={floatingStyles}>
<PrimaryRestoreOption>
<Button
disabled={restoreBothDisabled}
onClick={handleRestoreBoth}
style={{
cursor: restoreBothDisabled ? "wait" : "pointer",
}}>
<i className="codicon codicon-debug-restart" style={{ marginRight: "6px" }} />
Restore Files & Task
</Button>
<p>Revert files and clear messages after this point</p>
</PrimaryRestoreOption>
<MoreOptionsToggle onClick={() => setShowMoreOptions(!showMoreOptions)}>
More options
<i
className={`codicon codicon-chevron-${showMoreOptions ? "up" : "down"}`}
style={{ marginLeft: "4px", fontSize: "10px" }}
/>
</MoreOptionsToggle>
{showMoreOptions && (
<AdditionalOptions>
<RestoreOption>
<Button
disabled={restoreWorkspaceDisabled || isCheckpointCheckedOut}
onClick={handleRestoreWorkspace}
style={{
cursor: isCheckpointCheckedOut
? "not-allowed"
: restoreWorkspaceDisabled
? "wait"
: "pointer",
}}
variant="secondary">
<i
className="codicon codicon-file-symlink-directory"
style={{ marginRight: "6px" }}
/>
Restore Files Only
</Button>
<p>Revert files to this checkpoint</p>
</RestoreOption>
<RestoreOption>
<Button
disabled={restoreTaskDisabled}
onClick={handleRestoreTask}
style={{
cursor: restoreTaskDisabled ? "wait" : "pointer",
}}
variant="secondary">
<i
className="codicon codicon-comment-discussion"
style={{ marginRight: "6px" }}
/>
Restore Task Only
</Button>
<p>Clear messages after this point</p>
</RestoreOption>
</AdditionalOptions>
)}
</RestoreConfirmTooltip>,
document.body,
)}
</div>
<DottedLine $isCheckedOut={isCheckpointCheckedOut} small />
</ButtonGroup>
</div>
</Container>
)
}
const Container = styled.div<{ isMenuOpen?: boolean; $isCheckedOut?: boolean }>`
display: flex;
align-items: center;
padding: 8px 0px 0px 0px;
gap: 4px;
position: relative;
min-width: 0;
min-height: 17px;
margin-top: -2px;
margin-bottom: 1px;
opacity: ${(props) => (props.$isCheckedOut ? 1 : props.isMenuOpen ? 1 : 0.5)};
height: 0.5rem;
&:first-of-type {
padding-top: 0px;
}
&:hover {
opacity: 1;
}
.hover-content {
display: ${(props) => (props.isMenuOpen ? "flex" : "none")};
align-items: center;
gap: 4px;
flex: 1;
}
&:hover .hover-content {
display: flex;
}
.hover-show-inverse {
display: ${(props) => (props.isMenuOpen ? "none" : "flex")};
flex: 1;
}
&:hover .hover-show-inverse {
display: none;
}
`
const DottedLine = styled.div<{ small?: boolean; $isCheckedOut?: boolean }>`
flex: ${(props) => (props.small ? "0 0 5px" : "1")};
min-width: ${(props) => (props.small ? "5px" : "5px")};
height: 1px;
background-image: linear-gradient(
to right,
${(props) => (props.$isCheckedOut ? "var(--vscode-textLink-foreground)" : "var(--vscode-descriptionForeground)")} 50%,
transparent 50%
);
background-size: 4px 1px;
background-repeat: repeat-x;
`
const ButtonGroup = styled.div`
display: flex;
align-items: center;
gap: 4px;
shrink: 0;
`
const CustomButton = styled.button<{ disabled?: boolean; isActive?: boolean; $isCheckedOut?: boolean }>`
background: ${(props) =>
props.isActive || props.disabled
? props.$isCheckedOut
? "var(--vscode-textLink-foreground)"
: "var(--vscode-descriptionForeground)"
: "transparent"};
border: none;
color: ${(props) =>
props.isActive || props.disabled
? "var(--vscode-editor-background)"
: props.$isCheckedOut
? "var(--vscode-textLink-foreground)"
: "var(--vscode-descriptionForeground)"};
padding: 2px 6px;
font-size: 9px;
cursor: pointer;
position: relative;
&::before {
content: "";
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
border-radius: 1px;
background-image: ${(props) =>
props.isActive || props.disabled
? "none"
: `linear-gradient(to right, ${props.$isCheckedOut ? "var(--vscode-textLink-foreground)" : "var(--vscode-descriptionForeground)"} 50%, transparent 50%),
linear-gradient(to bottom, ${props.$isCheckedOut ? "var(--vscode-textLink-foreground)" : "var(--vscode-descriptionForeground)"} 50%, transparent 50%),
linear-gradient(to right, ${props.$isCheckedOut ? "var(--vscode-textLink-foreground)" : "var(--vscode-descriptionForeground)"} 50%, transparent 50%),
linear-gradient(to bottom, ${props.$isCheckedOut ? "var(--vscode-textLink-foreground)" : "var(--vscode-descriptionForeground)"} 50%, transparent 50%)`};
background-size: ${(props) => (props.isActive || props.disabled ? "auto" : `4px 1px, 1px 4px, 4px 1px, 1px 4px`)};
background-repeat: repeat-x, repeat-y, repeat-x, repeat-y;
background-position:
0 0,
100% 0,
0 100%,
0 0;
}
&:hover:not(:disabled) {
background: ${(props) =>
props.$isCheckedOut ? "var(--vscode-textLink-foreground)" : "var(--vscode-descriptionForeground)"};
color: var(--vscode-editor-background);
&::before {
display: none;
}
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
`
const PrimaryRestoreOption = styled.div`
margin-bottom: 12px;
p {
margin: 8px 0 0 0;
color: var(--vscode-descriptionForeground);
font-size: 11px;
line-height: 14px;
}
`
const MoreOptionsToggle = styled.button`
width: 100%;
padding: 2px 0;
background: transparent;
color: var(--vscode-textLink-foreground);
border: none;
font-size: 11px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: flex-start;
transition: opacity 0.1s ease;
opacity: 0.8;
margin-bottom: -4px;
&:hover {
opacity: 1;
}
`
const AdditionalOptions = styled.div`
padding-top: 8px;
margin-top: 6px;
border-top: 1px solid var(--vscode-editorGroup-border);
animation: slideDown 0.15s ease-out;
@keyframes slideDown {
from {
opacity: 0;
transform: translateY(-4px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
`
const RestoreOption = styled.div`
&:not(:last-child) {
margin-bottom: 12px;
}
p {
margin: 8px 0 0 0;
color: var(--vscode-descriptionForeground);
font-size: 11px;
line-height: 14px;
}
`
const RestoreConfirmTooltip = styled.div`
position: fixed;
background: ${CODE_BLOCK_BG_COLOR};
border: 1px solid var(--vscode-editorGroup-border);
padding: 14px;
border-radius: 5px;
width: min(calc(100vw - 54px), 200px);
z-index: 1000;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
// Add invisible padding to create a safe hover zone
&::before {
content: "";
position: absolute;
top: -8px;
left: 0;
right: 0;
height: 8px;
}
// Adjust arrow to be above the padding
&::after {
content: "";
position: absolute;
top: -6px;
right: 24px;
width: 10px;
height: 10px;
background: ${CODE_BLOCK_BG_COLOR};
border-left: 1px solid var(--vscode-editorGroup-border);
border-top: 1px solid var(--vscode-editorGroup-border);
transform: rotate(45deg);
z-index: 1;
}
// When menu appears above the button
&[data-placement^="top"] {
&::before {
top: auto;
bottom: -8px;
}
&::after {
top: auto;
bottom: -6px;
right: 24px;
transform: rotate(225deg);
}
}
p {
margin: 0;
color: var(--vscode-descriptionForeground);
font-size: 11px;
line-height: 14px;
white-space: normal;
word-wrap: break-word;
}
`
@@ -21,15 +21,30 @@ interface CategorizedModel {
section: "deployed" | "supported"
}
function isSapAiCoreFoundationChatModel(modelId: string): boolean {
const normalizedModelId = modelId.trim().toLowerCase()
const unsupportedModelKinds = ["base", "codex", "instruct", "realtime"]
return /^gpt-?\d/.test(normalizedModelId) && !unsupportedModelKinds.some((kind) => normalizedModelId.includes(kind))
}
const SapAiCoreModelPicker: React.FC<SapAiCoreModelPickerProps> = ({
sapAiCoreModelDeployments,
selectedModelId,
selectedDeploymentId,
onModelChange,
placeholder = "Select a model...",
useOrchestrationMode = false,
useOrchestrationMode = true,
}) => {
const { models: sapAiCoreModels } = useProviderModels("sapaicore")
const visibleSapAiCoreModels = useMemo(() => {
if (useOrchestrationMode) {
return sapAiCoreModels
}
return Object.fromEntries(Object.entries(sapAiCoreModels).filter(([modelId]) => isSapAiCoreFoundationChatModel(modelId)))
}, [sapAiCoreModels, useOrchestrationMode])
// Auto-fix deployment ID mismatch or missing deployment ID when deployments change (when ai core creds changes)
useEffect(() => {
if (!selectedModelId) {
@@ -69,7 +84,7 @@ const SapAiCoreModelPicker: React.FC<SapAiCoreModelPickerProps> = ({
}
const categorizedModels = useMemo(() => {
const allSupportedModels = Object.keys(sapAiCoreModels)
const allSupportedModels = Object.keys(visibleSapAiCoreModels)
// Models that are both deployed AND supported in Cline
const deployedModelNames = sapAiCoreModelDeployments.map((d) => d.modelName)
@@ -95,7 +110,7 @@ const SapAiCoreModelPicker: React.FC<SapAiCoreModelPickerProps> = ({
}))
return { deployed, supported }
}, [sapAiCoreModelDeployments, sapAiCoreModels])
}, [sapAiCoreModelDeployments, visibleSapAiCoreModels])
const renderOptions = () => {
const options: React.ReactNode[] = []
@@ -109,7 +124,7 @@ const SapAiCoreModelPicker: React.FC<SapAiCoreModelPickerProps> = ({
if (useOrchestrationMode) {
// Orchestration mode: Show all supported models in one flat list (no separators)
const allSupportedModels = Object.keys(sapAiCoreModels)
const allSupportedModels = Object.keys(visibleSapAiCoreModels)
allSupportedModels.forEach((modelId) => {
options.push(
<VSCodeOption key={modelId} value={modelId}>
@@ -1,8 +1,59 @@
import { render, screen } from "@testing-library/react"
import { describe, expect, it, vi } from "vitest"
import { render, screen, waitFor } from "@testing-library/react"
import { beforeEach, describe, expect, it, vi } from "vitest"
import { ExtensionStateContextProvider } from "@/context/ExtensionStateContext"
import SapAiCoreModelPicker from "../SapAiCoreModelPicker"
const mocks = vi.hoisted(() => ({
resolveProviderModelsMock: vi.fn().mockResolvedValue({
providerId: "sapaicore",
requestId: "test-request-id",
configFingerprint: "test-fingerprint",
fetchedAt: Date.now(),
ok: true,
models: {},
defaultModelId: "",
}),
setApiConfigurationMock: vi.fn(),
startProviderModelsRequestMock: vi.fn(),
applyProviderModelsResponseMock: vi.fn(),
useExtensionStateMock: vi.fn(() => ({
apiConfiguration: {
apiProvider: "sapaicore",
sapAiCoreModelId: "anthropic--claude-3.5-sonnet",
},
setApiConfiguration: mocks.setApiConfigurationMock,
providerModelsByProvider: {
sapaicore: {
models: {
"anthropic--claude-3.5-sonnet": { maxTokens: 8192, contextWindow: 200_000, supportsPromptCache: false },
"anthropic--claude-3-haiku": { maxTokens: 4096, contextWindow: 200_000, supportsPromptCache: false },
"gpt-4o": { maxTokens: 4096, contextWindow: 200_000, supportsPromptCache: false },
"gpt-5.5": { maxTokens: 4096, contextWindow: 200_000, supportsPromptCache: false },
"gpt-4-base": { maxTokens: 4096, contextWindow: 200_000, supportsPromptCache: false },
"gpt-5-codex": { maxTokens: 4096, contextWindow: 200_000, supportsPromptCache: false },
"gpt-4-instruct": { maxTokens: 4096, contextWindow: 200_000, supportsPromptCache: false },
"gpt-4-realtime": { maxTokens: 4096, contextWindow: 200_000, supportsPromptCache: false },
"gemini-2.5-pro": { maxTokens: 65536, contextWindow: 1_048_576, supportsPromptCache: true },
},
defaultModelId: "anthropic--claude-3.5-sonnet",
},
},
startProviderModelsRequest: mocks.startProviderModelsRequestMock,
applyProviderModelsResponse: mocks.applyProviderModelsResponseMock,
})),
}))
vi.mock("@/services/grpc-client", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/services/grpc-client")>()
return {
...actual,
ModelsServiceClient: {
...actual.ModelsServiceClient,
resolveProviderModels: mocks.resolveProviderModelsMock,
},
}
})
// Define the interface locally since it's not exported from the proto
interface SapAiCoreModelDeployment {
modelName: string
@@ -17,42 +68,71 @@ const createDeployments = (modelNames: string[]): SapAiCoreModelDeployment[] =>
}))
}
// Mock the ExtensionStateContext
vi.mock("../../../context/ExtensionStateContext", async (importOriginal) => {
const actual = await importOriginal()
return {
...(actual || {}),
useExtensionState: vi.fn(() => ({
apiConfiguration: {
apiProvider: "sapaicore",
sapAiCoreModelId: "anthropic--claude-3.5-sonnet",
},
setApiConfiguration: vi.fn(),
// Provider model-list context read by useProviderModels. The picker
// sources its supported-model list from the "sapaicore" entry here.
providerModelsByProvider: {
sapaicore: {
models: {
"anthropic--claude-3.5-sonnet": { maxTokens: 8192, contextWindow: 200_000, supportsPromptCache: false },
"anthropic--claude-3-haiku": { maxTokens: 4096, contextWindow: 200_000, supportsPromptCache: false },
"gpt-4o": { maxTokens: 4096, contextWindow: 200_000, supportsPromptCache: false },
"gemini-2.5-pro": { maxTokens: 65536, contextWindow: 1_048_576, supportsPromptCache: true },
},
defaultModelId: "anthropic--claude-3.5-sonnet",
},
},
startProviderModelsRequest: vi.fn(),
applyProviderModelsResponse: vi.fn(),
})),
}
})
// Mock the ExtensionStateContext used by the component and by this spec.
vi.mock("@/context/ExtensionStateContext", () => ({
ExtensionStateContextProvider: ({ children }: { children: any }) => children,
useExtensionState: mocks.useExtensionStateMock,
}))
describe("SapAiCoreModelPicker Component", () => {
vi.clearAllMocks()
const mockOnModelChange = vi.fn()
beforeEach(() => {
vi.clearAllMocks()
mockOnModelChange.mockClear()
mocks.resolveProviderModelsMock.mockClear()
})
it("does not refresh the provider model list when orchestration mode changes", async () => {
const { rerender } = render(
<ExtensionStateContextProvider>
<SapAiCoreModelPicker
onModelChange={mockOnModelChange}
sapAiCoreModelDeployments={createDeployments(["gpt-4o"])}
selectedModelId="gpt-4o"
useOrchestrationMode={false}
/>
</ExtensionStateContextProvider>,
)
await waitFor(() => expect(mocks.resolveProviderModelsMock).toHaveBeenCalledTimes(1))
rerender(
<ExtensionStateContextProvider>
<SapAiCoreModelPicker
onModelChange={mockOnModelChange}
sapAiCoreModelDeployments={createDeployments(["gpt-4o"])}
selectedModelId="gpt-4o"
useOrchestrationMode={true}
/>
</ExtensionStateContextProvider>,
)
await waitFor(() => expect(screen.getByText("anthropic--claude-3.5-sonnet")).toBeInTheDocument())
expect(mocks.resolveProviderModelsMock).toHaveBeenCalledTimes(1)
})
it("filters foundation-model mode to OpenAI chat models", () => {
render(
<ExtensionStateContextProvider>
<SapAiCoreModelPicker
onModelChange={mockOnModelChange}
sapAiCoreModelDeployments={createDeployments(["gpt-4o"])}
selectedModelId="gpt-4o"
useOrchestrationMode={false}
/>
</ExtensionStateContextProvider>,
)
expect(screen.getByText("gpt-4o")).toBeInTheDocument()
expect(screen.getByText("gpt-5.5")).toBeInTheDocument()
expect(screen.queryByText("anthropic--claude-3.5-sonnet")).not.toBeInTheDocument()
expect(screen.queryByText("anthropic--claude-3-haiku")).not.toBeInTheDocument()
expect(screen.queryByText("gemini-2.5-pro")).not.toBeInTheDocument()
expect(screen.queryByText("gpt-4-base")).not.toBeInTheDocument()
expect(screen.queryByText("gpt-5-codex")).not.toBeInTheDocument()
expect(screen.queryByText("gpt-4-instruct")).not.toBeInTheDocument()
expect(screen.queryByText("gpt-4-realtime")).not.toBeInTheDocument()
})
it("renders the model dropdown with correct label", () => {
@@ -106,8 +186,9 @@ describe("SapAiCoreModelPicker Component", () => {
<ExtensionStateContextProvider>
<SapAiCoreModelPicker
onModelChange={mockOnModelChange}
sapAiCoreModelDeployments={createDeployments(["anthropic--claude-3.5-sonnet", "gpt-4o"])}
selectedModelId="anthropic--claude-3.5-sonnet"
sapAiCoreModelDeployments={createDeployments(["gpt-4o", "gpt-5.5"])}
selectedModelId="gpt-4o"
useOrchestrationMode={false}
/>
</ExtensionStateContextProvider>,
)
@@ -117,10 +198,10 @@ describe("SapAiCoreModelPicker Component", () => {
expect(deployedHeader).toBeInTheDocument()
// Check for deployed model options
const claudeOption = screen.getByText("anthropic--claude-3.5-sonnet")
const gptOption = screen.getByText("gpt-4o")
expect(claudeOption).toBeInTheDocument()
const gptFiveOption = screen.getByText("gpt-5.5")
expect(gptOption).toBeInTheDocument()
expect(gptFiveOption).toBeInTheDocument()
})
it("shows not deployed models section when supported but not deployed models exist", () => {
@@ -128,8 +209,9 @@ describe("SapAiCoreModelPicker Component", () => {
<ExtensionStateContextProvider>
<SapAiCoreModelPicker
onModelChange={mockOnModelChange}
sapAiCoreModelDeployments={createDeployments(["anthropic--claude-3.5-sonnet"])}
selectedModelId="anthropic--claude-3.5-sonnet"
sapAiCoreModelDeployments={createDeployments(["gpt-4o"])}
selectedModelId="gpt-4o"
useOrchestrationMode={false}
/>
</ExtensionStateContextProvider>,
)
@@ -139,10 +221,8 @@ describe("SapAiCoreModelPicker Component", () => {
expect(notDeployedHeader).toBeInTheDocument()
// Check for not deployed model options
const haikuOption = screen.getByText("anthropic--claude-3-haiku")
const geminiOption = screen.getByText("gemini-2.5-pro")
expect(haikuOption).toBeInTheDocument()
expect(geminiOption).toBeInTheDocument()
const gptFiveOption = screen.getByText("gpt-5.5")
expect(gptFiveOption).toBeInTheDocument()
})
it("correctly categorizes models into deployed and not deployed", () => {
@@ -150,19 +230,18 @@ describe("SapAiCoreModelPicker Component", () => {
<ExtensionStateContextProvider>
<SapAiCoreModelPicker
onModelChange={mockOnModelChange}
sapAiCoreModelDeployments={createDeployments(["anthropic--claude-3.5-sonnet", "gpt-4o"])}
selectedModelId="anthropic--claude-3.5-sonnet"
sapAiCoreModelDeployments={createDeployments(["gpt-4o"])}
selectedModelId="gpt-4o"
useOrchestrationMode={false}
/>
</ExtensionStateContextProvider>,
)
// Deployed models should appear
expect(screen.getByText("anthropic--claude-3.5-sonnet")).toBeInTheDocument()
expect(screen.getByText("gpt-4o")).toBeInTheDocument()
// Not deployed models should appear
expect(screen.getByText("anthropic--claude-3-haiku")).toBeInTheDocument()
expect(screen.getByText("gemini-2.5-pro")).toBeInTheDocument()
expect(screen.getByText("gpt-5.5")).toBeInTheDocument()
})
it("calls onModelChange when a model is selected", () => {
@@ -192,8 +271,9 @@ describe("SapAiCoreModelPicker Component", () => {
<ExtensionStateContextProvider>
<SapAiCoreModelPicker
onModelChange={mockOnModelChange}
sapAiCoreModelDeployments={createDeployments(["anthropic--claude-3.5-sonnet"])}
selectedModelId="anthropic--claude-3.5-sonnet"
sapAiCoreModelDeployments={createDeployments(["gpt-4o"])}
selectedModelId="gpt-4o"
useOrchestrationMode={false}
/>
</ExtensionStateContextProvider>,
)
@@ -201,11 +281,10 @@ describe("SapAiCoreModelPicker Component", () => {
// Test that not deployed models are properly displayed
const dropdown = screen.getByRole("combobox")
expect(dropdown).toBeInTheDocument()
expect(dropdown).toHaveValue("anthropic--claude-3.5-sonnet")
expect(dropdown).toHaveValue("gpt-4o")
// Verify that not deployed models are shown with proper labeling
expect(screen.getByText("anthropic--claude-3-haiku")).toBeInTheDocument()
expect(screen.getByText("gemini-2.5-pro")).toBeInTheDocument()
expect(screen.getByText("gpt-5.5")).toBeInTheDocument()
})
it("updates selected value when selectedModelId prop changes", () => {
@@ -239,7 +318,12 @@ describe("SapAiCoreModelPicker Component", () => {
it("handles empty deployed models array", () => {
render(
<ExtensionStateContextProvider>
<SapAiCoreModelPicker onModelChange={mockOnModelChange} sapAiCoreModelDeployments={[]} selectedModelId="" />
<SapAiCoreModelPicker
onModelChange={mockOnModelChange}
sapAiCoreModelDeployments={[]}
selectedModelId=""
useOrchestrationMode={false}
/>
</ExtensionStateContextProvider>,
)
@@ -251,21 +335,23 @@ describe("SapAiCoreModelPicker Component", () => {
expect(notDeployedHeader).toBeInTheDocument()
// All models should be marked as not deployed
expect(screen.getByText("anthropic--claude-3.5-sonnet")).toBeInTheDocument()
expect(screen.getByText("anthropic--claude-3-haiku")).toBeInTheDocument()
expect(screen.getByText("gpt-4o")).toBeInTheDocument()
expect(screen.getByText("gemini-2.5-pro")).toBeInTheDocument()
expect(screen.getByText("gpt-5.5")).toBeInTheDocument()
expect(screen.queryByText("anthropic--claude-3.5-sonnet")).not.toBeInTheDocument()
expect(screen.queryByText("anthropic--claude-3-haiku")).not.toBeInTheDocument()
expect(screen.queryByText("gemini-2.5-pro")).not.toBeInTheDocument()
})
it("handles case where all supported models are deployed", () => {
const allSupportedModels = ["anthropic--claude-3.5-sonnet", "anthropic--claude-3-haiku", "gpt-4o", "gemini-2.5-pro"]
const allSupportedModels = ["gpt-4o", "gpt-5.5"]
render(
<ExtensionStateContextProvider>
<SapAiCoreModelPicker
onModelChange={mockOnModelChange}
sapAiCoreModelDeployments={createDeployments(allSupportedModels)}
selectedModelId="anthropic--claude-3.5-sonnet"
selectedModelId="gpt-4o"
useOrchestrationMode={false}
/>
</ExtensionStateContextProvider>,
)
@@ -278,10 +364,8 @@ describe("SapAiCoreModelPicker Component", () => {
expect(screen.queryByText("── Not Deployed Models ──")).not.toBeInTheDocument()
// All models should appear
expect(screen.getByText("anthropic--claude-3.5-sonnet")).toBeInTheDocument()
expect(screen.getByText("anthropic--claude-3-haiku")).toBeInTheDocument()
expect(screen.getByText("gpt-4o")).toBeInTheDocument()
expect(screen.getByText("gemini-2.5-pro")).toBeInTheDocument()
expect(screen.getByText("gpt-5.5")).toBeInTheDocument()
})
it("handles models that are deployed but not in supported list", () => {
@@ -290,18 +374,19 @@ describe("SapAiCoreModelPicker Component", () => {
<ExtensionStateContextProvider>
<SapAiCoreModelPicker
onModelChange={mockOnModelChange}
sapAiCoreModelDeployments={createDeployments(["anthropic--claude-3.5-sonnet", "unsupported-model"])}
selectedModelId="anthropic--claude-3.5-sonnet"
sapAiCoreModelDeployments={createDeployments(["gpt-4o", "unsupported-model"])}
selectedModelId="gpt-4o"
useOrchestrationMode={false}
/>
</ExtensionStateContextProvider>,
)
// Only supported deployed models should appear in deployed section
expect(screen.getByText("anthropic--claude-3.5-sonnet")).toBeInTheDocument()
expect(screen.getByText("gpt-4o")).toBeInTheDocument()
expect(screen.queryByText("unsupported-model")).not.toBeInTheDocument()
// Other supported models should appear in not deployed section
expect(screen.getByText("anthropic--claude-3-haiku")).toBeInTheDocument()
expect(screen.getByText("gpt-5.5")).toBeInTheDocument()
})
it("maintains correct dropdown structure with sections", () => {
@@ -309,8 +394,9 @@ describe("SapAiCoreModelPicker Component", () => {
<ExtensionStateContextProvider>
<SapAiCoreModelPicker
onModelChange={mockOnModelChange}
sapAiCoreModelDeployments={createDeployments(["anthropic--claude-3.5-sonnet"])}
selectedModelId="anthropic--claude-3.5-sonnet"
sapAiCoreModelDeployments={createDeployments(["gpt-4o"])}
selectedModelId="gpt-4o"
useOrchestrationMode={false}
/>
</ExtensionStateContextProvider>,
)
@@ -366,7 +452,12 @@ describe("SapAiCoreModelPicker Component", () => {
expect(screen.getByText("anthropic--claude-3.5-sonnet")).toBeInTheDocument()
expect(screen.getByText("anthropic--claude-3-haiku")).toBeInTheDocument()
expect(screen.getByText("gpt-4o")).toBeInTheDocument()
expect(screen.getByText("gpt-5.5")).toBeInTheDocument()
expect(screen.getByText("gemini-2.5-pro")).toBeInTheDocument()
expect(screen.getByText("gpt-4-base")).toBeInTheDocument()
expect(screen.getByText("gpt-5-codex")).toBeInTheDocument()
expect(screen.getByText("gpt-4-instruct")).toBeInTheDocument()
expect(screen.getByText("gpt-4-realtime")).toBeInTheDocument()
})
it("should auto-set deployment ID when model is selected but deployment ID is missing", () => {
@@ -268,6 +268,7 @@ export const ExtensionStateContextProvider: React.FC<{
const [state, setState] = useState<ExtensionState>({
version: "",
clineMessages: [],
queuedPrompts: [],
taskHistory: [],
shouldShowAnnouncement: false,
autoApprovalSettings: DEFAULT_AUTO_APPROVAL_SETTINGS,
+10 -5
View File
@@ -718,6 +718,11 @@
"grpc-tools",
],
"overrides": {
"@sap-cloud-sdk/connectivity": "4.6.0",
"@sap-cloud-sdk/http-client": "4.6.0",
"@sap-cloud-sdk/openapi": "4.6.0",
"@sap-cloud-sdk/resilience": "4.6.0",
"@sap-cloud-sdk/util": "4.6.0",
"diff": "8.0.4",
"js-yaml": "^4.1.1",
"protobufjs": "7.5.8",
@@ -2178,15 +2183,15 @@
"@sap-ai-sdk/prompt-registry": ["@sap-ai-sdk/prompt-registry@2.11.0", "", { "dependencies": { "@sap-ai-sdk/core": "^2.11.0", "zod": "^4.4.3" } }, "sha512-1WrqGjwHltF/aOXoNDPYtvvaprQarPsKZUC0wSMeC6XZokoYvjvHNrltLm1IFygpjTr8yma45JJkKcwV1qzvDg=="],
"@sap-cloud-sdk/connectivity": ["@sap-cloud-sdk/connectivity@4.7.0", "", { "dependencies": { "@sap-cloud-sdk/resilience": "^4.7.0", "@sap-cloud-sdk/util": "^4.7.0", "@sap/xsenv": "^6.2.0", "@sap/xssec": "^4.13.0", "async-retry": "^1.3.3", "axios": "^1.15.0", "jks-js": "^1.1.6", "jsonwebtoken": "^9.0.3", "safe-stable-stringify": "^2.5.0" } }, "sha512-+EgdTpGi3ZomPuv/ab+bWQIxUfJvownW2MzdYSvX7hkEkmKJfod0O6ja2OqC+OJBLm1TE0JpbJ6AcCkqeZYuOg=="],
"@sap-cloud-sdk/connectivity": ["@sap-cloud-sdk/connectivity@4.6.0", "", { "dependencies": { "@sap-cloud-sdk/resilience": "^4.6.0", "@sap-cloud-sdk/util": "^4.6.0", "@sap/xsenv": "^6.1.0", "@sap/xssec": "^4.13.0", "async-retry": "^1.3.3", "axios": "^1.15.0", "jks-js": "^1.1.6", "jsonwebtoken": "^9.0.3" } }, "sha512-Pf+0O1s4eDNR/MZ+UcyOyPa/U8dl3xUHEbjY+BG7DHZtLZFYVfKg2dV63ZTzeHypqMRVQePCN8yIyIz296FWHg=="],
"@sap-cloud-sdk/http-client": ["@sap-cloud-sdk/http-client@4.7.0", "", { "dependencies": { "@sap-cloud-sdk/connectivity": "^4.7.0", "@sap-cloud-sdk/resilience": "^4.7.0", "@sap-cloud-sdk/util": "^4.7.0", "axios": "^1.15.0" } }, "sha512-7+S6ru7SrnyKA2MGimX09oix0EVwmPGcCAz0TRwFZVnglU+VTRmZ8QdcwcVTR26E9YhkR4OVl6EK7jb2Z0OxfQ=="],
"@sap-cloud-sdk/http-client": ["@sap-cloud-sdk/http-client@4.6.0", "", { "dependencies": { "@sap-cloud-sdk/connectivity": "^4.6.0", "@sap-cloud-sdk/resilience": "^4.6.0", "@sap-cloud-sdk/util": "^4.6.0", "axios": "^1.15.0" } }, "sha512-fBaJAnOsHGyIlRS4HA2XYxp9v3GjUWPACoGDRg01jjoq49r3KjIMi8FqycMwKTF9QnkX/4rlPtj2wnziHgG2bg=="],
"@sap-cloud-sdk/openapi": ["@sap-cloud-sdk/openapi@4.7.0", "", { "dependencies": { "@sap-cloud-sdk/connectivity": "^4.7.0", "@sap-cloud-sdk/http-client": "^4.7.0", "@sap-cloud-sdk/resilience": "^4.7.0", "@sap-cloud-sdk/util": "^4.7.0", "axios": "^1.15.0" } }, "sha512-OKTEGVScCRsfgL9Lk/bEKcGnyfq0GgruXuZfsxoMbTqbe2f9X/fgHgXIWQEkTuXQplkrSDiNFRyvl0n/ON9Ycg=="],
"@sap-cloud-sdk/openapi": ["@sap-cloud-sdk/openapi@4.6.0", "", { "dependencies": { "@sap-cloud-sdk/connectivity": "^4.6.0", "@sap-cloud-sdk/http-client": "^4.6.0", "@sap-cloud-sdk/resilience": "^4.6.0", "@sap-cloud-sdk/util": "^4.6.0", "axios": "^1.15.0" } }, "sha512-If5eD5DWUA7nXUUvMYuG3p8+JjSR2+DZPnE88YdDR7+kIbblDuV2c4QbyjpT0oeA2sQ3S9iZVx8cZP8n5dmdHQ=="],
"@sap-cloud-sdk/resilience": ["@sap-cloud-sdk/resilience@4.7.0", "", { "dependencies": { "@sap-cloud-sdk/util": "^4.7.0", "async-retry": "^1.3.3", "axios": "^1.15.0", "opossum": "^9.0.0" } }, "sha512-L6PV49nNyZHnTNFbvaufadm+qOhaammXLXkDQmD7WIzMjhiYMqa/obK3NJoohe/kZ7q73jPB2vdLsAqopqTh0A=="],
"@sap-cloud-sdk/resilience": ["@sap-cloud-sdk/resilience@4.6.0", "", { "dependencies": { "@sap-cloud-sdk/util": "^4.6.0", "async-retry": "^1.3.3", "axios": "^1.15.0", "opossum": "^9.0.0" } }, "sha512-NMY683lNvJcE5dghVqUsBLM/voTLE6MMn5OmmB5BkG8qp5TZSuy/lhMZCVfGpBVnRLv6k19vWiBcueXxYg8bcQ=="],
"@sap-cloud-sdk/util": ["@sap-cloud-sdk/util@4.7.0", "", { "dependencies": { "axios": "^1.15.0", "logform": "^2.7.0", "voca": "^1.4.1", "winston": "^3.19.0", "winston-transport": "^4.9.0" } }, "sha512-nWJXMdM0Pcx/ipM2+5BvSciQKyCc0LAAO+acCOAQp65SA4HEQ2Odp9yxidY4ijW42TVp3fSMBvGwVjbWWx9Uew=="],
"@sap-cloud-sdk/util": ["@sap-cloud-sdk/util@4.6.0", "", { "dependencies": { "axios": "^1.15.0", "logform": "^2.7.0", "voca": "^1.4.1", "winston": "^3.19.0", "winston-transport": "^4.9.0" } }, "sha512-z1gFjgzhAUhzPy1IkwWjfOrnPHh9KrEZbLlFBG5LbL3oBS3JMmKusW0162Hq4r+AVTR43akEoHxk07p247YQTw=="],
"@sap/xsenv": ["@sap/xsenv@6.2.1", "", { "dependencies": { "debug": "4.4.3", "node-cache": "^5.1.2", "verror": "1.10.1" } }, "sha512-R1p7VdD3N3jvdkL8av4vLqF+cTQihTz9mCqqF+oa9rVZvgLaCb4ODyZ1dln5/fBgg1OSuch0ESxu3AqZrXVknw=="],
+6 -1
View File
@@ -71,7 +71,12 @@
"js-yaml": "^4.1.1",
"serialize-javascript": ">=7.0.3",
"protobufjs": "7.5.8",
"diff": "8.0.4"
"diff": "8.0.4",
"@sap-cloud-sdk/connectivity": "4.6.0",
"@sap-cloud-sdk/http-client": "4.6.0",
"@sap-cloud-sdk/openapi": "4.6.0",
"@sap-cloud-sdk/resilience": "4.6.0",
"@sap-cloud-sdk/util": "4.6.0"
},
"trustedDependencies": [
"better-sqlite3",
@@ -1541,69 +1541,6 @@ describe("AgentRuntime", () => {
expect(result.outputText).toBe("done");
});
it("executes tools in parallel but preserves assistant order in appended messages", async () => {
const executionOrder: string[] = [];
const finishOrder: string[] = [];
const slow: AgentTool = {
name: "slow",
description: "slow tool",
inputSchema: { type: "object" },
async execute() {
executionOrder.push("slow-start");
await new Promise((resolve) => setTimeout(resolve, 25));
finishOrder.push("slow-finish");
return { name: "slow" };
},
};
const fast: AgentTool = {
name: "fast",
description: "fast tool",
inputSchema: { type: "object" },
async execute() {
executionOrder.push("fast-start");
finishOrder.push("fast-finish");
return { name: "fast" };
},
};
const model = new ScriptedModel([
() => [
{
type: "tool-call-delta",
toolCallId: "slow_call",
toolName: "slow",
inputText: "{}",
},
{
type: "tool-call-delta",
toolCallId: "fast_call",
toolName: "fast",
inputText: "{}",
},
{ type: "finish", reason: "tool-calls" },
],
() => [
{ type: "text-delta", text: "done" },
{ type: "finish", reason: "stop" },
],
]);
const runtime = new AgentRuntime({
model,
tools: [slow, fast],
toolExecution: "parallel",
});
const result = await runtime.run("Parallel");
expect(executionOrder).toEqual(["slow-start", "fast-start"]);
expect(finishOrder).toEqual(["fast-finish", "slow-finish"]);
const toolMessages = result.messages.filter(
(message) => message.role === "tool",
);
expect(toolMessages[0]?.content[0]).toMatchObject({ toolName: "slow" });
expect(toolMessages[1]?.content[0]).toMatchObject({ toolName: "fast" });
});
it("captures events, logger calls, telemetry, and failed tool runs", async () => {
const telemetry = {
capture: vi.fn(),
+2 -12
View File
@@ -365,8 +365,7 @@ function normalizeInput(input: AgentRunInput): AgentMessage[] {
}
export class AgentRuntime {
private config: Required<Pick<BaseAgentRuntimeConfig, "toolExecution">> &
BaseAgentRuntimeConfig;
private config: BaseAgentRuntimeConfig;
private readonly listeners = new Set<AgentEventListener>();
// biome-ignore lint/suspicious/noExplicitAny: tool input/output types vary per tool
private readonly tools = new Map<string, AgentTool<any, any>>();
@@ -396,10 +395,7 @@ export class AgentRuntime {
constructor(config: AgentRuntimeConfig) {
const resolved = resolveRuntimeConfig(config);
this.config = {
...resolved,
toolExecution: resolved.toolExecution ?? "sequential",
};
this.config = resolved;
this.state.agentId = resolved.agentId ?? createUID("agent");
this.state.agentRole = resolved.agentRole;
this.state.parentAgentId = resolved.parentAgentId;
@@ -1086,12 +1082,6 @@ export class AgentRuntime {
prepared.push(await this.prepareToolExecution(toolCall));
}
if (this.config.toolExecution === "parallel") {
return Promise.all(
prepared.map((execution) => this.executePreparedTool(execution)),
);
}
const results: AgentMessage[] = [];
for (const execution of prepared) {
results.push(await this.executePreparedTool(execution));
@@ -387,6 +387,10 @@ export function createSearchTool(
});
}
const RUN_COMMANDS_SHARED_INSTRUCTIONS =
"Use for listing files, checking git status, running builds, executing tests, etc. " +
"Commands must be non-interactive. Commands that require follow-up input like pagers should be skipped or used with supported flags/env (e.g. git --no-pager, --non-interactive) to bypass the interaction steps. ";
/**
* Create the run_commands shell tool for the current platform.
*
@@ -408,12 +412,12 @@ export function createShellTool(
return createTool<unknown, ToolOperationResult[]>({
name: "run_commands",
description: isWindows
? "Run shell commands from the root of the workspace in Windows environment. " +
"Use for listing files, checking git status, running builds, executing tests, etc. " +
? "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."
: "Run shell commands from the root of the workspace. " +
"Use for listing files, checking git status, running builds, executing tests, etc. " +
: "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. " +
`Output beyond ~${Math.round(MAX_COMMAND_OUTPUT_CHARS / 1000)}k characters is middle-truncated (start and end preserved); pipe through grep/head/tail when you need specific sections of large output. ` +
"For long-running commands, run them in background and redirect output to a tmp file that you can read from later.",
@@ -1,13 +1,14 @@
import type {
AgentConfig,
AgentEvent,
AgentHooks,
AgentTool,
BasicLogger,
HookErrorMode,
ITelemetryService,
ToolApprovalRequest,
ToolApprovalResult,
import {
type AgentConfig,
type AgentEvent,
type AgentHooks,
type AgentTool,
type BasicLogger,
DEFAULT_API_TIMEOUT_MS,
type HookErrorMode,
type ITelemetryService,
type ToolApprovalRequest,
type ToolApprovalResult,
} from "@cline/shared";
import { SessionRuntime } from "../../../runtime/orchestration/session-runtime-orchestrator";
import {
@@ -27,6 +28,8 @@ export type DelegatedAgentConnectionConfig = Pick<
| "providerConfig"
| "knownModels"
| "thinking"
| "thinkingBudgetTokens"
| "reasoningEffort"
| "maxTokensPerTurn"
>;
@@ -37,6 +40,7 @@ export interface DelegatedAgentRuntimeConfig
clinePlatform?: string;
clineIdeName?: string;
maxIterations?: number;
apiTimeoutMs?: number;
hooks?: AgentHooks;
extensions?: AgentExtension[];
logger?: BasicLogger;
@@ -88,6 +92,8 @@ export function createDelegatedAgentConfigProvider(
providerConfig: runtimeConfig.providerConfig,
knownModels: runtimeConfig.knownModels,
thinking: runtimeConfig.thinking,
thinkingBudgetTokens: runtimeConfig.thinkingBudgetTokens,
reasoningEffort: runtimeConfig.reasoningEffort,
maxTokensPerTurn: runtimeConfig.maxTokensPerTurn,
}),
updateConnectionDefaults: (overrides) => {
@@ -113,12 +119,13 @@ export function buildDelegatedAgentConfig(
systemPrompt,
tools: options.tools,
maxIterations: options.maxIterations ?? runtimeConfig.maxIterations,
apiTimeoutMs: runtimeConfig.apiTimeoutMs ?? DEFAULT_API_TIMEOUT_MS,
parentAgentId: options.parentAgentId,
abortSignal: options.abortSignal,
onEvent: options.onEvent,
hooks: runtimeConfig.hooks,
extensions: runtimeConfig.extensions,
hookErrorMode: options.hookErrorMode,
hookErrorMode: options.hookErrorMode ?? "ignore",
toolPolicies: options.toolPolicies,
requestToolApproval: options.requestToolApproval,
logger: runtimeConfig.logger,
@@ -1,7 +1,6 @@
/**
* Unit tests for `createAgentRuntimeConfig` and its small pure
* helpers (`buildModelOptions`, `buildMessageModelInfo`,
* `resolveToolExecution`).
* helpers (`buildModelOptions`, `buildMessageModelInfo`).
*
*/
@@ -17,7 +16,6 @@ import {
buildMessageModelInfo,
buildModelOptions,
createAgentRuntimeConfig,
resolveToolExecution,
} from "./agent-runtime-config-builder";
// ---------------------------------------------------------------------------
@@ -102,25 +100,6 @@ describe("buildMessageModelInfo", () => {
});
});
// ---------------------------------------------------------------------------
// resolveToolExecution
// ---------------------------------------------------------------------------
describe("resolveToolExecution", () => {
it("returns undefined when unset", () => {
expect(resolveToolExecution(undefined)).toBeUndefined();
});
it("returns 'sequential' for 1", () => {
expect(resolveToolExecution(1)).toBe("sequential");
});
it("returns 'parallel' for >= 2", () => {
expect(resolveToolExecution(2)).toBe("parallel");
expect(resolveToolExecution(8)).toBe("parallel");
});
});
// ---------------------------------------------------------------------------
// createAgentRuntimeConfig
// ---------------------------------------------------------------------------
@@ -135,7 +114,6 @@ describe("createAgentRuntimeConfig", () => {
thinking: true,
reasoningEffort: "high",
maxIterations: 7,
maxParallelToolCalls: 4,
completionPolicy: { requireCompletionTool: true },
toolPolicies: { "*": { autoApprove: false } },
requestToolApproval: async () => ({ approved: true }),
@@ -175,7 +153,6 @@ describe("createAgentRuntimeConfig", () => {
});
expect(runtimeConfig.tools).toBe(tools);
expect(runtimeConfig.maxIterations).toBe(7);
expect(runtimeConfig.toolExecution).toBe("parallel");
expect(runtimeConfig.completionPolicy).toEqual({
requireCompletionTool: true,
});
@@ -88,7 +88,6 @@ export function createAgentRuntimeConfig(
const modelOptions = buildModelOptions(agentConfig);
const messageModelInfo = buildMessageModelInfo(agentConfig);
const hooks = input.hooks;
const toolExecution = resolveToolExecution(agentConfig.maxParallelToolCalls);
const config: AgentRuntimeConfig = {
sessionId: input.sessionId ?? agentConfig.sessionId,
@@ -110,7 +109,6 @@ export function createAgentRuntimeConfig(
initialMessages: input.initialMessages,
completionPolicy: agentConfig.completionPolicy,
maxIterations: agentConfig.maxIterations,
toolExecution,
toolPolicies: agentConfig.toolPolicies,
toolContextMetadata: input.toolContextMetadata,
requestToolApproval: agentConfig.requestToolApproval,
@@ -166,16 +164,3 @@ export function buildMessageModelInfo(
family,
};
}
/**
* `"parallel"` when `maxParallelToolCalls ≥ 2`, `"sequential"` when
* `1`, `undefined` when the caller did not specify.
*/
export function resolveToolExecution(
maxParallelToolCalls: number | undefined,
): "sequential" | "parallel" | undefined {
if (maxParallelToolCalls === undefined) {
return undefined;
}
return maxParallelToolCalls >= 2 ? "parallel" : "sequential";
}
@@ -3,13 +3,14 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { MessageWithMetadata } from "@cline/llms";
import type {
AgentConfig,
AgentEvent,
AgentExtensionAutomationContext,
AgentResult,
AgentRuntimeEvent,
BasicLogger,
import {
type AgentConfig,
type AgentEvent,
type AgentExtensionAutomationContext,
type AgentResult,
type AgentRuntimeEvent,
type BasicLogger,
DEFAULT_API_TIMEOUT_MS,
} from "@cline/shared";
import { setClineDir, setHomeDir } from "@cline/shared/storage";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
@@ -294,6 +295,87 @@ describe("LocalRuntimeHost", () => {
);
});
it("materializes local runtime defaults while preserving explicit overrides", async () => {
const defaultSessionId = "sess-defaults-default";
const configuredSessionId = "sess-defaults-configured";
const sessionService = {
ensureSessionsDir: vi.fn().mockReturnValue("/tmp/sessions"),
createRootSessionWithArtifacts: vi
.fn()
.mockResolvedValueOnce({
manifestPath: "/tmp/manifest-default.json",
messagesPath: "/tmp/messages-default.json",
manifest: createManifest(defaultSessionId),
})
.mockResolvedValueOnce({
manifestPath: "/tmp/manifest-configured.json",
messagesPath: "/tmp/messages-configured.json",
manifest: createManifest(configuredSessionId),
}),
persistSessionMessages: vi.fn(),
writeSessionManifest: vi.fn(),
listSessions: vi.fn().mockResolvedValue([]),
deleteSession: vi.fn().mockResolvedValue({ deleted: true }),
};
const runtimeBuilder = {
build: vi.fn().mockReturnValue({ tools: [], shutdown: vi.fn() }),
};
const agent = {
run: vi.fn().mockResolvedValue(createResult()),
continue: vi.fn().mockResolvedValue(createResult()),
getMessages: vi.fn().mockReturnValue([]),
getAgentId: vi.fn().mockReturnValue("agent-root-1"),
getConversationId: vi.fn().mockReturnValue("conv-root-1"),
abort: vi.fn(),
subscribeEvents: vi.fn().mockReturnValue(() => {}),
canStartRun: vi.fn().mockReturnValue(true),
shutdown: vi.fn().mockResolvedValue(undefined),
};
const createAgent = vi.fn(() => agent as never);
const manager = new RuntimeHostUnderTest({
distinctId,
sessionService: sessionService as never,
runtimeBuilder: runtimeBuilder as never,
createAgent,
});
await manager.startSession(
normalizeStartInput({
config: createConfig({ sessionId: defaultSessionId }),
prompt: "hello",
}),
);
await manager.startSession(
normalizeStartInput({
config: createConfig({
sessionId: configuredSessionId,
apiTimeoutMs: 60_000,
maxTokensPerTurn: 4096,
thinking: true,
thinkingBudgetTokens: 1024,
}),
prompt: "hello",
}),
);
expect(createAgent).toHaveBeenNthCalledWith(
1,
expect.objectContaining({
apiTimeoutMs: DEFAULT_API_TIMEOUT_MS,
hookErrorMode: "ignore",
}),
);
expect(createAgent).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
apiTimeoutMs: 60_000,
maxTokensPerTurn: 4096,
thinking: true,
thinkingBudgetTokens: 1024,
}),
);
});
it("resolves OAuth credentials before creating the agent", async () => {
const sessionId = "sess-oauth-bootstrap";
const manifest = createManifest(sessionId);
@@ -8,6 +8,7 @@ import {
type AgentResult,
captureSdkError,
createSessionId,
DEFAULT_API_TIMEOUT_MS,
type ITelemetryService,
isLikelyAuthError,
normalizeUserInput,
@@ -465,18 +466,26 @@ export class LocalRuntimeHost implements RuntimeHost {
headers: providerConfig.headers,
knownModels: providerConfig.knownModels,
providerConfig,
thinking: configWithProvider.thinking,
thinking: configWithProvider.thinking ?? providerConfig.thinking,
reasoningEffort:
configWithProvider.reasoningEffort ?? providerConfig.reasoningEffort,
maxTokensPerTurn: configWithProvider.maxTokensPerTurn,
thinkingBudgetTokens:
configWithProvider.thinkingBudgetTokens ??
providerConfig.thinkingBudgetTokens,
systemPrompt: configWithProvider.systemPrompt,
maxIterations: configWithProvider.maxIterations,
maxTokensPerTurn:
configWithProvider.maxTokensPerTurn ?? providerConfig.maxOutputTokens,
apiTimeoutMs:
configWithProvider.apiTimeoutMs ??
providerConfig.timeoutMs ??
DEFAULT_API_TIMEOUT_MS,
execution: configWithProvider.execution,
prepareTurn: createContextCompactionPrepareTurn(configWithProvider),
tools,
hooks: bootstrap.hooks,
extensions,
hookErrorMode: configWithProvider.hookErrorMode,
hookErrorMode: configWithProvider.hookErrorMode ?? "ignore",
initialMessages: bootstrap.effectiveInput.initialMessages,
userFileContentLoader: loadUserFileContent,
toolPolicies: bootstrap.toolPolicies,
@@ -160,6 +160,10 @@ export function createSessionSpawnTool(
providerConfig: config.providerConfig,
knownModels: config.knownModels,
thinking: config.thinking,
thinkingBudgetTokens: config.thinkingBudgetTokens,
reasoningEffort: config.reasoningEffort,
maxTokensPerTurn: config.maxTokensPerTurn,
apiTimeoutMs: config.apiTimeoutMs,
maxIterations: config.maxIterations,
hooks: config.hooks,
extensions: config.extensions,
@@ -178,6 +182,10 @@ export function createSessionSpawnTool(
providerConfig: config.providerConfig,
knownModels: config.knownModels,
thinking: config.thinking,
thinkingBudgetTokens: config.thinkingBudgetTokens,
reasoningEffort: config.reasoningEffort,
maxTokensPerTurn: config.maxTokensPerTurn,
apiTimeoutMs: config.apiTimeoutMs,
},
updateConnectionDefaults: () => {},
},
@@ -485,7 +485,10 @@ export class DefaultRuntimeBuilder implements RuntimeBuilder {
providerConfig: config.providerConfig,
knownModels: config.knownModels,
thinking: config.thinking,
thinkingBudgetTokens: config.thinkingBudgetTokens,
reasoningEffort: config.reasoningEffort,
maxTokensPerTurn: config.maxTokensPerTurn,
apiTimeoutMs: config.apiTimeoutMs,
maxIterations: config.maxIterations,
hooks,
extensions: runtimeExtensions,
@@ -106,13 +106,15 @@ describe("resolveProviderConfig", () => {
});
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(resolved?.knownModels?.["cline-pass/live-pass-model"]).toMatchObject({
id: "cline-pass/live-pass-model",
name: "Live Pass Model",
contextWindow: 256_000,
maxInputTokens: 200_000,
maxTokens: 32_000,
});
expect(resolved?.knownModels?.["cline-pass/live-pass-model"]).toMatchObject(
{
id: "cline-pass/live-pass-model",
name: "Live Pass Model",
contextWindow: 256_000,
maxInputTokens: 200_000,
maxTokens: 32_000,
},
);
expect(resolved?.knownModels?.["cline-pass/mimo-v2.5-pro"]).toBeUndefined();
});
@@ -152,9 +154,9 @@ describe("resolveProviderConfig", () => {
});
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(
resolved?.knownModels?.["cline-pass/mimo-v2.5-pro"]?.name,
).toBe("MiMo-V2.5-Pro");
expect(resolved?.knownModels?.["cline-pass/mimo-v2.5-pro"]?.name).toBe(
"MiMo-V2.5-Pro",
);
expect(
resolved?.knownModels?.["vendor/live-openrouter-model"],
).toBeUndefined();
+10 -1
View File
@@ -33,14 +33,23 @@ export interface CoreModelConfig {
* Request model-side thinking/reasoning when supported.
*/
thinking?: boolean;
/**
* Maximum tokens for model thinking/reasoning, when supported.
*/
thinkingBudgetTokens?: number;
/**
* Explicit reasoning effort override for capable models.
*/
reasoningEffort?: ProviderConfig["reasoningEffort"];
/**
* Maximum output tokens per API call.
* Maximum output tokens per model API call.
*/
maxTokensPerTurn?: number;
/**
* Timeout for each model API call. Defaults to the agent config API
* timeout default when omitted.
*/
apiTimeoutMs?: number;
}
export interface CoreRuntimeFeatures {
+8 -2
View File
@@ -2,12 +2,14 @@ import { getClineEnvironmentConfig } from "@cline/shared";
export const CLINE_NOT_SUBSCRIBED_RESPONSE_MESSAGE =
"the user is not subscribed to required model plan";
const CLINE_NOT_SUBSCRIBED_FORMATTED_MESSAGE_PREFIX =
"no access to clinepass subscription models yet. subscribe to clinepass";
export const CLINE_ORG_INDIVIDUAL_INFERENCE_SUBSCRIPTION_RESPONSE_MESSAGE =
"organization accounts cannot use individual model inference subscriptions";
export function getClinePassSubscriptionUrl(): string {
return `${new URL(
"/promo?code=CLI-100&personal=true",
"/dashboard/subscription?personal=true",
getClineEnvironmentConfig().appBaseUrl,
).toString()}`;
}
@@ -53,7 +55,11 @@ export function isClineOrgIndividualInferenceSubscriptionError(
}
export function isClineNotSubscribedMessage(text: string): boolean {
return text.toLowerCase().includes(CLINE_NOT_SUBSCRIBED_RESPONSE_MESSAGE);
const normalized = text.trim().toLowerCase();
return (
normalized.includes(CLINE_NOT_SUBSCRIBED_RESPONSE_MESSAGE) ||
normalized.includes(CLINE_NOT_SUBSCRIBED_FORMATTED_MESSAGE_PREFIX)
);
}
export function isClineOrgIndividualInferenceSubscriptionMessage(
@@ -2,8 +2,8 @@ import { describe, expect, it } from "vitest";
import {
ClineNotSubscribedError,
ClineOrgIndividualInferenceSubscriptionError,
getClineOrgIndividualInferenceSubscriptionMessage,
getClineNotSubscribedMessage,
getClineOrgIndividualInferenceSubscriptionMessage,
isClineNotSubscribedMessage,
isClineOrgIndividualInferenceSubscriptionMessage,
} from "./errors";
@@ -71,6 +71,14 @@ describe("ClineNotSubscribedError", () => {
false,
);
});
it("detects the formatted ClinePass subscription message regardless of URL", () => {
expect(
isClineNotSubscribedMessage(
"No access to ClinePass subscription models yet. Subscribe to ClinePass, the low cost open weights model coding plan: https://staging-app.cline.bot/promo?code=CLI-100&personal=true",
),
).toBe(true);
});
});
describe("ClineOrgIndividualInferenceSubscriptionError", () => {
-1
View File
@@ -429,7 +429,6 @@ export interface AgentRuntimeConfig {
requireCompletionTool?: boolean;
completionGuard?: () => string | undefined;
};
toolExecution?: "sequential" | "parallel";
toolPolicies?: Record<string, ToolPolicy>;
toolContextMetadata?: Record<string, unknown>;
requestToolApproval?: (
+3 -7
View File
@@ -36,6 +36,8 @@ import type { BasicLogger } from "../logging/logger";
import type { ITelemetryService } from "../services/telemetry";
import type { WorkspaceInfo } from "../session/workspace";
export const DEFAULT_API_TIMEOUT_MS = 180_000;
// =============================================================================
// Agent Events
// =============================================================================
@@ -702,11 +704,6 @@ export interface AgentConfig {
* If undefined, no iteration cap is enforced.
*/
maxIterations?: number;
/**
* Maximum number of tool calls to execute concurrently in a single iteration.
* @default 8
*/
maxParallelToolCalls?: number;
/**
* Maximum output tokens per API call
*/
@@ -878,9 +875,8 @@ export const AgentConfigSchema = z.object({
systemPrompt: z.string(),
tools: z.array(z.custom<AgentTool>()),
maxIterations: z.number().positive().optional(),
maxParallelToolCalls: z.number().int().positive().default(8),
maxTokensPerTurn: z.number().positive().optional(),
apiTimeoutMs: z.number().positive().default(180000),
apiTimeoutMs: z.number().positive().default(DEFAULT_API_TIMEOUT_MS),
userFileContentLoader: z
.function()
.input([z.string()])