mirror of
https://github.com/cline/cline.git
synced 2026-09-01 23:19:18 +08:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b471fbe864 | |||
| 7bd7282008 | |||
| a654d7db9a |
@@ -0,0 +1,73 @@
|
||||
import type { ClineMessage } from "@shared/ExtensionMessage"
|
||||
import { describe, expect, it } from "vitest"
|
||||
import { getButtonConfig } from "./ActionButtons"
|
||||
|
||||
describe("CLI getButtonConfig", () => {
|
||||
describe("api_req_failed state", () => {
|
||||
it("allows sending messages (sendingDisabled is false)", () => {
|
||||
const errorMessage: ClineMessage = {
|
||||
type: "ask",
|
||||
ask: "api_req_failed",
|
||||
text: "Insufficient funds",
|
||||
ts: Date.now(),
|
||||
}
|
||||
const config = getButtonConfig(errorMessage)
|
||||
expect(config.sendingDisabled).toBe(false)
|
||||
expect(config.enableButtons).toBe(true)
|
||||
expect(config.primaryText).toBe("Retry")
|
||||
expect(config.primaryAction).toBe("retry")
|
||||
expect(config.secondaryText).toBe("Start New Task")
|
||||
expect(config.secondaryAction).toBe("new_task")
|
||||
})
|
||||
|
||||
it("returns error config even when message is partial (streaming error)", () => {
|
||||
const errorMessage: ClineMessage = {
|
||||
type: "ask",
|
||||
ask: "api_req_failed",
|
||||
partial: true,
|
||||
text: "Rate limit exceeded",
|
||||
ts: Date.now(),
|
||||
}
|
||||
const config = getButtonConfig(errorMessage)
|
||||
// Error states should NOT be treated as streaming/partial
|
||||
expect(config.sendingDisabled).toBe(false)
|
||||
expect(config.enableButtons).toBe(true)
|
||||
expect(config.primaryAction).toBe("retry")
|
||||
})
|
||||
|
||||
it("does not return partial config for api_req_failed during streaming", () => {
|
||||
const errorMessage: ClineMessage = {
|
||||
type: "ask",
|
||||
ask: "api_req_failed",
|
||||
text: "Connection error",
|
||||
ts: Date.now(),
|
||||
}
|
||||
// isStreaming=true should not override error state
|
||||
const config = getButtonConfig(errorMessage, true)
|
||||
expect(config.sendingDisabled).toBe(false)
|
||||
expect(config.primaryAction).toBe("retry")
|
||||
})
|
||||
})
|
||||
|
||||
describe("default config", () => {
|
||||
it("returns default config when no message is provided", () => {
|
||||
const config = getButtonConfig(undefined)
|
||||
expect(config.sendingDisabled).toBe(false)
|
||||
expect(config.enableButtons).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("streaming states", () => {
|
||||
it("returns partial config for non-error streaming messages", () => {
|
||||
const streamingMessage: ClineMessage = {
|
||||
type: "say",
|
||||
say: "api_req_started",
|
||||
partial: true,
|
||||
ts: Date.now(),
|
||||
}
|
||||
const config = getButtonConfig(streamingMessage, true)
|
||||
expect(config.sendingDisabled).toBe(true)
|
||||
expect(config.secondaryAction).toBe("cancel")
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -40,7 +40,7 @@ export interface ButtonConfig {
|
||||
const BUTTON_CONFIGS: Record<string, ButtonConfig> = {
|
||||
// Error recovery states
|
||||
api_req_failed: {
|
||||
sendingDisabled: true,
|
||||
sendingDisabled: false,
|
||||
enableButtons: true,
|
||||
primaryText: "Retry",
|
||||
secondaryText: "Start New Task",
|
||||
@@ -194,7 +194,7 @@ const errorTypes = ["api_req_failed", "mistake_limit_reached"]
|
||||
/**
|
||||
* Get button configuration based on message type and state
|
||||
*/
|
||||
export function getButtonConfig(message: ClineMessage | undefined, isStreaming: boolean = false): ButtonConfig {
|
||||
export function getButtonConfig(message: ClineMessage | undefined, isStreaming = false): ButtonConfig {
|
||||
if (!message) {
|
||||
return BUTTON_CONFIGS.default
|
||||
}
|
||||
|
||||
@@ -118,7 +118,7 @@ export class ToolExecutor {
|
||||
private getActiveHookExecution: () => Promise<typeof taskState.activeHookExecution>,
|
||||
private runUserPromptSubmitHook: (
|
||||
userContent: ClineContent[],
|
||||
context: "initial_task" | "resume" | "feedback",
|
||||
context: "initial_task" | "resume" | "feedback" | "retry",
|
||||
) => Promise<{ cancel?: boolean; wasCancelled?: boolean; contextModification?: string; errorMessage?: string }>,
|
||||
) {
|
||||
this.autoApprover = new AutoApprove(this.stateManager)
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
import { describe, it } from "mocha"
|
||||
import "should"
|
||||
import type { ClineContent, ClineStorageMessage } from "@shared/messages/content"
|
||||
import type { ClineAskResponse } from "@shared/WebviewMessage"
|
||||
|
||||
/**
|
||||
* Tests for the handleApiReqFailedMessageResponse logic.
|
||||
*
|
||||
* This method handles the case where a user submits a message while viewing
|
||||
* an api_req_failed error (e.g., insufficient funds). The behavior is:
|
||||
* - Non-messageResponse responses pass through unchanged
|
||||
* - messageResponse with content appends to the last user message in API history
|
||||
* and returns "yesButtonClicked" to simulate a retry
|
||||
* - messageResponse with no content still returns "yesButtonClicked"
|
||||
*
|
||||
* Since handleApiReqFailedMessageResponse is a private method on Task,
|
||||
* we test the core logic patterns it implements.
|
||||
*/
|
||||
|
||||
interface AskResult {
|
||||
response: ClineAskResponse
|
||||
text?: string
|
||||
images?: string[]
|
||||
files?: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Simulates the core logic of handleApiReqFailedMessageResponse
|
||||
* without requiring a full Task instance.
|
||||
*/
|
||||
function simulateHandleApiReqFailedMessageResponse(
|
||||
askResult: AskResult,
|
||||
apiHistory: ClineStorageMessage[],
|
||||
): {
|
||||
returnValue: ClineAskResponse
|
||||
updatedHistory: ClineStorageMessage[]
|
||||
saidUserFeedback: boolean
|
||||
} {
|
||||
let saidUserFeedback = false
|
||||
|
||||
if (askResult.response !== "messageResponse") {
|
||||
return { returnValue: askResult.response, updatedHistory: apiHistory, saidUserFeedback }
|
||||
}
|
||||
|
||||
// Simulate buildUserFeedbackContent - simplified version
|
||||
const retryUserContent: ClineContent[] = []
|
||||
if (askResult.text) {
|
||||
retryUserContent.push({
|
||||
type: "text",
|
||||
text: `<feedback>\n${askResult.text}\n</feedback>`,
|
||||
})
|
||||
}
|
||||
|
||||
if (retryUserContent.length > 0) {
|
||||
saidUserFeedback = true
|
||||
|
||||
const lastApiMessage = apiHistory.at(-1)
|
||||
|
||||
if (lastApiMessage?.role === "user") {
|
||||
const existingUserContent: ClineContent[] = Array.isArray(lastApiMessage.content)
|
||||
? lastApiMessage.content
|
||||
: [{ type: "text", text: lastApiMessage.content as string }]
|
||||
|
||||
const updatedHistory = [
|
||||
...apiHistory.slice(0, -1),
|
||||
{
|
||||
...lastApiMessage,
|
||||
content: [...existingUserContent, ...retryUserContent],
|
||||
},
|
||||
]
|
||||
return { returnValue: "yesButtonClicked", updatedHistory, saidUserFeedback }
|
||||
}
|
||||
const updatedHistory = [
|
||||
...apiHistory,
|
||||
{
|
||||
role: "user" as const,
|
||||
content: retryUserContent,
|
||||
ts: Date.now(),
|
||||
},
|
||||
]
|
||||
return { returnValue: "yesButtonClicked", updatedHistory, saidUserFeedback }
|
||||
}
|
||||
|
||||
// No content but still messageResponse - return yesButtonClicked
|
||||
return { returnValue: "yesButtonClicked", updatedHistory: apiHistory, saidUserFeedback }
|
||||
}
|
||||
|
||||
describe("handleApiReqFailedMessageResponse", () => {
|
||||
describe("non-messageResponse passthrough", () => {
|
||||
it("should pass through yesButtonClicked unchanged", () => {
|
||||
const askResult: AskResult = { response: "yesButtonClicked" }
|
||||
const apiHistory: ClineStorageMessage[] = []
|
||||
|
||||
const result = simulateHandleApiReqFailedMessageResponse(askResult, apiHistory)
|
||||
|
||||
result.returnValue.should.equal("yesButtonClicked")
|
||||
result.saidUserFeedback.should.be.false()
|
||||
result.updatedHistory.should.have.length(0)
|
||||
})
|
||||
|
||||
it("should pass through noButtonClicked unchanged", () => {
|
||||
const askResult: AskResult = { response: "noButtonClicked" }
|
||||
const apiHistory: ClineStorageMessage[] = []
|
||||
|
||||
const result = simulateHandleApiReqFailedMessageResponse(askResult, apiHistory)
|
||||
|
||||
result.returnValue.should.equal("noButtonClicked")
|
||||
result.saidUserFeedback.should.be.false()
|
||||
})
|
||||
})
|
||||
|
||||
describe("messageResponse with text content", () => {
|
||||
it("should append to last user message and return yesButtonClicked", () => {
|
||||
const askResult: AskResult = {
|
||||
response: "messageResponse",
|
||||
text: "Please try a different approach",
|
||||
}
|
||||
const apiHistory: ClineStorageMessage[] = [
|
||||
{
|
||||
role: "user",
|
||||
content: [{ type: "text", text: "Original user message" }],
|
||||
ts: 1000,
|
||||
},
|
||||
]
|
||||
|
||||
const result = simulateHandleApiReqFailedMessageResponse(askResult, apiHistory)
|
||||
|
||||
result.returnValue.should.equal("yesButtonClicked")
|
||||
result.saidUserFeedback.should.be.true()
|
||||
result.updatedHistory.should.have.length(1)
|
||||
|
||||
// The last user message should have the original content plus the new feedback
|
||||
const lastMsg = result.updatedHistory[0]
|
||||
lastMsg.role.should.equal("user")
|
||||
const content = lastMsg.content as ClineContent[]
|
||||
content.should.have.length(2)
|
||||
;(content[0] as any).text.should.equal("Original user message")
|
||||
;(content[1] as any).text.should.containEql("Please try a different approach")
|
||||
})
|
||||
|
||||
it("should add new user message when last message is assistant", () => {
|
||||
const askResult: AskResult = {
|
||||
response: "messageResponse",
|
||||
text: "Try again with more context",
|
||||
}
|
||||
const apiHistory: ClineStorageMessage[] = [
|
||||
{
|
||||
role: "user",
|
||||
content: [{ type: "text", text: "Original request" }],
|
||||
ts: 1000,
|
||||
},
|
||||
{
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "I encountered an error" }],
|
||||
ts: 2000,
|
||||
},
|
||||
]
|
||||
|
||||
const result = simulateHandleApiReqFailedMessageResponse(askResult, apiHistory)
|
||||
|
||||
result.returnValue.should.equal("yesButtonClicked")
|
||||
result.saidUserFeedback.should.be.true()
|
||||
result.updatedHistory.should.have.length(3)
|
||||
|
||||
// Original messages should be preserved
|
||||
result.updatedHistory[0].role.should.equal("user")
|
||||
result.updatedHistory[1].role.should.equal("assistant")
|
||||
|
||||
// New user message should be appended
|
||||
const newMsg = result.updatedHistory[2]
|
||||
newMsg.role.should.equal("user")
|
||||
const content = newMsg.content as ClineContent[]
|
||||
content.should.have.length(1)
|
||||
;(content[0] as any).text.should.containEql("Try again with more context")
|
||||
})
|
||||
|
||||
it("should handle last user message with string content (not array)", () => {
|
||||
const askResult: AskResult = {
|
||||
response: "messageResponse",
|
||||
text: "Additional context",
|
||||
}
|
||||
const apiHistory: ClineStorageMessage[] = [
|
||||
{
|
||||
role: "user",
|
||||
content: "Simple string content" as any,
|
||||
ts: 1000,
|
||||
},
|
||||
]
|
||||
|
||||
const result = simulateHandleApiReqFailedMessageResponse(askResult, apiHistory)
|
||||
|
||||
result.returnValue.should.equal("yesButtonClicked")
|
||||
result.saidUserFeedback.should.be.true()
|
||||
|
||||
// Should convert string content to array and append
|
||||
const lastMsg = result.updatedHistory[0]
|
||||
const content = lastMsg.content as ClineContent[]
|
||||
content.should.have.length(2)
|
||||
;(content[0] as any).text.should.equal("Simple string content")
|
||||
;(content[1] as any).text.should.containEql("Additional context")
|
||||
})
|
||||
})
|
||||
|
||||
describe("messageResponse without content", () => {
|
||||
it("should return yesButtonClicked when text is empty", () => {
|
||||
const askResult: AskResult = {
|
||||
response: "messageResponse",
|
||||
text: "",
|
||||
}
|
||||
const apiHistory: ClineStorageMessage[] = [
|
||||
{
|
||||
role: "user",
|
||||
content: [{ type: "text", text: "Original" }],
|
||||
ts: 1000,
|
||||
},
|
||||
]
|
||||
|
||||
const result = simulateHandleApiReqFailedMessageResponse(askResult, apiHistory)
|
||||
|
||||
result.returnValue.should.equal("yesButtonClicked")
|
||||
result.saidUserFeedback.should.be.false()
|
||||
// History should not be modified
|
||||
result.updatedHistory.should.have.length(1)
|
||||
})
|
||||
|
||||
it("should return yesButtonClicked when text is undefined", () => {
|
||||
const askResult: AskResult = {
|
||||
response: "messageResponse",
|
||||
text: undefined,
|
||||
}
|
||||
const apiHistory: ClineStorageMessage[] = []
|
||||
|
||||
const result = simulateHandleApiReqFailedMessageResponse(askResult, apiHistory)
|
||||
|
||||
result.returnValue.should.equal("yesButtonClicked")
|
||||
result.saidUserFeedback.should.be.false()
|
||||
})
|
||||
})
|
||||
|
||||
describe("messageResponse with empty API history", () => {
|
||||
it("should add new user message when history is empty", () => {
|
||||
const askResult: AskResult = {
|
||||
response: "messageResponse",
|
||||
text: "First message after failure",
|
||||
}
|
||||
const apiHistory: ClineStorageMessage[] = []
|
||||
|
||||
const result = simulateHandleApiReqFailedMessageResponse(askResult, apiHistory)
|
||||
|
||||
result.returnValue.should.equal("yesButtonClicked")
|
||||
result.saidUserFeedback.should.be.true()
|
||||
result.updatedHistory.should.have.length(1)
|
||||
result.updatedHistory[0].role.should.equal("user")
|
||||
})
|
||||
})
|
||||
})
|
||||
+53
-3
@@ -889,7 +889,7 @@ export class Task {
|
||||
|
||||
private async runUserPromptSubmitHook(
|
||||
userContent: ClineContent[],
|
||||
_context: "initial_task" | "resume" | "feedback",
|
||||
_context: "initial_task" | "resume" | "feedback" | "retry",
|
||||
): Promise<{ cancel?: boolean; wasCancelled?: boolean; contextModification?: string; errorMessage?: string }> {
|
||||
const hooksEnabled = getHooksEnabledSafe()
|
||||
|
||||
@@ -1740,6 +1740,56 @@ export class Task {
|
||||
this.taskState.didAutomaticallyRetryFailedApiRequest = true
|
||||
}
|
||||
|
||||
private async handleApiReqFailedMessageResponse(askResult: {
|
||||
response: ClineAskResponse
|
||||
text?: string
|
||||
images?: string[]
|
||||
files?: string[]
|
||||
}): Promise<ClineAskResponse> {
|
||||
if (askResult.response !== "messageResponse") {
|
||||
return askResult.response
|
||||
}
|
||||
|
||||
const retryUserContent = await buildUserFeedbackContent(askResult.text, askResult.images, askResult.files)
|
||||
|
||||
if (retryUserContent.length > 0) {
|
||||
await this.say("user_feedback", askResult.text, askResult.images, askResult.files)
|
||||
|
||||
// Run UserPromptSubmit hook for retry feedback
|
||||
const hookResult = await this.runUserPromptSubmitHook(retryUserContent, "retry")
|
||||
|
||||
if (this.taskState.abort) {
|
||||
return "messageResponse" // will be caught by abort check in caller
|
||||
}
|
||||
|
||||
if (hookResult.cancel === true) {
|
||||
await this.handleHookCancellation("UserPromptSubmit", hookResult.wasCancelled ?? false)
|
||||
await this.cancelTask()
|
||||
return "messageResponse" // will be caught by abort check in caller
|
||||
}
|
||||
|
||||
// Always add as a new user message for clean conversation semantics
|
||||
const contentToAdd = [...retryUserContent]
|
||||
|
||||
// Add hook context if provided
|
||||
if (hookResult.contextModification) {
|
||||
contentToAdd.push({
|
||||
type: "text",
|
||||
text: `<hook_context source="UserPromptSubmit">\n${hookResult.contextModification}\n</hook_context>`,
|
||||
})
|
||||
}
|
||||
|
||||
await this.messageStateHandler.addToApiConversationHistory({
|
||||
role: "user",
|
||||
content: contentToAdd,
|
||||
ts: Date.now(),
|
||||
})
|
||||
}
|
||||
|
||||
// this will simulate user attempted to retry the failed api request
|
||||
return "yesButtonClicked"
|
||||
}
|
||||
|
||||
async *attemptApiRequest(previousApiReqIndex: number): ApiStream {
|
||||
// Wait for MCP servers to be connected before generating system prompt
|
||||
await pWaitFor(() => this.mcpHub.isConnecting !== true, {
|
||||
@@ -2032,7 +2082,7 @@ export class Task {
|
||||
)
|
||||
}
|
||||
const askResult = await this.ask("api_req_failed", streamingFailedMessage)
|
||||
response = askResult.response
|
||||
response = await this.handleApiReqFailedMessageResponse(askResult)
|
||||
if (response === "yesButtonClicked") {
|
||||
this.taskState.autoRetryAttempts = 0
|
||||
}
|
||||
@@ -3079,7 +3129,7 @@ export class Task {
|
||||
}),
|
||||
)
|
||||
const askResult = await this.ask("api_req_failed", noResponseErrorMessage)
|
||||
response = askResult.response
|
||||
response = await this.handleApiReqFailedMessageResponse(askResult)
|
||||
// Reset retry counter if user chooses to manually retry
|
||||
if (response === "yesButtonClicked") {
|
||||
this.taskState.autoRetryAttempts = 0
|
||||
|
||||
@@ -136,7 +136,7 @@ export interface TaskCallbacks {
|
||||
// User prompt hook callback
|
||||
runUserPromptSubmitHook: (
|
||||
userContent: ClineContent[],
|
||||
context: "initial_task" | "resume" | "feedback",
|
||||
context: "initial_task" | "resume" | "feedback" | "retry",
|
||||
) => Promise<{ cancel?: boolean; wasCancelled?: boolean; contextModification?: string; errorMessage?: string }>
|
||||
}
|
||||
|
||||
|
||||
@@ -168,12 +168,24 @@ export function useMessageHandlers(messages: ClineMessage[], chatState: ChatStat
|
||||
|
||||
switch (actionType) {
|
||||
case "retry":
|
||||
// For API retry (api_req_failed), always send simple approval without content
|
||||
await TaskServiceClient.askResponse(
|
||||
AskResponseRequest.create({
|
||||
responseType: "yesButtonClicked",
|
||||
}),
|
||||
)
|
||||
// For API retry (api_req_failed), send content as messageResponse if user typed something
|
||||
// This allows appending a message to the failed request before retrying
|
||||
if (hasContent) {
|
||||
await TaskServiceClient.askResponse(
|
||||
AskResponseRequest.create({
|
||||
responseType: "messageResponse",
|
||||
text: trimmedInput,
|
||||
images: images,
|
||||
files: files,
|
||||
}),
|
||||
)
|
||||
} else {
|
||||
await TaskServiceClient.askResponse(
|
||||
AskResponseRequest.create({
|
||||
responseType: "yesButtonClicked",
|
||||
}),
|
||||
)
|
||||
}
|
||||
clearInputState()
|
||||
break
|
||||
case "approve":
|
||||
|
||||
@@ -39,6 +39,19 @@ describe("getButtonConfig", () => {
|
||||
expect(config).toEqual(BUTTON_CONFIGS[errorState])
|
||||
})
|
||||
})
|
||||
|
||||
it("api_req_failed allows sending messages (sendingDisabled is false)", () => {
|
||||
const errorMessage: ClineMessage = {
|
||||
type: "ask",
|
||||
ask: "api_req_failed",
|
||||
text: "Insufficient funds",
|
||||
ts: Date.now(),
|
||||
}
|
||||
const config = getButtonConfig(errorMessage)
|
||||
expect(config.sendingDisabled).toBe(false)
|
||||
expect(config.enableButtons).toBe(true)
|
||||
expect(config.primaryAction).toBe("retry")
|
||||
})
|
||||
})
|
||||
|
||||
// Test tool approval states
|
||||
|
||||
@@ -32,7 +32,7 @@ export interface ButtonConfig {
|
||||
export const BUTTON_CONFIGS: Record<string, ButtonConfig> = {
|
||||
// Error recovery states - user must take action
|
||||
api_req_failed: {
|
||||
sendingDisabled: true,
|
||||
sendingDisabled: false,
|
||||
enableButtons: true,
|
||||
primaryText: "Retry",
|
||||
secondaryText: "Start New Task",
|
||||
|
||||
Reference in New Issue
Block a user