Compare commits

...
Author SHA1 Message Date
arafatkatze 9b222296cb feat: Fixing payment stuff 2025-07-30 08:49:22 -07:00
abeatrix de30ce7fc5 clean up 2025-07-29 22:43:12 -07:00
abeatrix 2b6601f281 Update toolResult message 2025-07-29 22:39:26 -07:00
abeatrix 02d069cdbf Merge branch 'main' into bee/fix-plan-response 2025-07-29 16:33:02 -07:00
abeatrix d491307140 revert imports 2025-07-29 13:33:42 -07:00
abeatrix f0090c50a4 Merge branch 'main' into bee/fix-plan-response 2025-07-29 13:30:14 -07:00
abeatrix 4b9f47eaa2 changeset added 2025-07-28 22:38:36 -07:00
abeatrix 87d49ed0a8 fix: Prevent assistant from asking repeatedly to switch to Act mode in Plan mode
This PR fixes a bug where the assistant would continuously prompt users to switch from Plan mode to Act mode without letting user to response

Problem

The core issue was in the conversation flow where:
1. Immediate Loop Without User Input: When the assistant suggested switching to Act mode, the system would immediately continue the conversation loop without waiting for user response
2. No Response Opportunity: Users never got the chance to accept, decline, or provide feedback on the mode switch suggestion
3. Infinite Prompting: This created a continuous loop where the assistant would keep suggesting "toggle to Act mode" repeatedly in the same conversation turn
4. Poor Plan Collaboration: Users couldn't effectively iterate on plans because they were trapped in mode-switching prompts

Root Cause

The issue was in the task execution flow where the assistant's response containing mode switch suggestions would not properly end the conversation turn, causing the system to continue processing without user interaction.

Changes

