mirror of
https://github.com/cline/cline.git
synced 2026-09-11 16:42:40 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ac2bdbfd5a | ||
|
|
bebc581652 | ||
|
|
21ebee3638 | ||
|
|
7d78c5f074 | ||
|
|
352a23a6da | ||
|
|
176662ee20 | ||
|
|
309ad9da72 | ||
|
|
16a6926985 | ||
|
|
d6db723c9d | ||
|
|
ce1fc20a9f | ||
|
|
b780a5ec00 | ||
|
|
285cd6d54c | ||
|
|
ff845539e4 |
+1
-1
@@ -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) {
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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> {
|
||||
|
||||
@@ -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")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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>
|
||||
) : (
|
||||
|
||||
+3
@@ -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,
|
||||
|
||||
+61
@@ -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"
|
||||
|
||||
+5
-1
@@ -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}
|
||||
/>
|
||||
|
||||
+12
-72
@@ -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}>
|
||||
|
||||
+158
-67
@@ -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,
|
||||
|
||||
@@ -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
@@ -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(),
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -429,7 +429,6 @@ export interface AgentRuntimeConfig {
|
||||
requireCompletionTool?: boolean;
|
||||
completionGuard?: () => string | undefined;
|
||||
};
|
||||
toolExecution?: "sequential" | "parallel";
|
||||
toolPolicies?: Record<string, ToolPolicy>;
|
||||
toolContextMetadata?: Record<string, unknown>;
|
||||
requestToolApproval?: (
|
||||
|
||||
@@ -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()])
|
||||
|
||||
Reference in New Issue
Block a user