Refactor workflow detection logic and add comprehensive test coverage

- Extract duplicated automated workflow detection into shared utility
- Add 23 comprehensive unit tests covering all edge cases and scenarios
- Add detailed JSDoc documentation with examples and issue references
- Improve code maintainability and eliminate duplication

Addresses PR review feedback for issue #4574.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
hannesrudolph 2025-06-11 21:52:40 -06:00
parent 10967936f9
commit a6982abee0
4 changed files with 448 additions and 28 deletions

View file

@ -10,7 +10,7 @@ import { getCommitInfo, getWorkingState } from "../../utils/git"
import { getWorkspacePath } from "../../utils/path"
import { openFile } from "../../integrations/misc/open-file"
import { ClineProvider } from "../webview/ClineProvider"
import { isInAutomatedWorkflowFromVisibleProvider } from "../../utils/workflow-detection"
import { extractTextFromFile } from "../../integrations/misc/extract-text"
import { diagnosticsToProblemsString } from "../../integrations/diagnostics"
@ -37,21 +37,10 @@ export async function openMention(mention?: string): Promise<void> {
if (mention.endsWith("/")) {
vscode.commands.executeCommand("revealInExplorer", vscode.Uri.file(absPath))
} else {
// Check if we're in an automated workflow when opening file mentions
const visibleProvider = ClineProvider.getVisibleInstance()
const currentCline = visibleProvider?.getCurrentCline()
const autoApprovalEnabled = visibleProvider?.contextProxy.getValue("autoApprovalEnabled")
// Check if we're in an automated workflow to preserve chat focus during AI processing
const shouldPreserveFocus = isInAutomatedWorkflowFromVisibleProvider()
// Detect automated workflow: AI is actively processing, streaming, or auto-approval is enabled
// and there's an active task that hasn't completed reading/processing
const isInAutomatedWorkflow =
currentCline &&
(currentCline.isStreaming ||
currentCline.isWaitingForFirstChunk ||
(autoApprovalEnabled && !currentCline.didCompleteReadingStream) ||
(autoApprovalEnabled && currentCline.presentAssistantMessageLocked))
openFile(absPath, { preserveFocus: !!isInAutomatedWorkflow })
openFile(absPath, { preserveFocus: shouldPreserveFocus })
}
} else if (mention === "problems") {
vscode.commands.executeCommand("workbench.actions.view.problems")

View file

@ -39,6 +39,7 @@ import { getModels, flushModels } from "../../api/providers/fetchers/modelCache"
import { GetModelsOptions } from "../../shared/api"
import { generateSystemPrompt } from "./generateSystemPrompt"
import { getCommand } from "../../utils/commands"
import { isInAutomatedWorkflowFromProvider } from "../../utils/workflow-detection"
const ALLOWED_VSCODE_SETTINGS = new Set(["terminal.integrated.inheritEnv"])
@ -426,22 +427,12 @@ export const webviewMessageHandler = async (
openImage(message.text!)
break
case "openFile":
// Check if we're in an automated workflow (AI is streaming/processing)
const currentCline = provider.getCurrentCline()
const autoApprovalEnabled = getGlobalState("autoApprovalEnabled")
// Detect automated workflow: AI is actively processing, streaming, or auto-approval is enabled
// and there's an active task that hasn't completed reading/processing
const isInAutomatedWorkflow =
currentCline &&
(currentCline.isStreaming ||
currentCline.isWaitingForFirstChunk ||
(autoApprovalEnabled && !currentCline.didCompleteReadingStream) ||
(autoApprovalEnabled && currentCline.presentAssistantMessageLocked))
// Check if we're in an automated workflow to preserve chat focus during AI processing
const shouldPreserveFocus = isInAutomatedWorkflowFromProvider(provider)
openFile(message.text!, {
...(message.values as { create?: boolean; content?: string; line?: number }),
preserveFocus: !!isInAutomatedWorkflow,
preserveFocus: shouldPreserveFocus,
})
break
case "openMention":

View file

@ -0,0 +1,322 @@
// Mock ClineProvider first
const mockGetVisibleInstance = jest.fn()
jest.mock("../../core/webview/ClineProvider", () => ({
ClineProvider: {
getVisibleInstance: mockGetVisibleInstance,
},
}))
import {
isInAutomatedWorkflow,
isInAutomatedWorkflowFromProvider,
isInAutomatedWorkflowFromVisibleProvider,
} from "../workflow-detection"
import type { Task } from "../../core/task/Task"
import type { ClineProvider } from "../../core/webview/ClineProvider"
// Mock ClineProvider
const mockProvider = {
getCurrentCline: jest.fn(),
contextProxy: {
getValue: jest.fn(),
},
} as unknown as ClineProvider
const mockVisibleProvider = {
getCurrentCline: jest.fn(),
contextProxy: {
getValue: jest.fn(),
},
} as unknown as ClineProvider
describe("workflow-detection", () => {
beforeEach(() => {
jest.clearAllMocks()
})
describe("isInAutomatedWorkflow", () => {
it("should return false when currentTask is null", () => {
expect(isInAutomatedWorkflow(null, false)).toBe(false)
expect(isInAutomatedWorkflow(null, true)).toBe(false)
})
it("should return false when currentTask is undefined", () => {
expect(isInAutomatedWorkflow(undefined, false)).toBe(false)
expect(isInAutomatedWorkflow(undefined, true)).toBe(false)
})
it("should return true when task is streaming", () => {
const streamingTask = {
isStreaming: true,
isWaitingForFirstChunk: false,
didCompleteReadingStream: true,
presentAssistantMessageLocked: false,
} as Task
expect(isInAutomatedWorkflow(streamingTask, false)).toBe(true)
expect(isInAutomatedWorkflow(streamingTask, true)).toBe(true)
})
it("should return true when task is waiting for first chunk", () => {
const waitingTask = {
isStreaming: false,
isWaitingForFirstChunk: true,
didCompleteReadingStream: true,
presentAssistantMessageLocked: false,
} as Task
expect(isInAutomatedWorkflow(waitingTask, false)).toBe(true)
expect(isInAutomatedWorkflow(waitingTask, true)).toBe(true)
})
it("should return true when auto-approval is enabled and stream not completed", () => {
const incompleteStreamTask = {
isStreaming: false,
isWaitingForFirstChunk: false,
didCompleteReadingStream: false,
presentAssistantMessageLocked: false,
} as Task
expect(isInAutomatedWorkflow(incompleteStreamTask, false)).toBe(false)
expect(isInAutomatedWorkflow(incompleteStreamTask, true)).toBe(true)
})
it("should return true when auto-approval is enabled and assistant message is locked", () => {
const lockedMessageTask = {
isStreaming: false,
isWaitingForFirstChunk: false,
didCompleteReadingStream: true,
presentAssistantMessageLocked: true,
} as Task
expect(isInAutomatedWorkflow(lockedMessageTask, false)).toBe(false)
expect(isInAutomatedWorkflow(lockedMessageTask, true)).toBe(true)
})
it("should return false when task is idle and auto-approval is disabled", () => {
const idleTask = {
isStreaming: false,
isWaitingForFirstChunk: false,
didCompleteReadingStream: true,
presentAssistantMessageLocked: false,
} as Task
expect(isInAutomatedWorkflow(idleTask, false)).toBe(false)
})
it("should return false when task is idle even with auto-approval enabled", () => {
const idleTask = {
isStreaming: false,
isWaitingForFirstChunk: false,
didCompleteReadingStream: true,
presentAssistantMessageLocked: false,
} as Task
expect(isInAutomatedWorkflow(idleTask, true)).toBe(false)
})
it("should return true for multiple simultaneous conditions", () => {
const busyTask = {
isStreaming: true,
isWaitingForFirstChunk: true,
didCompleteReadingStream: false,
presentAssistantMessageLocked: true,
} as Task
expect(isInAutomatedWorkflow(busyTask, false)).toBe(true)
expect(isInAutomatedWorkflow(busyTask, true)).toBe(true)
})
it("should handle tasks with missing properties gracefully", () => {
const partialTask = {} as Task
expect(isInAutomatedWorkflow(partialTask, false)).toBe(false)
expect(isInAutomatedWorkflow(partialTask, true)).toBe(false)
})
})
describe("isInAutomatedWorkflowFromProvider", () => {
it("should return false when no current task", () => {
mockProvider.getCurrentCline = jest.fn().mockReturnValue(null)
mockProvider.contextProxy.getValue = jest.fn().mockReturnValue(false)
expect(isInAutomatedWorkflowFromProvider(mockProvider)).toBe(false)
})
it("should return true when task is streaming", () => {
const streamingTask = {
isStreaming: true,
isWaitingForFirstChunk: false,
didCompleteReadingStream: true,
presentAssistantMessageLocked: false,
} as Task
mockProvider.getCurrentCline = jest.fn().mockReturnValue(streamingTask)
mockProvider.contextProxy.getValue = jest.fn().mockReturnValue(false)
expect(isInAutomatedWorkflowFromProvider(mockProvider)).toBe(true)
})
it("should return true when auto-approval is enabled and conditions are met", () => {
const incompleteTask = {
isStreaming: false,
isWaitingForFirstChunk: false,
didCompleteReadingStream: false,
presentAssistantMessageLocked: false,
} as Task
mockProvider.getCurrentCline = jest.fn().mockReturnValue(incompleteTask)
mockProvider.contextProxy.getValue = jest.fn().mockReturnValue(true)
expect(isInAutomatedWorkflowFromProvider(mockProvider)).toBe(true)
})
it("should return false when task is idle and auto-approval is disabled", () => {
const idleTask = {
isStreaming: false,
isWaitingForFirstChunk: false,
didCompleteReadingStream: true,
presentAssistantMessageLocked: false,
} as Task
mockProvider.getCurrentCline = jest.fn().mockReturnValue(idleTask)
mockProvider.contextProxy.getValue = jest.fn().mockReturnValue(false)
expect(isInAutomatedWorkflowFromProvider(mockProvider)).toBe(false)
})
it("should handle undefined values from contextProxy", () => {
const streamingTask = {
isStreaming: true,
isWaitingForFirstChunk: false,
didCompleteReadingStream: true,
presentAssistantMessageLocked: false,
} as Task
mockProvider.getCurrentCline = jest.fn().mockReturnValue(streamingTask)
mockProvider.contextProxy.getValue = jest.fn().mockReturnValue(undefined)
// Should still return true because isStreaming is true, regardless of autoApprovalEnabled value
expect(isInAutomatedWorkflowFromProvider(mockProvider)).toBe(true)
})
})
describe("isInAutomatedWorkflowFromVisibleProvider", () => {
it("should return false when no visible provider", () => {
mockGetVisibleInstance.mockReturnValue(null)
expect(isInAutomatedWorkflowFromVisibleProvider()).toBe(false)
})
it("should return false when visible provider has no current task", () => {
mockVisibleProvider.getCurrentCline = jest.fn().mockReturnValue(null)
mockVisibleProvider.contextProxy.getValue = jest.fn().mockReturnValue(false)
mockGetVisibleInstance.mockReturnValue(mockVisibleProvider)
expect(isInAutomatedWorkflowFromVisibleProvider()).toBe(false)
})
it("should return true when visible provider has streaming task", () => {
const streamingTask = {
isStreaming: true,
isWaitingForFirstChunk: false,
didCompleteReadingStream: true,
presentAssistantMessageLocked: false,
} as Task
mockVisibleProvider.getCurrentCline = jest.fn().mockReturnValue(streamingTask)
mockVisibleProvider.contextProxy.getValue = jest.fn().mockReturnValue(false)
mockGetVisibleInstance.mockReturnValue(mockVisibleProvider)
expect(isInAutomatedWorkflowFromVisibleProvider()).toBe(true)
})
it("should return true when visible provider has auto-approval enabled with incomplete task", () => {
const incompleteTask = {
isStreaming: false,
isWaitingForFirstChunk: false,
didCompleteReadingStream: false,
presentAssistantMessageLocked: false,
} as Task
mockVisibleProvider.getCurrentCline = jest.fn().mockReturnValue(incompleteTask)
mockVisibleProvider.contextProxy.getValue = jest.fn().mockReturnValue(true)
mockGetVisibleInstance.mockReturnValue(mockVisibleProvider)
expect(isInAutomatedWorkflowFromVisibleProvider()).toBe(true)
})
it("should return false when visible provider has idle task and no auto-approval", () => {
const idleTask = {
isStreaming: false,
isWaitingForFirstChunk: false,
didCompleteReadingStream: true,
presentAssistantMessageLocked: false,
} as Task
mockVisibleProvider.getCurrentCline = jest.fn().mockReturnValue(idleTask)
mockVisibleProvider.contextProxy.getValue = jest.fn().mockReturnValue(false)
mockGetVisibleInstance.mockReturnValue(mockVisibleProvider)
expect(isInAutomatedWorkflowFromVisibleProvider()).toBe(false)
})
})
describe("edge cases and type safety", () => {
it("should handle boolean coercion correctly", () => {
// Test that the double negation (!!) in the original function works as expected
const truthyTask = {
isStreaming: true,
} as Task
expect(isInAutomatedWorkflow(truthyTask, false)).toBe(true)
// Test falsy values
const falsyTask = {
isStreaming: false,
isWaitingForFirstChunk: false,
didCompleteReadingStream: true,
presentAssistantMessageLocked: false,
} as Task
expect(isInAutomatedWorkflow(falsyTask, false)).toBe(false)
})
it("should handle provider with undefined getCurrentCline", () => {
const providerWithUndefinedTask = {
getCurrentCline: jest.fn().mockReturnValue(undefined),
contextProxy: {
getValue: jest.fn().mockReturnValue(false),
},
} as unknown as ClineProvider
expect(isInAutomatedWorkflowFromProvider(providerWithUndefinedTask)).toBe(false)
})
it("should handle all combinations of auto-approval conditions", () => {
// Test matrix of auto-approval conditions
const testCases = [
// [didCompleteReadingStream, presentAssistantMessageLocked, autoApprovalEnabled, expected]
[true, false, false, false],
[true, false, true, false],
[false, false, false, false],
[false, false, true, true], // incomplete stream with auto-approval
[true, true, false, false],
[true, true, true, true], // locked message with auto-approval
[false, true, false, false],
[false, true, true, true], // both incomplete stream and locked message
]
testCases.forEach(([didComplete, isLocked, autoApproval, expected]) => {
const task = {
isStreaming: false,
isWaitingForFirstChunk: false,
didCompleteReadingStream: didComplete,
presentAssistantMessageLocked: isLocked,
} as Task
expect(isInAutomatedWorkflow(task, autoApproval as boolean)).toBe(expected as boolean)
})
})
})
})

View file

@ -0,0 +1,118 @@
/**
* @fileoverview Automated workflow detection utilities for preserving chat focus during AI operations.
*
* This module provides utilities to detect when the AI is actively processing tasks, which is used
* to determine whether file operations should preserve chat focus to prevent interruption of user
* input or accidental exposure of sensitive information like API keys.
*
* Created as part of the fix for GitHub issue #4574: "chatbox loses focus during automated workflow"
*
* @author Roo Code Team
* @since v3.19.7
*/
import type { Task } from "../core/task/Task"
import type { ClineProvider } from "../core/webview/ClineProvider"
import { ClineProvider as ClineProviderClass } from "../core/webview/ClineProvider"
/**
* Detects if we are currently in an automated workflow where the AI is actively processing.
*
* This utility function addresses GitHub issue #4574 - preventing chatbox focus loss during
* automated file editing workflows. When the AI is actively working (streaming, processing,
* or auto-approving), opening files should preserve chat focus to prevent accidental
* interruption of user input or API key exposure.
*
* An automated workflow is detected when:
* - AI is actively streaming responses (`isStreaming === true`)
* - AI is waiting for the first chunk of a response (`isWaitingForFirstChunk === true`)
* - Auto-approval is enabled and the task hasn't completed reading/processing
* (`autoApprovalEnabled && didCompleteReadingStream === false`)
* - Auto-approval is enabled and the assistant message is locked for processing
* (`autoApprovalEnabled && presentAssistantMessageLocked === true`)
*
* @example
* ```typescript
* // Basic usage with explicit task and settings
* const shouldPreserveFocus = isInAutomatedWorkflow(currentTask, autoApprovalEnabled);
* openFile(filePath, { preserveFocus: shouldPreserveFocus });
*
* // Used in webview message handlers
* if (isInAutomatedWorkflow(task, autoApproval)) {
* // Preserve focus during automated operations
* openFile(path, { preserveFocus: true });
* }
* ```
*
* @param currentTask - The current active task, if any. Can be null/undefined when no task is active.
* @param autoApprovalEnabled - Whether auto-approval mode is currently enabled in the UI settings.
* @returns true if we're in an automated workflow and should preserve focus to prevent interruption
* @since v3.19.7 - Added as part of focus preservation fix for issue #4574
*/
export function isInAutomatedWorkflow(currentTask: Task | null | undefined, autoApprovalEnabled: boolean): boolean {
return !!(
currentTask &&
(currentTask.isStreaming ||
currentTask.isWaitingForFirstChunk ||
(autoApprovalEnabled && currentTask.didCompleteReadingStream === false) ||
(autoApprovalEnabled && currentTask.presentAssistantMessageLocked === true))
)
}
/**
* Convenience function for detecting automated workflows using a ClineProvider instance.
*
* This function extracts the current task and auto-approval state directly from the
* provider's context, making it easier to use in components that have access to a
* ClineProvider instance.
*
* @example
* ```typescript
* // In webview message handlers where provider is available
* const shouldPreserveFocus = isInAutomatedWorkflowFromProvider(provider);
* openFile(message.text!, {
* ...(message.values as OpenFileOptions),
* preserveFocus: shouldPreserveFocus,
* });
* ```
*
* @param provider - The ClineProvider instance to get current task and settings from
* @returns true if we're in an automated workflow and should preserve focus
* @since v3.19.7 - Added as part of focus preservation fix for issue #4574
*/
export function isInAutomatedWorkflowFromProvider(provider: ClineProvider): boolean {
const currentTask = provider.getCurrentCline()
const autoApprovalEnabled = provider.contextProxy.getValue("autoApprovalEnabled") as boolean
return isInAutomatedWorkflow(currentTask, autoApprovalEnabled)
}
/**
* Convenience function for detecting automated workflows from the currently visible provider.
*
* This function is particularly useful for components like file mention handlers that
* don't have direct access to a ClineProvider instance but need to determine if they're
* in an automated workflow context. It uses the static `getVisibleInstance()` method
* to access the currently active provider.
*
* @example
* ```typescript
* // In mention handlers or other components without provider access
* export async function openMention(mention?: string): Promise<void> {
* if (mention?.startsWith('/')) {
* const shouldPreserveFocus = isInAutomatedWorkflowFromVisibleProvider();
* openFile(absPath, { preserveFocus: shouldPreserveFocus });
* }
* }
* ```
*
* @returns true if we're in an automated workflow and should preserve focus,
* false if no visible provider exists or not in automated workflow
* @since v3.19.7 - Added as part of focus preservation fix for issue #4574
*/
export function isInAutomatedWorkflowFromVisibleProvider(): boolean {
const visibleProvider = ClineProviderClass.getVisibleInstance()
if (!visibleProvider) {
return false
}
return isInAutomatedWorkflowFromProvider(visibleProvider)
}