- Convert regular imports to type imports where appropriate
- Reorder imports for better organization
- Add early return in togglePlanActModeWithChatSettings when mode unchanged
- Optimize performance by avoiding unnecessary mode switches
- Optimize mode switching by setting loading state instead of using timeout that cause delays between UI and host
- Exit task loop early when assistant mentioned "toggle to act mode" in plan mode response
2025-07-28 22:35:29 -07:00
12 changed files with 136 additions and 50 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": patch
---
prevent assistant from asking to switch to Act from Plan mode repeatedly
+10 -1
View File
@@ -26,10 +26,19 @@ export async function getUserCredits(controller: Controller, request: EmptyReque
throw new Error("Failed to fetch user credits data")
}
const packedPaymentTransactions = (paymentTransactions || []).map((tx) => {
return {
paidAt: JSON.stringify(tx),
creatorId: tx.id,
amountCents: tx.amountCents,
credits: tx.credits,
}
})
return UserCreditsData.create({
balance: balance ? { currentBalance: balance.balance / 100 } : { currentBalance: 0 },
usageTransactions: usageTransactions,
paymentTransactions: paymentTransactions,
paymentTransactions: packedPaymentTransactions,
})
} catch (error) {
console.error(`Failed to fetch user credits data: ${error}`)
@@ -78,7 +78,7 @@ export async function refreshGroqModels(controller: Controller, request: EmptyRe
inputPrice: staticModelInfo?.inputPrice || 0,
outputPrice: staticModelInfo?.outputPrice || 0,
cacheWritesPrice: (staticModelInfo as any)?.cacheWritesPrice || 0,
cacheReadsPrice: (staticModelInfo as any).cacheReadsPrice || 0,
cacheReadsPrice: (staticModelInfo as any)?.cacheReadsPrice || 0,
description: generateModelDescription(rawModel, staticModelInfo),
}
+12 -3
View File
@@ -106,7 +106,12 @@ export class ToolExecutor {
type: ClineAsk,
text?: string,
partial?: boolean,
) => Promise<{ response: ClineAskResponse; text?: string; images?: string[]; files?: string[] }>,
) => Promise<{
response: ClineAskResponse
text?: string
images?: string[]
files?: string[]
}>,
private saveCheckpoint: (isAttemptCompletionMessage?: boolean) => Promise<void>,
private sayAndCreateMissingParamError: (toolName: ToolUseName, paramName: string, relPath?: string) => Promise<any>,
private removeLastPartialMessageIfExistsWithType: (type: "ask" | "say", askOrSay: ClineAsk | ClineSay) => Promise<void>,
@@ -444,7 +449,7 @@ export class ToolExecutor {
case "write_to_file":
case "replace_in_file": {
const relPath: string | undefined = block.params.path
let content: string | undefined = block.params.content // for write_to_file
const content: string | undefined = block.params.content // for write_to_file
let diff: string | undefined = block.params.diff // for replace_in_file
if (!relPath || (!content && !diff)) {
// checking for content/diff ensures relPath is complete
@@ -2190,7 +2195,11 @@ export class ToolExecutor {
} else {
// if we didn't switch to ACT MODE, then we can just send the user_feedback message
this.pushToolResult(
formatResponse.toolResult(`<user_message>\n${text}\n</user_message>`, images, fileContentString),
formatResponse.toolResult(
`The user replied with the following message but has not toggled to ACT MODE:\n<user_message>\n${text}\n</user_message>`,
images,
fileContentString,
),
block,
)
}
+8
View File
@@ -2450,6 +2450,14 @@ export class Task {
content: [{ type: "text", text: assistantMessage }],
})
// Check if assistant message contains "toggle to Act mode" pattern - if so, end the loop to wait for user's response
const trimmedAssistantMessage = assistantMessage?.toLowerCase().trim()
const hasPlanModeResponseTag = trimmedAssistantMessage?.includes("</plan_mode_respond>")
if (hasPlanModeResponseTag && trimmedAssistantMessage && /toggle to act mode\b/i.test(trimmedAssistantMessage)) {
this.taskState.isAwaitingPlanResponse = true
return true
}
// NOTE: this comment is here for future reference - this was a workaround for userMessageContent not getting set to true. It was due to it not recursively calling for partial blocks when didRejectTool, so it would get stuck waiting for a partial block to complete before it could continue.
// in case the content blocks finished
// it may be the api stream finished after the last parsed content block was executed, so we are able to detect out of bounds and set userMessageContentReady to true (note you should not call presentAssistantMessage since if the last block is completed it will be presented again)
+2 -4
View File
@@ -129,10 +129,8 @@ export class ClineAccountService {
console.error("Failed to fetch user ID for usage transactions")
return undefined
}
const data = await this.authenticatedRequest<{ paymentTransactions: PaymentTransaction[] }>(
`/api/v1/users/${me.id}/payments`,
)
return data.paymentTransactions
const data = await this.authenticatedRequest<{ items: PaymentTransaction[] }>(`/api/v1/users/${me.id}/payments`)
return data.items
} catch (error) {
console.error("Failed to fetch payment transactions (RPC):", error)
return undefined
+16 -2
View File
@@ -43,10 +43,24 @@ export interface UsageTransaction {
}
export interface PaymentTransaction {
paidAt: string
creatorId: string
id: string
transactionId: string
userId: string
amountCents: number
credits: number
type: string
status: string
providerReference: string
metadata: {
customer_id: string
event_id: string
payment_id: string
provider: string
subscription_id: string
}
createdAt: string
updatedAt: string
completedAt: string
}
export interface OrganizationBalanceResponse {
@@ -11,7 +11,7 @@ import VSCodeButtonLink from "../common/VSCodeButtonLink"
import { AccountWelcomeView } from "./AccountWelcomeView"
import { CreditBalance } from "./CreditBalance"
import CreditsHistoryTable from "./CreditsHistoryTable"
import { convertProtoUsageTransactions, getClineUris, getMainRole } from "./helpers"
import { convertProtoPaymentTransactions, convertProtoUsageTransactions, getClineUris, getMainRole } from "./helpers"
type AccountViewProps = {
clineUser: ClineUser | null
@@ -112,7 +112,7 @@ export const ClineAccountView = ({ clineUser, userOrganizations, activeOrganizat
setBalance(newBalance ?? null)
const newUsage = convertProtoUsageTransactions(response.usageTransactions)
setUsageData((prev) => (deepEqual(newUsage, prev) ? prev : newUsage))
const newPaymentsData = response.paymentTransactions
const newPaymentsData = convertProtoPaymentTransactions(response.paymentTransactions)
setPaymentsData((prev) => (deepEqual(newPaymentsData, prev) ? prev : newPaymentsData))
} catch (error) {
console.error("Failed to fetch user credit:", error)
@@ -1,4 +1,4 @@
import type { PaymentTransaction, UsageTransaction } from "@shared/ClineAccount"
import type { PaymentTransaction as ClinePaymentTransaction, UsageTransaction } from "@shared/ClineAccount"
import { VSCodeDataGrid, VSCodeDataGridCell, VSCodeDataGridRow } from "@vscode/webview-ui-toolkit/react"
import { useState } from "react"
import { formatDollars, formatTimestamp } from "@/utils/format"
@@ -7,7 +7,7 @@ import { TabButton } from "../mcp/configuration/McpConfigurationView"
interface CreditsHistoryTableProps {
isLoading: boolean
usageData: UsageTransaction[]
paymentsData: PaymentTransaction[]
paymentsData: ClinePaymentTransaction[]
showPayments?: boolean
}
@@ -93,7 +93,7 @@ const CreditsHistoryTable = ({ isLoading, usageData, paymentsData, showPayments
{paymentsData.map((row, index) => (
<VSCodeDataGridRow key={index}>
<VSCodeDataGridCell grid-column="1">
{formatTimestamp(row.paidAt)}
{formatTimestamp(row.completedAt)}
</VSCodeDataGridCell>
<VSCodeDataGridCell grid-column="2">{`$${formatDollars(row.amountCents)}`}</VSCodeDataGridCell>
<VSCodeDataGridCell grid-column="3">{`${row.credits}`}</VSCodeDataGridCell>
+43 -2
View File
@@ -1,5 +1,11 @@
import type { UsageTransaction as ClineAccountUsageTransaction } from "@shared/ClineAccount"
import type { UsageTransaction as ProtoUsageTransaction } from "@shared/proto/cline/account"
import type {
PaymentTransaction as ClineAccountPaymentTransaction,
UsageTransaction as ClineAccountUsageTransaction,
} from "@shared/ClineAccount"
import type {
PaymentTransaction as ProtoPaymentTransaction,
UsageTransaction as ProtoUsageTransaction,
} from "@shared/proto/cline/account"
export const getMainRole = (roles?: string[]) => {
if (!roles) return undefined
@@ -45,3 +51,38 @@ export function convertProtoUsageTransaction(protoTransaction: ProtoUsageTransac
export function convertProtoUsageTransactions(protoTransactions: ProtoUsageTransaction[]): ClineAccountUsageTransaction[] {
return protoTransactions.map(convertProtoUsageTransaction)
}
/**
* Converts a protobuf PaymentTransaction to a ClineAccount PaymentTransaction
* This is a temporary workaround for the fact that the protobuf definition is out of sync with the API response.
*/
export function convertProtoPaymentTransaction(protoTransaction: ProtoPaymentTransaction): ClineAccountPaymentTransaction {
try {
const unpackedData = JSON.parse(protoTransaction.paidAt)
return unpackedData as ClineAccountPaymentTransaction
} catch (error) {
console.error("Failed to parse packed payment transaction:", error)
// Return a default/empty object that won't crash the UI
return {
id: "",
transactionId: "",
userId: "",
amountCents: 0,
credits: 0,
type: "",
status: "",
providerReference: "",
metadata: {},
createdAt: "",
updatedAt: "",
completedAt: "",
} as unknown as ClineAccountPaymentTransaction
}
}
/**
* Converts an array of protobuf PaymentTransactions to ClineAccount PaymentTransactions
*/
export function convertProtoPaymentTransactions(protoTransactions: ProtoPaymentTransaction[]): ClineAccountPaymentTransaction[] {
return protoTransactions.map(convertProtoPaymentTransaction)
}
+19 -29
View File
@@ -315,6 +315,7 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
const unsupportedFileTimerRef = useRef<NodeJS.Timeout | null>(null)
const [showDimensionError, setShowDimensionError] = useState(false)
const dimensionErrorTimerRef = useRef<NodeJS.Timeout | null>(null)
const [isSwitchingMode, setIsSwitchingMode] = useState(false)
const [fileSearchResults, setFileSearchResults] = useState<SearchResult[]>([])
const [searchLoading, setSearchLoading] = useState(false)
@@ -988,35 +989,24 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
}
}, [apiConfiguration, openRouterModels])
const onModeToggle = useCallback(() => {
// if (textAreaDisabled) return
let changeModeDelay = 0
if (showModelSelector) {
// user has model selector open, so we should save it before switching modes
submitApiConfig()
changeModeDelay = 250 // necessary to let the api config update (we send message and wait for it to be saved) FIXME: this is a hack and we ideally should check for api config changes, then wait for it to be saved, before switching modes
const onModeToggle = useCallback(async () => {
if (isSwitchingMode) return // prevent double toggling
setIsSwitchingMode(true)
const convertedProtoMode = mode === "plan" ? PlanActMode.ACT : PlanActMode.PLAN
const response = await StateServiceClient.togglePlanActModeProto({
mode: convertedProtoMode,
chatContent: {
message: inputValue.trim() ? inputValue : undefined,
images: selectedImages,
files: selectedFiles,
},
})
if (response?.value) {
setInputValue("")
}
setTimeout(async () => {
const convertedProtoMode = mode === "plan" ? PlanActMode.ACT : PlanActMode.PLAN
const response = await StateServiceClient.togglePlanActModeProto(
TogglePlanActModeRequest.create({
mode: convertedProtoMode,
chatContent: {
message: inputValue.trim() ? inputValue : undefined,
images: selectedImages,
files: selectedFiles,
},
}),
)
// Focus the textarea after mode toggle with slight delay
setTimeout(() => {
if (response.value) {
setInputValue("")
}
textAreaRef.current?.focus()
}, 100)
}, changeModeDelay)
}, [mode, showModelSelector, submitApiConfig, inputValue, selectedImages, selectedFiles])
textAreaRef.current?.focus()
setIsSwitchingMode(false)
}, [mode, inputValue, selectedImages, selectedFiles, setInputValue, isSwitchingMode])
useShortcut("Meta+Shift+a", onModeToggle, { disableTextInputs: false }) // important that we don't disable the text input here
@@ -1741,7 +1731,7 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
visible={shownTooltipMode !== null}
tipText={`In ${shownTooltipMode === "act" ? "Act" : "Plan"} mode, Cline will ${shownTooltipMode === "act" ? "complete the task immediately" : "gather information to architect a plan"}`}
hintText={`Toggle w/ ${metaKeyChar}+Shift+A`}>
<SwitchContainer data-testid="mode-switch" disabled={false} onClick={onModeToggle}>
<SwitchContainer data-testid="mode-switch" disabled={isSwitchingMode} onClick={onModeToggle}>
<Slider isAct={mode === "act"} isPlan={mode === "plan"} />
<SwitchOption
isActive={mode === "plan"}
@@ -1,7 +1,6 @@
import { useEffect } from "react"
import { ClineMessage, ClineSayTool } from "@shared/ExtensionMessage"
import { ChatState } from "../types/chatTypes"
import { useDeepCompareEffect } from "react-use"
/**
* Custom hook for managing button state based on messages
@@ -19,7 +18,7 @@ export function useButtonState(messages: ClineMessage[], chatState: ChatState) {
} = chatState
// Update button state based on last message
useDeepCompareEffect(() => {
useEffect(() => {
if (lastMessage) {
switch (lastMessage.type) {
case "ask":
@@ -146,7 +145,20 @@ export function useButtonState(messages: ClineMessage[], chatState: ChatState) {
break
}
}
}, [lastMessage, secondLastMessage])
}, [
lastMessage?.type,
lastMessage?.partial,
lastMessage?.ask,
lastMessage?.text,
lastMessage?.say,
secondLastMessage?.ask,
setSendingDisabled,
setEnableButtons,
setPrimaryButtonText,
setSecondaryButtonText,
setDidClickCancel,
chatState,
])
// Reset button state when no messages
useEffect(() => {