diff --git a/CHANGELOG.md b/CHANGELOG.md index 3a4c24ca8..1164788ba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ - **RovoDev (BBY)**: Fixed `ROVODEV_REBRAND_JCA` env var handling so the "Jira Coding Agent" rebrand works correctly in webviews. - **Notifications**: Fixed `atlassianNotificationNotifier` to correctly flush all promise levels, resolving a test reliability issue. - **RovoDev**: Fixed the prompt toolbar so only one dropdown (Add, Preferences, or model selector) can be open at a time - opening one now closes any other that was open, instead of stacking a dropdown over a dropdown. +- **RovoDev (BBY)**: Fixed frequent live preview failures (`HTTP 409` on `/v3/stream_chat`) caused by triggering a live preview while the agent was already streaming. The Live Preview button is now hidden as soon as it is clicked (and while the agent is running), and a backstop guard prevents a live preview request from being sent when a response is already in progress. When a live preview attempt fails or ends without starting a preview, the button is automatically restored so it can be clicked again to retry (the button itself is the retry affordance, so live-preview errors no longer show a non-functional "Try again"). ### Improvements diff --git a/src/rovo-dev/rovoDevChatProvider.test.ts b/src/rovo-dev/rovoDevChatProvider.test.ts index f8a689f34..6f7560fc6 100644 --- a/src/rovo-dev/rovoDevChatProvider.test.ts +++ b/src/rovo-dev/rovoDevChatProvider.test.ts @@ -63,6 +63,7 @@ describe('RovoDevChatProvider', () => { // Mock API client mockApiClient = { chat: jest.fn(), + createLivePreview: jest.fn(), cancel: jest.fn(), replay: jest.fn(), resumeToolCall: jest.fn(), @@ -295,6 +296,109 @@ describe('RovoDevChatProvider', () => { }); }); + describe('executeLivePreview', () => { + it('should throw error if API client is not initialized', async () => { + const providerWithoutClient = new RovoDevChatProvider(false, mockTelemetryProvider); + providerWithoutClient.setWebview(mockWebview); + + await expect(providerWithoutClient.executeLivePreview()).rejects.toThrow(/failed to initialize/); + expect(mockApiClient.createLivePreview).not.toHaveBeenCalled(); + }); + + it('should call createLivePreview when the agent is idle', async () => { + await chatProvider.setReady(mockApiClient); + + const mockReadableStream = new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode('data: {"event_kind": "close"}\n\n')); + controller.close(); + }, + }); + mockApiClient.createLivePreview.mockResolvedValue({ body: mockReadableStream } as Response); + + await chatProvider.executeLivePreview(); + + expect(mockApiClient.createLivePreview).toHaveBeenCalledTimes(1); + }); + + it('should NOT call createLivePreview when the agent is already running (avoids HTTP 409)', async () => { + await chatProvider.setReady(mockApiClient); + + // Simulate an in-flight stream by marking the agent as running. + (chatProvider as any)._abortController = new AbortController(); + expect(chatProvider.isAgentRunning).toBe(true); + + await chatProvider.executeLivePreview(); + + // The guard must short-circuit before hitting the backend so the + // second concurrent stream is never requested. + expect(mockApiClient.createLivePreview).not.toHaveBeenCalled(); + + // The button (hidden optimistically on click) should be restored so it + // reappears once the in-flight response finishes and the user can retry. + expect(mockWebview.postMessage).toHaveBeenCalledWith( + expect.objectContaining({ + type: RovoDevProviderMessageType.ShowLivePreviewButton, + show: true, + }), + ); + }); + + it('should restore the button when the attempt ends without starting a preview', async () => { + await chatProvider.setReady(mockApiClient); + + // A stream that finishes cleanly but never emits a `configure_live_preview` + // tool-call — i.e. the preview did not actually start. + const mockReadableStream = new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode('data: {"event_kind": "close"}\n\n')); + controller.close(); + }, + }); + mockApiClient.createLivePreview.mockResolvedValue({ body: mockReadableStream } as Response); + + await chatProvider.executeLivePreview(); + + expect(mockWebview.postMessage).toHaveBeenCalledWith( + expect.objectContaining({ + type: RovoDevProviderMessageType.ShowLivePreviewButton, + show: true, + }), + ); + }); + + it('should NOT restore the button when the preview actually starts', async () => { + await chatProvider.setReady(mockApiClient); + + // A stream that emits a `configure_live_preview` tool-call — the preview + // started successfully, so the button must stay hidden. + const toolCallData = JSON.stringify({ + tool_name: 'configure_live_preview', + tool_call_id: 'tc-1', + args: '{}', + }); + const mockReadableStream = new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode(`event: tool-call\ndata: ${toolCallData}\n\n`)); + controller.enqueue(new TextEncoder().encode('event: close\ndata: {}\n\n')); + controller.close(); + }, + }); + mockApiClient.createLivePreview.mockResolvedValue({ body: mockReadableStream } as Response); + + await chatProvider.executeLivePreview(); + + // The tool-call handler hides the button (show:false); the completion + // path must NOT subsequently re-show it (show:true). + expect(mockWebview.postMessage).not.toHaveBeenCalledWith( + expect.objectContaining({ + type: RovoDevProviderMessageType.ShowLivePreviewButton, + show: true, + }), + ); + }); + }); + describe('executeReplay', () => { it('should throw error if API client is not initialized', async () => { const providerWithoutClient = new RovoDevChatProvider(false, mockTelemetryProvider); diff --git a/src/rovo-dev/rovoDevChatProvider.ts b/src/rovo-dev/rovoDevChatProvider.ts index 489edc24e..7478035e1 100644 --- a/src/rovo-dev/rovoDevChatProvider.ts +++ b/src/rovo-dev/rovoDevChatProvider.ts @@ -96,6 +96,12 @@ export class RovoDevChatProvider { private _replayInProgress = false; private _lastMessageSentTime: number | undefined; + // true while a live-preview stream is being processed (drives non-retriable + // errors and restoring the button when the attempt doesn't start a preview) + private _livePreviewInProgress = false; + // set when a `configure_live_preview` tool-call is seen, i.e. the preview started + private _livePreviewStarted = false; + private get isDebugPanelEnabled() { return this.extensionApi.config.isDebugPanelEnabled(); } @@ -656,6 +662,7 @@ export class RovoDevChatProvider { message: response, }); if (response.tool_name === 'configure_live_preview' && sourceApi !== 'replay') { + this._livePreviewStarted = true; webview .postMessage({ type: RovoDevProviderMessageType.ShowLivePreviewButton, @@ -1102,8 +1109,10 @@ export class RovoDevChatProvider { ...(httpStatus !== undefined ? { httpStatus } : {}), }); } - // the error is retriable only when it happens during the streaming of a 'chat' response - await this.processError(error, { isRetriable: sourceApi === 'chat' }); + // retriable only for a 'chat' response; live-preview retries via the restored button + await this.processError(error, { + isRetriable: sourceApi === 'chat' && !this._livePreviewInProgress, + }); } } else { if (sourceApi === 'chat') { @@ -1261,9 +1270,22 @@ export class RovoDevChatProvider { return; } - // Put the chat into "listen" mode (GeneratingResponse) without echoing a synthetic - // user-message bubble — the live-preview click is implicitly a prompt, but we don't - // want to display fake user text for it. + // Backstop against the HTTP 409 "agent busy" race: `/v3/live-preview` starts a + // `stream_chat`, which the backend rejects if one is already in flight. + if (this.isAgentRunning) { + Logger.info('Ignoring live preview request: the agent is already running'); + // Restore the button (hidden optimistically on click) without touching the + // running response's state, so it reappears once the response finishes. + await this._webView.postMessage({ + type: RovoDevProviderMessageType.ShowLivePreviewButton, + show: true, + }); + return; + } + + this._livePreviewInProgress = true; + this._livePreviewStarted = false; + this.beginNewPrompt(); await this.signalPromptSent({ text: 'Start a live preview for this project', context: [] }, true); @@ -1274,8 +1296,21 @@ export class RovoDevChatProvider { return this.processResponse('chat', response); }; - await this.executeStreamingApiWithErrorHandling('chat', fetchOp); - this._abortController = null; + try { + await this.executeStreamingApiWithErrorHandling('chat', fetchOp); + } finally { + this._abortController = null; + + // if the preview never started, restore the button so the user can retry + if (!this._livePreviewStarted && this._webView) { + await this._webView.postMessage({ + type: RovoDevProviderMessageType.ShowLivePreviewButton, + show: true, + }); + } + + this._livePreviewInProgress = false; + } } private preparePayloadForChatRequest(prompt: RovoDevPrompt): RovoDevChatRequest { diff --git a/src/rovo-dev/ui/live-preview/LivePreviewButton.tsx b/src/rovo-dev/ui/live-preview/LivePreviewButton.tsx index 792e406b1..fa42d685d 100644 --- a/src/rovo-dev/ui/live-preview/LivePreviewButton.tsx +++ b/src/rovo-dev/ui/live-preview/LivePreviewButton.tsx @@ -8,15 +8,32 @@ interface LivePreviewButtonProps { messagingApi: ReturnType< typeof useMessagingApi >; + // preferred handler for starting a live preview; the parent guards re-clicks and + // hides the button. returns false if it did not start (so we can reset the + // spinner). falls back to posting the message directly when omitted. + onCreateLivePreview?: () => boolean; } -export const LivePreviewButton: React.FC = ({ messagingApi: { postMessage } }) => { +export const LivePreviewButton: React.FC = ({ + messagingApi: { postMessage }, + onCreateLivePreview, +}) => { const [isLoading, setIsLoading] = React.useState(false); const handleClick = async (event: React.MouseEvent) => { event.preventDefault(); + if (isLoading) { + return; + } setIsLoading(true); - postMessage({ type: RovoDevViewResponseType.CreateLivePreview }); + if (onCreateLivePreview) { + // reset the spinner if the preview wasn't started (e.g. agent no longer idle) + if (!onCreateLivePreview()) { + setIsLoading(false); + } + } else { + postMessage({ type: RovoDevViewResponseType.CreateLivePreview }); + } }; return ( diff --git a/src/rovo-dev/ui/messaging/ChatStream.tsx b/src/rovo-dev/ui/messaging/ChatStream.tsx index d50780b2f..2740609ad 100644 --- a/src/rovo-dev/ui/messaging/ChatStream.tsx +++ b/src/rovo-dev/ui/messaging/ChatStream.tsx @@ -49,6 +49,7 @@ interface ChatStreamProps { credentialHints?: CredentialHint[]; onGeneratePlanClick?: (planId: string, proceed: boolean) => void; showLivePreviewButton?: boolean; + onCreateLivePreview?: () => boolean; isAtlassianUser?: boolean; } @@ -75,6 +76,7 @@ export const ChatStream: React.FC = ({ credentialHints, onGeneratePlanClick, showLivePreviewButton, + onCreateLivePreview, isAtlassianUser, }) => { const chatEndRef = React.useRef(null); @@ -283,7 +285,7 @@ export const ChatStream: React.FC = ({ {showActionFooter && ( - + )}
diff --git a/src/rovo-dev/ui/rovoDevView.tsx b/src/rovo-dev/ui/rovoDevView.tsx index 13aca8717..47dc10279 100644 --- a/src/rovo-dev/ui/rovoDevView.tsx +++ b/src/rovo-dev/ui/rovoDevView.tsx @@ -675,6 +675,25 @@ const RovoDevView: React.FC = () => { }); }, [postMessage]); + // returns true if the live preview was started, false if it was skipped + // (agent no longer idle) so the caller can reset its own state + const onCreateLivePreview = useCallback((): boolean => { + // only start when idle, and hide the button immediately to avoid a + // double-click race that triggers an HTTP 409 ("agent busy") + if (currentState.state !== 'WaitingForPrompt') { + return false; + } + + setShowLivePreviewButton(false); + setCurrentState({ state: 'GeneratingResponse' }); + + postMessage({ + type: RovoDevViewResponseType.CreateLivePreview, + }); + + return true; + }, [currentState.state, postMessage]); + const cancelResponse = useCallback((): void => { if (currentState.state === 'CancellingResponse') { return; @@ -1107,6 +1126,7 @@ const RovoDevView: React.FC = () => { credentialHints={credentialHints} onGeneratePlanClick={(e: string, proceed: boolean) => handleExitPlanMode(proceed, e)} showLivePreviewButton={showLivePreviewButton} + onCreateLivePreview={onCreateLivePreview} /> {!hidePromptBox && (