Compare commits

...

3 Commits

Author SHA1 Message Date
Max Paulus 🥪 b471fbe864 add hook invoke for api_req_failed prompt submit 2026-03-02 11:34:24 -08:00
Max Paulus 🥪 7bd7282008 add tests for user submit message even after api_req_failed messaged 2026-03-02 11:34:23 -08:00
Max Paulus 🥪 a654d7db9a user can submit message even after getting a insufficient funds api failure
- submitting a message in this scenario will be equivalent to
    - user appends their last (failed) message with another message
(hence overwrite apihistory)
    - user clicks retry button (hence return "yesButtonClicked")
2026-03-02 11:34:23 -08:00
9 changed files with 418 additions and 14 deletions
+73
View File
@@ -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")
})
})
})
+2 -2
View File
@@ -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
}
+1 -1
View File
@@ -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
View File
@@ -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
+1 -1
View File
@@ -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",