From 88784527850b02c9470212431e997e4f8694553b Mon Sep 17 00:00:00 2001 From: Lifei Zhou Date: Sun, 28 Jun 2026 09:13:34 +1000 Subject: [PATCH] feat (ui): Remove ACP chat feature flag and turn on chat using ACP (#10062) --- ui/desktop/src/App.test.tsx | 12 - .../src/__tests__/createSession.test.ts | 81 +- .../acp/__tests__/chatNotifications.test.ts | 4 - .../acp/__tests__/elicitationRequests.test.ts | 4 - .../acp/__tests__/permissionRequests.test.ts | 4 - .../acp/__tests__/recipeParamRequests.test.ts | 4 - .../src/acp/adapter/toolNotifications.ts | 24 +- ui/desktop/src/acp/chatNotifications.ts | 38 +- ui/desktop/src/acp/elicitationRequests.ts | 3 +- ui/desktop/src/acp/permissionRequests.ts | 5 - ui/desktop/src/acp/recipeParamRequests.ts | 5 - ui/desktop/src/acpChatFeatureFlag.ts | 1 - ui/desktop/src/components/BaseChat.tsx | 40 +- ui/desktop/src/components/ChatInput.tsx | 7 - .../components/ToolApprovalButtons.test.tsx | 49 - .../src/components/ToolApprovalButtons.tsx | 28 +- .../BottomMenuExtensionSelection.tsx | 11 +- .../components/sessions/SessionListView.tsx | 2 - .../settings/extensions/agent-api.ts | 22 +- ui/desktop/src/hooks/useAcpChatSession.ts | 340 ----- ui/desktop/src/hooks/useChatSession.ts | 333 ++++- ui/desktop/src/hooks/useChatSessionTypes.ts | 4 - ui/desktop/src/hooks/useChatStream.ts | 1183 ----------------- .../src/hooks/useSessionEvents.test.tsx | 94 -- ui/desktop/src/hooks/useSessionEvents.ts | 161 --- ui/desktop/src/sessions.ts | 33 +- ui/desktop/src/types/message.ts | 41 - 27 files changed, 423 insertions(+), 2110 deletions(-) delete mode 100644 ui/desktop/src/acpChatFeatureFlag.ts delete mode 100644 ui/desktop/src/hooks/useAcpChatSession.ts delete mode 100644 ui/desktop/src/hooks/useChatStream.ts delete mode 100644 ui/desktop/src/hooks/useSessionEvents.test.tsx delete mode 100644 ui/desktop/src/hooks/useSessionEvents.ts diff --git a/ui/desktop/src/App.test.tsx b/ui/desktop/src/App.test.tsx index cee3c1466..2f7effb30 100644 --- a/ui/desktop/src/App.test.tsx +++ b/ui/desktop/src/App.test.tsx @@ -34,23 +34,11 @@ vi.mock('./utils/costDatabase', () => ({ })); vi.mock('./api', () => { - const test_chat = { - data: { - session_id: 'test', - messages: [], - metadata: { - description: '', - }, - }, - }; - return { initConfig: vi.fn().mockResolvedValue(undefined), backupConfig: vi.fn().mockResolvedValue(undefined), recoverConfig: vi.fn().mockResolvedValue(undefined), validateConfig: vi.fn().mockResolvedValue(undefined), - startAgent: vi.fn().mockResolvedValue(test_chat), - resumeAgent: vi.fn().mockResolvedValue(test_chat), }; }); diff --git a/ui/desktop/src/__tests__/createSession.test.ts b/ui/desktop/src/__tests__/createSession.test.ts index 17cede662..053fdc183 100644 --- a/ui/desktop/src/__tests__/createSession.test.ts +++ b/ui/desktop/src/__tests__/createSession.test.ts @@ -1,11 +1,23 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { startAgent } from '../api'; import { createSession } from '../sessions'; import type { ExtensionConfig, Session } from '../api'; import type { FixedExtensionEntry } from '../components/ConfigContext'; +import type { GooseExtension, GooseExtensionEntry } from '@aaif/goose-sdk'; +import { getConfiguredGooseExtensions } from '../acp/extensions'; +import { acpChatSessionController } from '../acp/chatSessionController'; -vi.mock('../api', () => ({ - startAgent: vi.fn(), +vi.mock('../acp/extensions', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + getConfiguredGooseExtensions: vi.fn(), + }; +}); + +vi.mock('../acp/chatSessionController', () => ({ + acpChatSessionController: { + createSession: vi.fn(), + }, })); const testSession: Session = { @@ -29,30 +41,40 @@ const configuredExtension = (name: string, enabled: boolean): FixedExtensionEntr enabled, }); -const mockedStartAgent = vi.mocked(startAgent); +const gooseExtension = (name: string): GooseExtension => ({ + type: 'builtin', + name, + description: `${name} extension`, +}); -describe('createSession extension overrides', () => { +const gooseExtensionEntry = (name: string): GooseExtensionEntry => ({ + extension: gooseExtension(name), + enabled: true, +}); + +const mockedGetConfiguredGooseExtensions = vi.mocked(getConfiguredGooseExtensions); +const mockedCreateAcpSession = vi.mocked(acpChatSessionController.createSession); + +describe('createSession ACP session extensions', () => { beforeEach(() => { - mockedStartAgent.mockReset(); - mockedStartAgent.mockResolvedValue({ - data: testSession, - error: undefined, - request: new globalThis.Request('http://localhost/sessions'), - response: new globalThis.Response(), - }); + mockedGetConfiguredGooseExtensions.mockReset(); + mockedGetConfiguredGooseExtensions.mockResolvedValue([ + gooseExtensionEntry('developer'), + gooseExtensionEntry('memory'), + ]); + mockedCreateAcpSession.mockReset(); + mockedCreateAcpSession.mockResolvedValue(testSession); }); - it('sends non-empty extension configs as overrides', async () => { + it('sends non-empty extension configs as ACP session extensions', async () => { await createSession('/tmp', { extensionConfigs: [extensionConfig('developer')], }); - expect(mockedStartAgent).toHaveBeenCalledWith({ - body: { - working_dir: '/tmp', - extension_overrides: [extensionConfig('developer')], - }, - throwOnError: true, + expect(mockedGetConfiguredGooseExtensions).toHaveBeenCalledOnce(); + expect(mockedCreateAcpSession).toHaveBeenCalledWith('/tmp', [gooseExtension('developer')], { + recipeDeeplink: undefined, + recipeId: undefined, }); }); @@ -62,25 +84,22 @@ describe('createSession extension overrides', () => { allExtensions: [configuredExtension('developer', true), configuredExtension('memory', false)], }); - expect(mockedStartAgent).toHaveBeenCalledWith({ - body: { - working_dir: '/tmp', - extension_overrides: [extensionConfig('developer')], - }, - throwOnError: true, + expect(mockedGetConfiguredGooseExtensions).toHaveBeenCalledOnce(); + expect(mockedCreateAcpSession).toHaveBeenCalledWith('/tmp', [gooseExtension('developer')], { + recipeDeeplink: undefined, + recipeId: undefined, }); }); - it('omits extension overrides when no configured extensions are enabled', async () => { + it('omits ACP session extensions when no configured extensions are enabled', async () => { await createSession('/tmp', { allExtensions: [configuredExtension('developer', false)], }); - expect(mockedStartAgent).toHaveBeenCalledWith({ - body: { - working_dir: '/tmp', - }, - throwOnError: true, + expect(mockedGetConfiguredGooseExtensions).not.toHaveBeenCalled(); + expect(mockedCreateAcpSession).toHaveBeenCalledWith('/tmp', [], { + recipeDeeplink: undefined, + recipeId: undefined, }); }); }); diff --git a/ui/desktop/src/acp/__tests__/chatNotifications.test.ts b/ui/desktop/src/acp/__tests__/chatNotifications.test.ts index a5d6d6894..8d1258e44 100644 --- a/ui/desktop/src/acp/__tests__/chatNotifications.test.ts +++ b/ui/desktop/src/acp/__tests__/chatNotifications.test.ts @@ -8,10 +8,6 @@ import { handleAcpSessionNotification } from '../chatNotifications'; import type { AcpChatSessionSnapshot } from '../chatSessionStore'; import { acpChatSessionActions, acpChatSessionStore } from '../chatSessionStore'; -vi.mock('../../acpChatFeatureFlag', () => ({ - USE_ACP_CHAT: true, -})); - vi.mock('../chatSessionStore', () => ({ acpChatSessionStore: { getSnapshot: vi.fn(), diff --git a/ui/desktop/src/acp/__tests__/elicitationRequests.test.ts b/ui/desktop/src/acp/__tests__/elicitationRequests.test.ts index c84c3eb0f..51941d85c 100644 --- a/ui/desktop/src/acp/__tests__/elicitationRequests.test.ts +++ b/ui/desktop/src/acp/__tests__/elicitationRequests.test.ts @@ -8,10 +8,6 @@ import { } from '../elicitationRequests'; import { acpChatSessionActions } from '../chatSessionStore'; -vi.mock('../../acpChatFeatureFlag', () => ({ - USE_ACP_CHAT: true, -})); - vi.mock('../chatSessionStore', () => ({ acpElicitationUserInputRequestId: (elicitationId: string) => `elicitation:${elicitationId}`, acpChatSessionActions: { diff --git a/ui/desktop/src/acp/__tests__/permissionRequests.test.ts b/ui/desktop/src/acp/__tests__/permissionRequests.test.ts index 9274011fb..839c71fc8 100644 --- a/ui/desktop/src/acp/__tests__/permissionRequests.test.ts +++ b/ui/desktop/src/acp/__tests__/permissionRequests.test.ts @@ -7,10 +7,6 @@ import { } from '../permissionRequests'; import { acpChatSessionActions } from '../chatSessionStore'; -vi.mock('../../acpChatFeatureFlag', () => ({ - USE_ACP_CHAT: true, -})); - vi.mock('../chatSessionStore', () => ({ acpPermissionUserInputRequestId: (toolCallId: string) => `permission:${toolCallId}`, acpChatSessionActions: { diff --git a/ui/desktop/src/acp/__tests__/recipeParamRequests.test.ts b/ui/desktop/src/acp/__tests__/recipeParamRequests.test.ts index 87291d5d1..b2f9511ea 100644 --- a/ui/desktop/src/acp/__tests__/recipeParamRequests.test.ts +++ b/ui/desktop/src/acp/__tests__/recipeParamRequests.test.ts @@ -7,10 +7,6 @@ import { resolveAcpRecipeParamRequest, } from '../recipeParamRequests'; -vi.mock('../../acpChatFeatureFlag', () => ({ - USE_ACP_CHAT: true, -})); - function recipeParamRequest(): RequestRecipeParams_unstable { return { sessionId: 'session-1', diff --git a/ui/desktop/src/acp/adapter/toolNotifications.ts b/ui/desktop/src/acp/adapter/toolNotifications.ts index 290136d3c..f23ab7042 100644 --- a/ui/desktop/src/acp/adapter/toolNotifications.ts +++ b/ui/desktop/src/acp/adapter/toolNotifications.ts @@ -4,18 +4,18 @@ import type { AcpChatStateChange } from './shared'; import { isRecord } from './shared'; type ToolNotification = - | { - type: 'message'; - params: LoggingMessageNotificationParams; - } - | { - type: 'progress'; - params: ProgressNotificationParams; - } - | { - type: 'platform_event'; - params: PlatformEventParams; - }; + | { + type: 'message'; + params: LoggingMessageNotificationParams; + } + | { + type: 'progress'; + params: ProgressNotificationParams; + } + | { + type: 'platform_event'; + params: PlatformEventParams; + }; type LoggingMessageNotificationParams = { level: string; diff --git a/ui/desktop/src/acp/chatNotifications.ts b/ui/desktop/src/acp/chatNotifications.ts index ba67ad252..6e870eed2 100644 --- a/ui/desktop/src/acp/chatNotifications.ts +++ b/ui/desktop/src/acp/chatNotifications.ts @@ -1,31 +1,29 @@ import type { GooseSessionNotification_unstable } from '@aaif/goose-sdk'; import type { SessionNotification } from '@agentclientprotocol/sdk'; -import { USE_ACP_CHAT } from '../acpChatFeatureFlag'; import { AppEvents } from '../constants/events'; import { maybeHandlePlatformEvent } from '../utils/platform_events'; import { toolNotificationEvent } from './adapter/toolNotifications'; import { acpChatSessionActions, acpChatSessionStore } from './chatSessionStore'; export function handleAcpSessionNotification(notification: SessionNotification): Promise { - if (USE_ACP_CHAT) { - const sessionNameBeforeNotification = acpChatSessionStore.getSnapshot( - notification.sessionId - )?.session?.name; - const updatedName = - notification.update.sessionUpdate === 'session_info_update' - ? notification.update.title - : undefined; - acpChatSessionActions.applyAcpSessionNotification(notification); - maybeHandleLivePlatformEvent(notification); + const sessionNameBeforeNotification = acpChatSessionStore.getSnapshot( + notification.sessionId + )?.session?.name; + const updatedName = + notification.update.sessionUpdate === 'session_info_update' + ? notification.update.title + : undefined; + acpChatSessionActions.applyAcpSessionNotification(notification); + maybeHandleLivePlatformEvent(notification); - if (updatedName && updatedName !== sessionNameBeforeNotification) { - window.dispatchEvent( - new CustomEvent(AppEvents.SESSION_RENAMED, { - detail: { sessionId: notification.sessionId, newName: updatedName }, - }) - ); - } + if (updatedName && updatedName !== sessionNameBeforeNotification) { + window.dispatchEvent( + new CustomEvent(AppEvents.SESSION_RENAMED, { + detail: { sessionId: notification.sessionId, newName: updatedName }, + }) + ); } + return Promise.resolve(); } @@ -48,8 +46,6 @@ function maybeHandleLivePlatformEvent(notification: SessionNotification): void { export function handleAcpGooseSessionNotification( notification: GooseSessionNotification_unstable ): Promise { - if (USE_ACP_CHAT) { - acpChatSessionActions.applyAcpGooseSessionNotification(notification); - } + acpChatSessionActions.applyAcpGooseSessionNotification(notification); return Promise.resolve(); } diff --git a/ui/desktop/src/acp/elicitationRequests.ts b/ui/desktop/src/acp/elicitationRequests.ts index 9ec139d1a..0ca451a3f 100644 --- a/ui/desktop/src/acp/elicitationRequests.ts +++ b/ui/desktop/src/acp/elicitationRequests.ts @@ -5,7 +5,6 @@ import type { ElicitationSchema, } from '@agentclientprotocol/sdk'; import { v7 as uuidv7 } from 'uuid'; -import { USE_ACP_CHAT } from '../acpChatFeatureFlag'; import { acpChatSessionActions, acpElicitationUserInputRequestId } from './chatSessionStore'; type SessionScopedFormElicitationRequest = CreateElicitationRequest & { @@ -32,7 +31,7 @@ export const ACP_ELICITATION_TIMEOUT_SECONDS = 300; export async function requestAcpElicitation( request: CreateElicitationRequest ): Promise { - if (!USE_ACP_CHAT || !isSessionScopedFormElicitation(request)) { + if (!isSessionScopedFormElicitation(request)) { return cancelledElicitationResponse(); } diff --git a/ui/desktop/src/acp/permissionRequests.ts b/ui/desktop/src/acp/permissionRequests.ts index c6f2167eb..9beed3a6b 100644 --- a/ui/desktop/src/acp/permissionRequests.ts +++ b/ui/desktop/src/acp/permissionRequests.ts @@ -1,6 +1,5 @@ import type { RequestPermissionRequest, RequestPermissionResponse } from '@agentclientprotocol/sdk'; import type { Permission } from '../api'; -import { USE_ACP_CHAT } from '../acpChatFeatureFlag'; import { acpChatSessionActions, acpPermissionUserInputRequestId } from './chatSessionStore'; interface PendingPermissionRequest { @@ -19,10 +18,6 @@ export async function requestAcpPermission( previous.resolve(cancelledPermissionResponse()); } - if (!USE_ACP_CHAT) { - return cancelledPermissionResponse(); - } - return new Promise((resolve) => { pendingRequests.set(key, { request, resolve }); acpChatSessionActions.applyPermissionRequest(request); diff --git a/ui/desktop/src/acp/recipeParamRequests.ts b/ui/desktop/src/acp/recipeParamRequests.ts index 21f73cdb0..1fcbcbafa 100644 --- a/ui/desktop/src/acp/recipeParamRequests.ts +++ b/ui/desktop/src/acp/recipeParamRequests.ts @@ -4,7 +4,6 @@ import type { RequestRecipeParams_unstable, } from '@aaif/goose-sdk'; import { v7 as uuidv7 } from 'uuid'; -import { USE_ACP_CHAT } from '../acpChatFeatureFlag'; export interface AcpRecipeParamRequest { id: string; @@ -50,10 +49,6 @@ function configuredParameterValues(): Record { export async function requestAcpRecipeParams( request: RequestRecipeParams_unstable ): Promise { - if (!USE_ACP_CHAT) { - return { action: 'cancel' }; - } - const initialValues = configuredParameterValues(); const paramRequest: AcpRecipeParamRequest = { id: `acp_recipe_params_${uuidv7()}`, diff --git a/ui/desktop/src/acpChatFeatureFlag.ts b/ui/desktop/src/acpChatFeatureFlag.ts deleted file mode 100644 index 34ebb1753..000000000 --- a/ui/desktop/src/acpChatFeatureFlag.ts +++ /dev/null @@ -1 +0,0 @@ -export const USE_ACP_CHAT = false; diff --git a/ui/desktop/src/components/BaseChat.tsx b/ui/desktop/src/components/BaseChat.tsx index 7e23fa148..e47388932 100644 --- a/ui/desktop/src/components/BaseChat.tsx +++ b/ui/desktop/src/components/BaseChat.tsx @@ -10,14 +10,13 @@ import ChatInput from './ChatInput'; import { ChatInputCard } from './ChatInputCard'; import { ScrollArea, ScrollAreaHandle } from './ui/scroll-area'; import { useFileDrop } from '../hooks/useFileDrop'; -import { Message, updateWorkingDir } from '../api'; +import { Message } from '../api'; import { ChatState } from '../types/chatState'; import { ChatType } from '../types/chat'; import { useIsMobile } from '../hooks/use-mobile'; import { useNavigationContextSafe } from './Layout/NavigationContext'; import { cn } from '../utils'; import { useChatSession } from '../hooks/useChatSession'; -import { USE_ACP_CHAT } from '../acpChatFeatureFlag'; import { acpDeleteSession, acpUpdateWorkingDir } from '../acp/sessions'; import { useNavigation } from '../hooks/useNavigation'; import { RecipeHeader } from './RecipeHeader'; @@ -27,7 +26,6 @@ import type { Recipe } from '../recipe'; import { UserInput } from '../types/message'; import RecipeActivities from './recipes/RecipeActivities'; import { getThinkingMessage, getTextAndImageContent } from '../types/message'; -import ParameterInputModal from './ParameterInputModal'; import { substituteParameters } from '../utils/parameterSubstitution'; import { useAutoSubmit } from '../hooks/useAutoSubmit'; import { Goose } from './icons'; @@ -91,14 +89,12 @@ export default function BaseChat({ session, messages, chatState, - setChatState, updateSession, handleSubmit, onSteerQueuedMessage, submitElicitationResponse, stopStreaming, sessionLoadError, - setRecipeUserParams, tokenState, notifications: toolCallNotifications, pauseQueueOnStop, @@ -111,22 +107,13 @@ export default function BaseChat({ const handleWorkingDirChange = useCallback( async (newDir: string) => { - if (USE_ACP_CHAT) { - if (!session) { - throw new Error('Cannot update working directory before ACP session is loaded'); - } - - await acpUpdateWorkingDir(session.id, newDir); - } else { - await updateWorkingDir({ - body: { session_id: sessionId, working_dir: newDir }, - throwOnError: true, - }); + if (!session) { + throw new Error('Cannot update working directory before ACP session is loaded'); } - + await acpUpdateWorkingDir(session.id, newDir); updateSession((currentSession) => ({ ...currentSession, working_dir: newDir })); }, - [session, sessionId, updateSession] + [session, updateSession] ); const recipe = session?.recipe as Recipe | null | undefined; @@ -502,7 +489,6 @@ export default function BaseChat({ sessionId={sessionId} handleSubmit={chatInputSubmit} chatState={chatState} - setChatState={setChatState} onStop={stopStreaming} onSteerQueuedMessage={onSteerQueuedMessage} pauseQueueOnStop={pauseQueueOnStop} @@ -553,22 +539,6 @@ export default function BaseChat({ hasSecurityWarnings={hasRecipeSecurityWarnings} /> )} - - {!USE_ACP_CHAT && - recipe?.parameters && - recipe.parameters.length > 0 && - !session?.user_recipe_values && - session?.session_type !== 'scheduled' && ( - setView('chat')} - initialValues={ - (window.appConfig?.get('recipeParameters') as Record | undefined) || - undefined - } - /> - )} ); } diff --git a/ui/desktop/src/components/ChatInput.tsx b/ui/desktop/src/components/ChatInput.tsx index 25dec3d58..89f6e55d1 100644 --- a/ui/desktop/src/components/ChatInput.tsx +++ b/ui/desktop/src/components/ChatInput.tsx @@ -16,7 +16,6 @@ import { cn } from '../utils'; import { AlertType, useAlerts } from './alerts'; import { useModelAndProvider } from './ModelAndProviderContext'; import { acpListProviderDetails } from '../acp/providers'; -import { USE_ACP_CHAT } from '../acpChatFeatureFlag'; import { useAudioRecorder } from '../hooks/useAudioRecorder'; import { toastError } from '../toasts'; import MentionPopover, { DisplayItemWithMatch } from './MentionPopover'; @@ -161,7 +160,6 @@ interface ChatInputProps { sessionId: string | null; handleSubmit: (input: UserInput) => void; chatState: ChatState; - setChatState?: (state: ChatState) => void; onStop?: () => void; onSteerQueuedMessage?: (input: UserInput) => Promise; pauseQueueOnStop?: boolean; @@ -197,7 +195,6 @@ export default function ChatInput({ sessionId, handleSubmit, chatState = ChatState.Idle, - setChatState, onStop, onSteerQueuedMessage, pauseQueueOnStop = false, @@ -1682,10 +1679,6 @@ export default function ChatInput({ await onWorkingDirChange?.(newDir); setWorkingDirOverride(newDir); }} - onRestartStart={ - USE_ACP_CHAT ? undefined : () => setChatState?.(ChatState.RestartingAgent) - } - onRestartEnd={USE_ACP_CHAT ? undefined : () => setChatState?.(ChatState.Idle)} /> )} diff --git a/ui/desktop/src/components/ToolApprovalButtons.test.tsx b/ui/desktop/src/components/ToolApprovalButtons.test.tsx index 6800c6fb5..89b21314e 100644 --- a/ui/desktop/src/components/ToolApprovalButtons.test.tsx +++ b/ui/desktop/src/components/ToolApprovalButtons.test.tsx @@ -1,39 +1,22 @@ import { render, type RenderOptions, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { confirmToolAction } from '../api'; import { resolveAcpPermissionRequest } from '../acp/permissionRequests'; import { IntlTestWrapper } from '../i18n/test-utils'; import ToolApprovalButtons from './ToolApprovalButtons'; -const acpChatFeatureFlagMock = vi.hoisted(() => ({ - useAcpChat: true, -})); - -vi.mock('../api', () => ({ - confirmToolAction: vi.fn(), -})); - vi.mock('../acp/permissionRequests', () => ({ resolveAcpPermissionRequest: vi.fn(), })); -vi.mock('../acpChatFeatureFlag', () => ({ - get USE_ACP_CHAT() { - return acpChatFeatureFlagMock.useAcpChat; - }, -})); - const renderWithIntl = (ui: React.ReactElement, options?: RenderOptions) => render(ui, { wrapper: IntlTestWrapper, ...options }); -const confirmToolActionMock = vi.mocked(confirmToolAction); const resolveAcpPermissionRequestMock = vi.mocked(resolveAcpPermissionRequest); describe('ToolApprovalButtons', () => { beforeEach(() => { vi.clearAllMocks(); - acpChatFeatureFlagMock.useAcpChat = true; }); it('marks the approval accepted when the ACP request resolves', async () => { @@ -56,7 +39,6 @@ describe('ToolApprovalButtons', () => { 'tool-call-approved', 'allow_once' ); - expect(confirmToolActionMock).not.toHaveBeenCalled(); expect(screen.getByText('developer__shell - Allowed once')).toBeInTheDocument(); }); @@ -80,38 +62,7 @@ describe('ToolApprovalButtons', () => { 'tool-call-rerun', 'allow_once' ); - expect(confirmToolActionMock).not.toHaveBeenCalled(); expect(screen.getByText('This approval request is no longer active.')).toBeInTheDocument(); expect(screen.queryByText('developer__shell - Allowed once')).not.toBeInTheDocument(); }); - - it('uses the REST confirmation path when ACP chat is disabled', async () => { - acpChatFeatureFlagMock.useAcpChat = false; - confirmToolActionMock.mockResolvedValueOnce({ error: undefined } as Awaited< - ReturnType - >); - - renderWithIntl( - - ); - - await userEvent.click(screen.getByRole('button', { name: 'Allow Once' })); - - expect(resolveAcpPermissionRequestMock).not.toHaveBeenCalled(); - expect(confirmToolActionMock).toHaveBeenCalledWith({ - body: { - sessionId: 'session-1', - id: 'tool-call-rest', - action: 'allow_once', - principalType: 'Tool', - }, - }); - expect(screen.getByText('developer__shell - Allowed once')).toBeInTheDocument(); - }); }); diff --git a/ui/desktop/src/components/ToolApprovalButtons.tsx b/ui/desktop/src/components/ToolApprovalButtons.tsx index 8922611ba..e0cd8c97d 100644 --- a/ui/desktop/src/components/ToolApprovalButtons.tsx +++ b/ui/desktop/src/components/ToolApprovalButtons.tsx @@ -1,8 +1,7 @@ import { useState, useEffect } from 'react'; import { Button } from './ui/button'; -import { confirmToolAction, Permission } from '../api'; +import { Permission } from '../api'; import { resolveAcpPermissionRequest } from '../acp/permissionRequests'; -import { USE_ACP_CHAT } from '../acpChatFeatureFlag'; import { defineMessages, useIntl } from '../i18n'; const i18n = defineMessages({ @@ -90,27 +89,10 @@ export default function ToolApprovalButtons({ data }: { data: ToolApprovalData } const handleAction = async (action: Permission) => { try { - if (USE_ACP_CHAT) { - if (resolveAcpPermissionRequest(sessionId, id, action)) { - setResolvedDecision(action); - } else { - setApprovalError(intl.formatMessage(i18n.staleApprovalRequest)); - } - return; - } - - setResolvedDecision(action); - - const response = await confirmToolAction({ - body: { - sessionId, - id, - action, - principalType: 'Tool', - }, - }); - if (response.error) { - console.error('Failed to confirm tool action:', response.error); + if (resolveAcpPermissionRequest(sessionId, id, action)) { + setResolvedDecision(action); + } else { + setApprovalError(intl.formatMessage(i18n.staleApprovalRequest)); } } catch (err) { console.error('Error confirming tool action:', err); diff --git a/ui/desktop/src/components/bottom_menu/BottomMenuExtensionSelection.tsx b/ui/desktop/src/components/bottom_menu/BottomMenuExtensionSelection.tsx index ee152269e..0648e01bc 100644 --- a/ui/desktop/src/components/bottom_menu/BottomMenuExtensionSelection.tsx +++ b/ui/desktop/src/components/bottom_menu/BottomMenuExtensionSelection.tsx @@ -5,7 +5,6 @@ import { formatExtensionName } from '../settings/extensions/subcomponents/Extens import { nameToKey } from '../settings/extensions/utils'; import { ExtensionConfig, getSessionExtensions } from '../../api'; import { getSessionExtensions as getAcpSessionExtensions } from '../../acp/session-extensions'; -import { USE_ACP_CHAT } from '../../acpChatFeatureFlag'; import { addToAgent, removeFromAgent } from '../settings/extensions/agent-api'; import { defineMessages, useIntl } from '../../i18n'; import { AppEvents } from '../../constants/events'; @@ -262,15 +261,7 @@ function SessionExtensionsMenu({ sessionId }: { sessionId: string }) { const loadSessionExtensions = useCallback( async (targetSessionId: string, signal?: GetSessionExtensionsSignal) => { - const extensions = USE_ACP_CHAT - ? await getAcpSessionExtensions(targetSessionId) - : (( - await getSessionExtensions({ - path: { session_id: targetSessionId }, - signal, - throwOnError: true, - }) - ).data?.extensions ?? []); + const extensions = await getAcpSessionExtensions(targetSessionId) if (signal?.aborted || latestSessionIdRef.current !== targetSessionId) { return; diff --git a/ui/desktop/src/components/sessions/SessionListView.tsx b/ui/desktop/src/components/sessions/SessionListView.tsx index c884baa8c..c6c08e5e2 100644 --- a/ui/desktop/src/components/sessions/SessionListView.tsx +++ b/ui/desktop/src/components/sessions/SessionListView.tsx @@ -48,7 +48,6 @@ import { acpChatSessionActions } from '../../acp/chatSessionStore'; import { cancelAcpPermissionRequestsForSession } from '../../acp/permissionRequests'; import { cancelAcpElicitationRequestsForSession } from '../../acp/elicitationRequests'; import { getSearchShortcutText } from '../../utils/keyboardShortcuts'; -import { clearSessionCache } from '../../hooks/useChatStream'; const i18n = defineMessages({ editSessionTitle: { id: 'sessions.edit.title', defaultMessage: 'Edit Session Description' }, @@ -484,7 +483,6 @@ const SessionListView: React.FC = React.memo( window.dispatchEvent( new CustomEvent(AppEvents.SESSION_DELETED, { detail: { sessionId: sessionToDeleteId } }) ); - clearSessionCache(sessionToDeleteId); cancelAcpPermissionRequestsForSession(sessionToDeleteId); cancelAcpElicitationRequestsForSession(sessionToDeleteId); acpChatSessionActions.deleteSnapshot(sessionToDeleteId); diff --git a/ui/desktop/src/components/settings/extensions/agent-api.ts b/ui/desktop/src/components/settings/extensions/agent-api.ts index 3a29d4346..dec6c3639 100644 --- a/ui/desktop/src/components/settings/extensions/agent-api.ts +++ b/ui/desktop/src/components/settings/extensions/agent-api.ts @@ -1,6 +1,5 @@ import { toastService } from '../../../toasts'; -import { agentAddExtension, ExtensionConfig, agentRemoveExtension } from '../../../api'; -import { USE_ACP_CHAT } from '../../../acpChatFeatureFlag'; +import { ExtensionConfig } from '../../../api'; import { addSessionExtension, removeSessionExtension } from '../../../acp/session-extensions'; import { errorMessage } from '../../../utils/conversionUtils'; import { @@ -22,14 +21,7 @@ export async function addToAgent( : 0; try { - if (USE_ACP_CHAT) { - await addSessionExtension(sessionId, extensionConfig); - } else { - await agentAddExtension({ - body: { session_id: sessionId, config: extensionConfig }, - throwOnError: true, - }); - } + await addSessionExtension(sessionId, extensionConfig); if (showToast) { toastService.dismiss(toastId); toastService.success({ @@ -67,14 +59,8 @@ export async function removeFromAgent( : 0; try { - if (USE_ACP_CHAT) { - await removeSessionExtension(sessionId, extensionName); - } else { - await agentRemoveExtension({ - body: { session_id: sessionId, name: extensionName }, - throwOnError: true, - }); - } + await removeSessionExtension(sessionId, extensionName); + if (showToast) { toastService.dismiss(toastId); toastService.success({ diff --git a/ui/desktop/src/hooks/useAcpChatSession.ts b/ui/desktop/src/hooks/useAcpChatSession.ts deleted file mode 100644 index ebc646151..000000000 --- a/ui/desktop/src/hooks/useAcpChatSession.ts +++ /dev/null @@ -1,340 +0,0 @@ -import { useCallback, useEffect, useMemo, useRef } from 'react'; -import { defineMessages, useIntl } from '../i18n'; -import { AppEvents } from '../constants/events'; -import { ChatState } from '../types/chatState'; - -import { Message, Session, TokenState } from '../api'; - -import { createUserMessage, NotificationEvent, UserInput } from '../types/message'; -import { errorMessage } from '../utils/conversionUtils'; -import type { UseChatSessionParams, UseChatSessionResult } from './useChatSessionTypes'; -import { resolveAcpElicitationRequest } from '../acp/elicitationRequests'; -import { acpChatSessionController } from '../acp/chatSessionController'; -import { - acpChatSessionActions, - acpChatSessionStore, - useAcpChatSessionSnapshot, -} from '../acp/chatSessionStore'; -import { acpSteerSession } from '../acp/prompt'; - -const initialTokenState: TokenState = { - inputTokens: 0, - outputTokens: 0, - totalTokens: 0, - accumulatedInputTokens: 0, - accumulatedOutputTokens: 0, - accumulatedTotalTokens: 0, -}; - -function isClearCommand(message: string): boolean { - return message.trim() === '/clear'; -} - -function isSlashCommand(message: string): boolean { - return message.trim().startsWith('/'); -} - -const i18n = defineMessages({ - notificationTitle: { - id: 'chat.notification.taskComplete.title', - defaultMessage: 'Goose finished the task.', - }, - notificationBody: { - id: 'chat.notification.taskComplete.body', - defaultMessage: 'Click here to bring Goose back into focus.', - }, -}); - -export function useAcpChatSession({ - sessionId, - onStreamFinish, - onSessionLoaded, -}: UseChatSessionParams): UseChatSessionResult { - const intl = useIntl(); - const acpSnapshot = useAcpChatSessionSnapshot(sessionId); - const messages = acpSnapshot?.messages ?? []; - const session = acpSnapshot?.session; - const chatState = acpSnapshot?.chatState ?? ChatState.LoadingConversation; - const sessionLoadError = acpSnapshot?.sessionLoadError; - const tokenState = acpSnapshot?.tokenState ?? initialTokenState; - const queueProcessingBlocked = acpSnapshot?.pendingCancelPromptAttemptId != null; - - const snapshotRef = useRef(acpSnapshot); - snapshotRef.current = acpSnapshot; - - const getCurrentSnapshot = useCallback( - () => snapshotRef.current ?? acpChatSessionStore.getSnapshot(sessionId), - [sessionId] - ); - - useEffect(() => { - const handleSessionRenamed = (event: Event) => { - const { - sessionId: renamedSessionId, - newName, - userInitiated, - } = (event as CustomEvent<{ sessionId: string; newName: string; userInitiated?: boolean }>) - .detail; - - if (renamedSessionId !== sessionId) { - return; - } - - const currentSession = getCurrentSnapshot()?.session; - if (!currentSession || (currentSession.name === newName && !userInitiated)) { - return; - } - - const updatedSession = { - ...currentSession, - name: newName, - ...(userInitiated && { user_set_name: true }), - }; - acpChatSessionActions.setSessionMetadata(sessionId, updatedSession); - }; - - window.addEventListener(AppEvents.SESSION_RENAMED, handleSessionRenamed); - return () => window.removeEventListener(AppEvents.SESSION_RENAMED, handleSessionRenamed); - }, [getCurrentSnapshot, sessionId]); - - const onFinish = useCallback( - async (error?: string): Promise => { - if (!error) { - try { - const [notificationsEnabled, anyWindowFocused] = await Promise.all([ - window.electron.getSetting('enableNotifications'), - window.electron.isAnyWindowFocused(), - ]); - if (notificationsEnabled === true && !anyWindowFocused) { - window.electron.showNotification({ - title: intl.formatMessage(i18n.notificationTitle), - body: intl.formatMessage(i18n.notificationBody), - }); - } - } catch (notifyError) { - console.warn('Failed to show task completion notification:', notifyError); - } - } - - onStreamFinish(); - }, - [intl, onStreamFinish] - ); - - const submitToAcpSession = useCallback( - async (targetSessionId: string, userMessage: Message) => { - await acpChatSessionController.submitMessage(targetSessionId, userMessage, { - getCurrentSnapshot: () => - targetSessionId === sessionId - ? getCurrentSnapshot() - : acpChatSessionStore.getSnapshot(targetSessionId), - onFinish, - }); - }, - [getCurrentSnapshot, onFinish, sessionId] - ); - - // Load session on mount or sessionId change - useEffect(() => { - if (!sessionId) return; - - void acpChatSessionController.loadSession(sessionId, { onSessionLoaded }); - }, [sessionId, onSessionLoaded]); - - const handleSubmit = useCallback( - async (input: UserInput) => { - const { msg: userMessage, images } = input; - const currentSnapshot = getCurrentSnapshot(); - - if ( - !currentSnapshot?.session || - currentSnapshot.chatState === ChatState.LoadingConversation || - currentSnapshot.chatState === ChatState.Streaming || - currentSnapshot.chatState === ChatState.Thinking || - currentSnapshot.chatState === ChatState.Compacting || - currentSnapshot.pendingCancelPromptAttemptId !== null - ) { - return; - } - - const currentMessages = currentSnapshot.messages; - const hasExistingMessages = currentMessages.length > 0; - const hasNewMessage = userMessage.trim().length > 0 || images.length > 0; - const clearsConversation = hasNewMessage && isClearCommand(userMessage); - - if (!hasNewMessage && !hasExistingMessages) { - return; - } - - // Emit session-created event for first message in a new session - if (!hasExistingMessages && hasNewMessage) { - window.dispatchEvent(new CustomEvent(AppEvents.SESSION_CREATED)); - } - - const newMessage = hasNewMessage - ? createUserMessage(userMessage, images) - : currentMessages[currentMessages.length - 1]; - const messagesForStore = clearsConversation - ? [] - : hasNewMessage - ? [...currentMessages, newMessage] - : [...currentMessages]; - - if (clearsConversation || hasNewMessage) { - acpChatSessionActions.setMessages(sessionId, messagesForStore); - } - - await submitToAcpSession(sessionId, newMessage); - }, - [getCurrentSnapshot, sessionId, submitToAcpSession] - ); - - const onSteerQueuedMessage = useCallback( - async (input: UserInput): Promise => { - const { msg: userMessage, images } = input; - const hasTextContent = userMessage.trim().length > 0; - const hasNewMessage = hasTextContent || images.length > 0; - if (!hasNewMessage) { - return false; - } - - // ACP confirms picked-up steers with user text chunks; image-only steers cannot confirm pickup. - if (!hasTextContent) { - return false; - } - - if (isSlashCommand(userMessage)) { - return false; - } - - const activeRunId = - acpChatSessionStore.getSnapshot(sessionId)?.activeRunId ?? - getCurrentSnapshot()?.activeRunId; - if (!activeRunId) { - return false; - } - - try { - const steeredMessage = createUserMessage(userMessage, images); - const response = await acpSteerSession(sessionId, steeredMessage, activeRunId); - const localSteerMessage: Message = { - ...steeredMessage, - id: response.messageId, - metadata: { ...steeredMessage.metadata, steer: true }, - }; - const latestSnapshot = acpChatSessionStore.getSnapshot(sessionId) ?? getCurrentSnapshot(); - if (latestSnapshot?.activeRunId !== activeRunId) { - return false; - } - - const currentMessages = latestSnapshot.messages; - - if (!currentMessages.some((message) => message.id === response.messageId)) { - acpChatSessionActions.addPendingLocalSteerMessage(sessionId, localSteerMessage); - } - - return true; - } catch (error) { - console.warn('Failed to steer ACP session:', error); - return false; - } - }, - [getCurrentSnapshot, sessionId] - ); - - const submitElicitationResponse = useCallback( - async (elicitationId: string, userData: Record) => { - const currentSnapshot = getCurrentSnapshot(); - - if ( - !currentSnapshot?.session || - currentSnapshot.chatState === ChatState.LoadingConversation - ) { - return false; - } - - if (!resolveAcpElicitationRequest(sessionId, elicitationId, userData)) { - console.error('No pending ACP elicitation request found', { sessionId, elicitationId }); - return false; - } - - return true; - }, - [getCurrentSnapshot, sessionId] - ); - - const setRecipeUserParams = useCallback((_userRecipeValues: Record) => { - return Promise.reject(new Error('ACP recipe parameters are handled during session creation')); - }, []); - - const stopStreaming = useCallback(() => { - acpChatSessionController.stop(sessionId); - }, [sessionId]); - - const onMessageUpdate = useCallback( - async (messageId: string, newContent: string, editType: 'fork' | 'edit' = 'fork') => { - try { - await acpChatSessionController.updateMessage(sessionId, messageId, newContent, editType, { - getCurrentSnapshot, - onFinish, - }); - } catch (error) { - const errorMsg = errorMessage(error); - console.error('Failed to edit message:', error); - const { toastError } = await import('../toasts'); - toastError({ - title: 'Failed to edit message', - msg: errorMsg, - }); - } - }, - [getCurrentSnapshot, onFinish, sessionId] - ); - - const setChatState = useCallback( - (newState: ChatState) => { - acpChatSessionActions.setChatState(sessionId, newState); - }, - [sessionId] - ); - - const updateSession = useCallback( - (updater: (session: Session) => Session) => { - const currentSession = getCurrentSnapshot()?.session; - if (!currentSession) return; - - const nextSession = updater(currentSession); - acpChatSessionActions.setSessionMetadata(sessionId, nextSession); - }, - [getCurrentSnapshot, sessionId] - ); - const notificationsMap = useMemo(() => { - return (acpSnapshot?.notifications ?? []).reduce((map, notification) => { - const key = notification.request_id; - if (!map.has(key)) { - map.set(key, []); - } - map.get(key)!.push(notification); - return map; - }, new Map()); - }, [acpSnapshot?.notifications]); - - return { - sessionLoadError, - messages, - session, - chatState, - setChatState, - updateSession, - handleSubmit, - onSteerQueuedMessage, - submitElicitationResponse, - stopStreaming, - setRecipeUserParams, - tokenState, - notifications: notificationsMap, - pauseQueueOnStop: false, - queueProcessingBlocked, - onMessageUpdate, - }; -} diff --git a/ui/desktop/src/hooks/useChatSession.ts b/ui/desktop/src/hooks/useChatSession.ts index f1f8ba1ca..1b1fc1537 100644 --- a/ui/desktop/src/hooks/useChatSession.ts +++ b/ui/desktop/src/hooks/useChatSession.ts @@ -1,8 +1,327 @@ -import { USE_ACP_CHAT } from '../acpChatFeatureFlag'; -import { useAcpChatSession } from './useAcpChatSession'; -import { useChatStream } from './useChatStream'; -import type { UseChatSessionHook } from './useChatSessionTypes'; +import { useCallback, useEffect, useMemo, useRef } from 'react'; +import { defineMessages, useIntl } from '../i18n'; +import { AppEvents } from '../constants/events'; +import { ChatState } from '../types/chatState'; -export const useChatSession: UseChatSessionHook = USE_ACP_CHAT - ? useAcpChatSession - : useChatStream; +import { Message, Session, TokenState } from '../api'; + +import { createUserMessage, NotificationEvent, UserInput } from '../types/message'; +import { errorMessage } from '../utils/conversionUtils'; +import type { UseChatSessionParams, UseChatSessionResult } from './useChatSessionTypes'; +import { resolveAcpElicitationRequest } from '../acp/elicitationRequests'; +import { acpChatSessionController } from '../acp/chatSessionController'; +import { + acpChatSessionActions, + acpChatSessionStore, + useAcpChatSessionSnapshot, +} from '../acp/chatSessionStore'; +import { acpSteerSession } from '../acp/prompt'; + +const initialTokenState: TokenState = { + inputTokens: 0, + outputTokens: 0, + totalTokens: 0, + accumulatedInputTokens: 0, + accumulatedOutputTokens: 0, + accumulatedTotalTokens: 0, +}; + +function isClearCommand(message: string): boolean { + return message.trim() === '/clear'; +} + +function isSlashCommand(message: string): boolean { + return message.trim().startsWith('/'); +} + +const i18n = defineMessages({ + notificationTitle: { + id: 'chat.notification.taskComplete.title', + defaultMessage: 'Goose finished the task.', + }, + notificationBody: { + id: 'chat.notification.taskComplete.body', + defaultMessage: 'Click here to bring Goose back into focus.', + }, +}); + +export function useChatSession({ + sessionId, + onStreamFinish, + onSessionLoaded, +}: UseChatSessionParams): UseChatSessionResult { + const intl = useIntl(); + const acpSnapshot = useAcpChatSessionSnapshot(sessionId); + const messages = acpSnapshot?.messages ?? []; + const session = acpSnapshot?.session; + const chatState = acpSnapshot?.chatState ?? ChatState.LoadingConversation; + const sessionLoadError = acpSnapshot?.sessionLoadError; + const tokenState = acpSnapshot?.tokenState ?? initialTokenState; + const queueProcessingBlocked = acpSnapshot?.pendingCancelPromptAttemptId != null; + + const snapshotRef = useRef(acpSnapshot); + snapshotRef.current = acpSnapshot; + + const getCurrentSnapshot = useCallback( + () => snapshotRef.current ?? acpChatSessionStore.getSnapshot(sessionId), + [sessionId] + ); + + useEffect(() => { + const handleSessionRenamed = (event: Event) => { + const { + sessionId: renamedSessionId, + newName, + userInitiated, + } = (event as CustomEvent<{ sessionId: string; newName: string; userInitiated?: boolean }>) + .detail; + + if (renamedSessionId !== sessionId) { + return; + } + + const currentSession = getCurrentSnapshot()?.session; + if (!currentSession || (currentSession.name === newName && !userInitiated)) { + return; + } + + const updatedSession = { + ...currentSession, + name: newName, + ...(userInitiated && { user_set_name: true }), + }; + acpChatSessionActions.setSessionMetadata(sessionId, updatedSession); + }; + + window.addEventListener(AppEvents.SESSION_RENAMED, handleSessionRenamed); + return () => window.removeEventListener(AppEvents.SESSION_RENAMED, handleSessionRenamed); + }, [getCurrentSnapshot, sessionId]); + + const onFinish = useCallback( + async (error?: string): Promise => { + if (!error) { + try { + const [notificationsEnabled, anyWindowFocused] = await Promise.all([ + window.electron.getSetting('enableNotifications'), + window.electron.isAnyWindowFocused(), + ]); + if (notificationsEnabled === true && !anyWindowFocused) { + window.electron.showNotification({ + title: intl.formatMessage(i18n.notificationTitle), + body: intl.formatMessage(i18n.notificationBody), + }); + } + } catch (notifyError) { + console.warn('Failed to show task completion notification:', notifyError); + } + } + + onStreamFinish(); + }, + [intl, onStreamFinish] + ); + + const submitToAcpSession = useCallback( + async (targetSessionId: string, userMessage: Message) => { + await acpChatSessionController.submitMessage(targetSessionId, userMessage, { + getCurrentSnapshot: () => + targetSessionId === sessionId + ? getCurrentSnapshot() + : acpChatSessionStore.getSnapshot(targetSessionId), + onFinish, + }); + }, + [getCurrentSnapshot, onFinish, sessionId] + ); + + // Load session on mount or sessionId change + useEffect(() => { + if (!sessionId) return; + + void acpChatSessionController.loadSession(sessionId, { onSessionLoaded }); + }, [sessionId, onSessionLoaded]); + + const handleSubmit = useCallback( + async (input: UserInput) => { + const { msg: userMessage, images } = input; + const currentSnapshot = getCurrentSnapshot(); + + if ( + !currentSnapshot?.session || + currentSnapshot.chatState === ChatState.LoadingConversation || + currentSnapshot.chatState === ChatState.Streaming || + currentSnapshot.chatState === ChatState.Thinking || + currentSnapshot.chatState === ChatState.Compacting || + currentSnapshot.pendingCancelPromptAttemptId !== null + ) { + return; + } + + const currentMessages = currentSnapshot.messages; + const hasExistingMessages = currentMessages.length > 0; + const hasNewMessage = userMessage.trim().length > 0 || images.length > 0; + const clearsConversation = hasNewMessage && isClearCommand(userMessage); + + if (!hasNewMessage && !hasExistingMessages) { + return; + } + + // Emit session-created event for first message in a new session + if (!hasExistingMessages && hasNewMessage) { + window.dispatchEvent(new CustomEvent(AppEvents.SESSION_CREATED)); + } + + const newMessage = hasNewMessage + ? createUserMessage(userMessage, images) + : currentMessages[currentMessages.length - 1]; + const messagesForStore = clearsConversation + ? [] + : hasNewMessage + ? [...currentMessages, newMessage] + : [...currentMessages]; + + if (clearsConversation || hasNewMessage) { + acpChatSessionActions.setMessages(sessionId, messagesForStore); + } + + await submitToAcpSession(sessionId, newMessage); + }, + [getCurrentSnapshot, sessionId, submitToAcpSession] + ); + + const onSteerQueuedMessage = useCallback( + async (input: UserInput): Promise => { + const { msg: userMessage, images } = input; + const hasTextContent = userMessage.trim().length > 0; + const hasNewMessage = hasTextContent || images.length > 0; + if (!hasNewMessage) { + return false; + } + + // ACP confirms picked-up steers with user text chunks; image-only steers cannot confirm pickup. + if (!hasTextContent) { + return false; + } + + if (isSlashCommand(userMessage)) { + return false; + } + + const activeRunId = + acpChatSessionStore.getSnapshot(sessionId)?.activeRunId ?? + getCurrentSnapshot()?.activeRunId; + if (!activeRunId) { + return false; + } + + try { + const steeredMessage = createUserMessage(userMessage, images); + const response = await acpSteerSession(sessionId, steeredMessage, activeRunId); + const localSteerMessage: Message = { + ...steeredMessage, + id: response.messageId, + metadata: { ...steeredMessage.metadata, steer: true }, + }; + const latestSnapshot = acpChatSessionStore.getSnapshot(sessionId) ?? getCurrentSnapshot(); + if (latestSnapshot?.activeRunId !== activeRunId) { + return false; + } + + const currentMessages = latestSnapshot.messages; + + if (!currentMessages.some((message) => message.id === response.messageId)) { + acpChatSessionActions.addPendingLocalSteerMessage(sessionId, localSteerMessage); + } + + return true; + } catch (error) { + console.warn('Failed to steer ACP session:', error); + return false; + } + }, + [getCurrentSnapshot, sessionId] + ); + + const submitElicitationResponse = useCallback( + async (elicitationId: string, userData: Record) => { + const currentSnapshot = getCurrentSnapshot(); + + if ( + !currentSnapshot?.session || + currentSnapshot.chatState === ChatState.LoadingConversation + ) { + return false; + } + + if (!resolveAcpElicitationRequest(sessionId, elicitationId, userData)) { + console.error('No pending ACP elicitation request found', { sessionId, elicitationId }); + return false; + } + + return true; + }, + [getCurrentSnapshot, sessionId] + ); + + const stopStreaming = useCallback(() => { + acpChatSessionController.stop(sessionId); + }, [sessionId]); + + const onMessageUpdate = useCallback( + async (messageId: string, newContent: string, editType: 'fork' | 'edit' = 'fork') => { + try { + await acpChatSessionController.updateMessage(sessionId, messageId, newContent, editType, { + getCurrentSnapshot, + onFinish, + }); + } catch (error) { + const errorMsg = errorMessage(error); + console.error('Failed to edit message:', error); + const { toastError } = await import('../toasts'); + toastError({ + title: 'Failed to edit message', + msg: errorMsg, + }); + } + }, + [getCurrentSnapshot, onFinish, sessionId] + ); + + const updateSession = useCallback( + (updater: (session: Session) => Session) => { + const currentSession = getCurrentSnapshot()?.session; + if (!currentSession) return; + + const nextSession = updater(currentSession); + acpChatSessionActions.setSessionMetadata(sessionId, nextSession); + }, + [getCurrentSnapshot, sessionId] + ); + const notificationsMap = useMemo(() => { + return (acpSnapshot?.notifications ?? []).reduce((map, notification) => { + const key = notification.request_id; + if (!map.has(key)) { + map.set(key, []); + } + map.get(key)!.push(notification); + return map; + }, new Map()); + }, [acpSnapshot?.notifications]); + + return { + sessionLoadError, + messages, + session, + chatState, + updateSession, + handleSubmit, + onSteerQueuedMessage, + submitElicitationResponse, + stopStreaming, + tokenState, + notifications: notificationsMap, + pauseQueueOnStop: false, + queueProcessingBlocked, + onMessageUpdate, + }; +} diff --git a/ui/desktop/src/hooks/useChatSessionTypes.ts b/ui/desktop/src/hooks/useChatSessionTypes.ts index 05c7e08a2..c40470497 100644 --- a/ui/desktop/src/hooks/useChatSessionTypes.ts +++ b/ui/desktop/src/hooks/useChatSessionTypes.ts @@ -12,7 +12,6 @@ export interface UseChatSessionResult { session?: Session; messages: Message[]; chatState: ChatState; - setChatState: (state: ChatState) => void; updateSession: (updater: (session: Session) => Session) => void; handleSubmit: (input: UserInput) => Promise; onSteerQueuedMessage?: (input: UserInput) => Promise; @@ -20,7 +19,6 @@ export interface UseChatSessionResult { elicitationId: string, userData: Record ) => Promise; - setRecipeUserParams: (values: Record) => Promise; stopStreaming: () => void; sessionLoadError?: string; tokenState: TokenState; @@ -33,5 +31,3 @@ export interface UseChatSessionResult { editType?: 'fork' | 'edit' ) => Promise; } - -export type UseChatSessionHook = (params: UseChatSessionParams) => UseChatSessionResult; diff --git a/ui/desktop/src/hooks/useChatStream.ts b/ui/desktop/src/hooks/useChatStream.ts deleted file mode 100644 index e6e2b9b61..000000000 --- a/ui/desktop/src/hooks/useChatStream.ts +++ /dev/null @@ -1,1183 +0,0 @@ -import { useCallback, useEffect, useMemo, useReducer, useRef } from 'react'; -import { defineMessages, useIntl } from '../i18n'; -import { v7 as uuidv7 } from 'uuid'; -import { AppEvents } from '../constants/events'; -import { ChatState } from '../types/chatState'; - -import { - getSession, - Message, - resumeAgent, - Session, - sessionCancel, - sessionReply, - TokenState, - updateFromSession, - updateSessionUserRecipeValues, -} from '../api'; -import { listMcpApps } from '../acp/mcp-apps'; - -import { - createUserMessage, - createElicitationResponseMessage, - getCompactingMessage, - getThinkingMessage, - NotificationEvent, - UserInput, -} from '../types/message'; -import { errorMessage } from '../utils/conversionUtils'; -import { showExtensionLoadResults } from '../utils/extensionErrorUtils'; -import { maybeHandlePlatformEvent } from '../utils/platform_events'; -import { useSessionEvents, type SessionEvent } from './useSessionEvents'; -import type { UseChatSessionParams, UseChatSessionResult } from './useChatSessionTypes'; - -const resultsCache = new Map(); - -export function clearSessionCache(sessionId: string): void { - resultsCache.delete(sessionId); -} - -interface StreamState { - messages: Message[]; - session: Session | undefined; - chatState: ChatState; - sessionLoadError: string | undefined; - tokenState: TokenState; - notifications: NotificationEvent[]; -} - -type StreamAction = - | { type: 'SET_MESSAGES'; payload: Message[] } - | { type: 'SET_SESSION'; payload: Session | undefined } - | { type: 'SET_CHAT_STATE'; payload: ChatState } - | { type: 'SET_SESSION_LOAD_ERROR'; payload: string | undefined } - | { type: 'SET_TOKEN_STATE'; payload: TokenState } - | { type: 'ADD_NOTIFICATION'; payload: NotificationEvent } - | { type: 'CLEAR_NOTIFICATIONS' } - | { - type: 'SESSION_LOADED'; - payload: { - session: Session; - messages: Message[]; - tokenState: TokenState; - }; - } - | { type: 'RESET_FOR_NEW_SESSION' } - | { type: 'START_STREAMING' } - | { type: 'STREAM_ERROR'; payload: string } - | { type: 'STREAM_FINISH'; payload?: string }; - -const initialTokenState: TokenState = { - inputTokens: 0, - outputTokens: 0, - totalTokens: 0, - accumulatedInputTokens: 0, - accumulatedOutputTokens: 0, - accumulatedTotalTokens: 0, -}; - -const initialState: StreamState = { - messages: [], - session: undefined, - chatState: ChatState.Idle, - sessionLoadError: undefined, - tokenState: initialTokenState, - notifications: [], -}; - -function streamReducer(state: StreamState, action: StreamAction): StreamState { - switch (action.type) { - case 'SET_MESSAGES': - return { ...state, messages: action.payload }; - - case 'SET_SESSION': - return { ...state, session: action.payload }; - - case 'SET_CHAT_STATE': - return { ...state, chatState: action.payload }; - - case 'SET_SESSION_LOAD_ERROR': - return { ...state, sessionLoadError: action.payload }; - - case 'SET_TOKEN_STATE': - return { ...state, tokenState: action.payload }; - - case 'ADD_NOTIFICATION': - return { ...state, notifications: [...state.notifications, action.payload] }; - - case 'CLEAR_NOTIFICATIONS': - return { ...state, notifications: [] }; - - case 'SESSION_LOADED': - return { - ...state, - session: action.payload.session, - messages: action.payload.messages, - tokenState: action.payload.tokenState, - chatState: ChatState.Idle, - sessionLoadError: undefined, - }; - - case 'RESET_FOR_NEW_SESSION': - return { - ...state, - messages: [], - session: undefined, - sessionLoadError: undefined, - chatState: ChatState.LoadingConversation, - }; - - case 'START_STREAMING': - return { - ...state, - chatState: ChatState.Streaming, - notifications: [], - }; - - case 'STREAM_ERROR': - return { - ...state, - sessionLoadError: action.payload, - chatState: ChatState.Idle, - }; - - case 'STREAM_FINISH': - return { - ...state, - sessionLoadError: action.payload, - chatState: ChatState.Idle, - }; - - default: - return state; - } -} - -function pushMessage(currentMessages: Message[], incomingMsg: Message): Message[] { - const lastMsg = currentMessages[currentMessages.length - 1]; - - if (lastMsg?.id && lastMsg.id === incomingMsg.id) { - const lastContent = lastMsg.content[lastMsg.content.length - 1]; - const newContent = incomingMsg.content[incomingMsg.content.length - 1]; - - if (incomingMsg.metadata?.inference) { - lastMsg.metadata = { - ...lastMsg.metadata, - inference: incomingMsg.metadata.inference, - }; - } - - if ( - lastContent?.type === 'text' && - newContent?.type === 'text' && - incomingMsg.content.length === 1 - ) { - lastContent.text += newContent.text; - } else if ( - lastContent?.type === 'thinking' && - newContent?.type === 'thinking' && - incomingMsg.content.length === 1 && - 'thinking' in lastContent && - 'thinking' in newContent - ) { - // For thinking blocks: if the new block has a signature, it's the complete - // block from content_block_stop — replace entirely. Otherwise append the delta. - if ('signature' in newContent && newContent.signature) { - lastContent.thinking = newContent.thinking; - lastContent.signature = newContent.signature; - } else { - lastContent.thinking += newContent.thinking; - } - } else { - lastMsg.content.push(...incomingMsg.content); - } - return [...currentMessages]; - } else { - return [...currentMessages, incomingMsg]; - } -} - -function prefersReducedMotion(): boolean { - return window.matchMedia('(prefers-reduced-motion: reduce)').matches; -} - -const REDUCED_MOTION_BATCH_INTERVAL = 1000; - -/** - * Creates an event processor that handles individual SSE events for a request. - * Returns an unsubscribe function and a handler to process events. - */ -function createEventProcessor( - initialMessages: Message[], - dispatch: React.Dispatch, - onFinish: (error?: string) => void, - sessionId: string, - onReloadNeeded?: () => void -) { - let currentMessages = initialMessages; - const reduceMotion = prefersReducedMotion(); - let latestTokenState: TokenState | null = null; - let latestChatState: ChatState = ChatState.Streaming; - let lastBatchUpdate = Date.now(); - let hasPendingUpdate = false; - let pendingInference: Message['metadata']['inference'] | undefined; - - const flushBatchedUpdates = () => { - if (reduceMotion && hasPendingUpdate) { - if (latestTokenState) { - dispatch({ type: 'SET_TOKEN_STATE', payload: latestTokenState }); - } - dispatch({ type: 'SET_MESSAGES', payload: currentMessages }); - dispatch({ type: 'SET_CHAT_STATE', payload: latestChatState }); - hasPendingUpdate = false; - lastBatchUpdate = Date.now(); - } - }; - - const maybeUpdateUI = (tokenState: TokenState, chatState: ChatState, forceImmediate = false) => { - if (!reduceMotion) { - dispatch({ type: 'SET_TOKEN_STATE', payload: tokenState }); - dispatch({ type: 'SET_MESSAGES', payload: currentMessages }); - dispatch({ type: 'SET_CHAT_STATE', payload: chatState }); - } else if (forceImmediate) { - dispatch({ type: 'SET_TOKEN_STATE', payload: tokenState }); - dispatch({ type: 'SET_MESSAGES', payload: currentMessages }); - dispatch({ type: 'SET_CHAT_STATE', payload: chatState }); - hasPendingUpdate = false; - lastBatchUpdate = Date.now(); - } else { - latestTokenState = tokenState; - latestChatState = chatState; - hasPendingUpdate = true; - const now = Date.now(); - if (now - lastBatchUpdate >= REDUCED_MOTION_BATCH_INTERVAL) { - flushBatchedUpdates(); - } - } - }; - - const flushPendingInference = () => { - if (!pendingInference) { - return; - } - - for (let i = currentMessages.length - 1; i >= 0; i--) { - const message = currentMessages[i]; - if (message.role === 'assistant' && message.metadata.userVisible) { - currentMessages = [ - ...currentMessages.slice(0, i), - { - ...message, - metadata: { - ...message.metadata, - inference: message.metadata.inference ?? pendingInference, - }, - }, - ...currentMessages.slice(i + 1), - ]; - break; - } - } - pendingInference = undefined; - }; - - // Returns true if the event is terminal (Finish or Error) - const processEvent = (event: SessionEvent): boolean => { - switch (event.type) { - case 'Message': { - let msg = (event as Record).message as Message; - const tokenState = (event as Record).token_state as TokenState; - - if (msg.content.length === 0 && msg.metadata?.inference) { - pendingInference = msg.metadata.inference; - return false; - } - - if (pendingInference && msg.role === 'assistant' && msg.metadata.userVisible) { - msg = { - ...msg, - metadata: { - ...msg.metadata, - inference: msg.metadata.inference ?? pendingInference, - }, - }; - pendingInference = undefined; - } - - currentMessages = pushMessage(currentMessages, msg); - - const hasToolConfirmation = msg.content.some( - (content) => - content.type === 'actionRequired' && content.data.actionType === 'toolConfirmation' - ); - - const hasElicitation = msg.content.some( - (content) => - content.type === 'actionRequired' && content.data.actionType === 'elicitation' - ); - - if (hasToolConfirmation || hasElicitation) { - maybeUpdateUI(tokenState, ChatState.WaitingForUserInput, true); - } else if (getCompactingMessage(msg)) { - maybeUpdateUI(tokenState, ChatState.Compacting); - } else if (getThinkingMessage(msg)) { - maybeUpdateUI(tokenState, ChatState.Thinking); - } else { - maybeUpdateUI(tokenState, ChatState.Streaming); - } - return false; - } - case 'Error': { - flushPendingInference(); - flushBatchedUpdates(); - dispatch({ type: 'SET_MESSAGES', payload: currentMessages }); - const errorMsg = String((event as Record).error ?? ''); - if (errorMsg.includes('too far behind') && onReloadNeeded) { - // Server indicated we missed events — end streaming without setting - // an error (which would show a blocking error screen), then reload - // the full conversation so the UI reflects the actual state. - onFinish(); - onReloadNeeded(); - } else { - onFinish('Stream error: ' + errorMsg); - } - return true; - } - case 'Finish': { - flushPendingInference(); - flushBatchedUpdates(); - dispatch({ type: 'SET_MESSAGES', payload: currentMessages }); - onFinish(); - return true; - } - case 'UpdateConversation': { - const conversation = (event as Record).conversation as Message[]; - currentMessages = conversation; - if (!reduceMotion) { - dispatch({ type: 'SET_MESSAGES', payload: conversation }); - } else { - hasPendingUpdate = true; - } - return false; - } - case 'Notification': { - dispatch({ type: 'ADD_NOTIFICATION', payload: event as unknown as NotificationEvent }); - maybeHandlePlatformEvent((event as Record).message, sessionId); - return false; - } - case 'Ping': - return false; - default: - return false; - } - }; - - return processEvent; -} - -const i18n = defineMessages({ - notificationTitle: { - id: 'chat.notification.taskComplete.title', - defaultMessage: 'Goose finished the task.', - }, - notificationBody: { - id: 'chat.notification.taskComplete.body', - defaultMessage: 'Click here to bring Goose back into focus.', - }, -}); - -export function useChatStream({ - sessionId, - onStreamFinish, - onSessionLoaded, -}: UseChatSessionParams): UseChatSessionResult { - const intl = useIntl(); - const [state, dispatch] = useReducer(streamReducer, initialState); - - // Long-lived SSE connection for this session - const { addListener, setActiveRequestsHandler } = useSessionEvents(sessionId); - - // Track the active request for cancellation (includes the session that started it) - const activeRequestIdRef = useRef(null); - const activeRequestSessionIdRef = useRef(null); - const activeAbortRef = useRef(null); - const activeUnsubscribeRef = useRef<(() => void) | null>(null); - // When ActiveRequests fires before resumeAgent populates messages (cold mount), - // defer the reattach until the session is loaded so the event processor has - // the full conversation history. Events are buffered in the meantime. - const pendingReattachRequestIdRef = useRef(null); - const pendingReattachBufferRef = useRef([]); - const namePollingRef = useRef | null>(null); - - // Ref to access latest state in callbacks (avoids stale closures) - const stateRef = useRef(state); - stateRef.current = state; - const doReattachRef = useRef<((requestId: string, messages: Message[]) => void) | null>(null); - - useEffect(() => { - return () => { - if (namePollingRef.current) { - clearTimeout(namePollingRef.current); - namePollingRef.current = null; - } - }; - }, [sessionId]); - - useEffect(() => { - if (state.session) { - resultsCache.set(sessionId, { session: state.session, messages: state.messages }); - } - }, [sessionId, state.session, state.messages]); - - const onFinish = useCallback( - async (error?: string): Promise => { - // Note: SSE listener/ref cleanup is handled by the terminal-event - // handler in each listener closure (which guards on requestId) so - // that overlapping requests don't clobber each other's state. - - if (namePollingRef.current) { - clearTimeout(namePollingRef.current); - namePollingRef.current = null; - } - - dispatch({ type: 'STREAM_FINISH', payload: error }); - - if (!error) { - try { - const [notificationsEnabled, anyWindowFocused] = await Promise.all([ - window.electron.getSetting('enableNotifications'), - window.electron.isAnyWindowFocused(), - ]); - if (notificationsEnabled === true && !anyWindowFocused) { - window.electron.showNotification({ - title: intl.formatMessage(i18n.notificationTitle), - body: intl.formatMessage(i18n.notificationBody), - }); - } - } catch (notifyError) { - console.warn('Failed to show task completion notification:', notifyError); - } - } - - const isNewSession = sessionId && sessionId.match(/^\d{8}_\d{6}$/); - if (isNewSession) { - window.dispatchEvent(new CustomEvent(AppEvents.MESSAGE_STREAM_FINISHED)); - } - - // Refresh session name after each reply for the first 3 user messages - if (!error && sessionId) { - const currentState = stateRef.current; - const userMessageCount = currentState.messages.filter((m) => m.role === 'user').length; - - if (userMessageCount <= 3) { - try { - const response = await getSession({ - path: { session_id: sessionId }, - throwOnError: true, - }); - if (response.data?.name) { - const nextMessageCount = - response.data.message_count ?? currentState.session?.message_count; - dispatch({ - type: 'SET_SESSION', - payload: currentState.session - ? { - ...currentState.session, - name: response.data.name, - ...(nextMessageCount !== undefined && { message_count: nextMessageCount }), - } - : undefined, - }); - window.dispatchEvent( - new CustomEvent(AppEvents.SESSION_RENAMED, { - detail: { sessionId, newName: response.data.name }, - }) - ); - } - } catch (refreshError) { - console.warn('Failed to refresh session name:', refreshError); - } - } - } - - onStreamFinish(); - }, - [intl, onStreamFinish, sessionId] - ); - - // Reload the full conversation from the server, e.g. after the SSE - // stream indicates the client fell too far behind the replay buffer. - const reloadConversation = useCallback(() => { - getSession({ - path: { session_id: sessionId }, - throwOnError: true, - }) - .then((response) => { - const session = response.data as Session; - if (session?.conversation) { - dispatch({ type: 'SET_MESSAGES', payload: session.conversation }); - } - }) - .catch((e) => { - console.warn('Failed to reload conversation after buffer overflow:', e); - }); - }, [sessionId]); - - // Perform the actual reattach: wire up an event processor and listener - // for a request that is already in-flight on the server. - const doReattach = useCallback( - (requestId: string, messages: Message[]) => { - activeRequestIdRef.current = requestId; - activeRequestSessionIdRef.current = sessionId; - pendingReattachRequestIdRef.current = null; - - dispatch({ type: 'SET_CHAT_STATE', payload: ChatState.Streaming }); - dispatch({ type: 'SET_SESSION_LOAD_ERROR', payload: undefined }); - - const processEvent = createEventProcessor( - messages, - dispatch, - onFinish, - sessionId, - reloadConversation - ); - - // Replay any events that were buffered during cold-mount wait - const buffered = pendingReattachBufferRef.current; - pendingReattachBufferRef.current = []; - let finished = false; - for (const event of buffered) { - if (processEvent(event)) { - finished = true; - break; - } - } - - if (finished) { - // The reply already completed while we were waiting for session load. - // Clean up — the buffering listener will be replaced below but the - // old one captured into activeUnsubscribeRef should be removed. - if (activeUnsubscribeRef.current) { - activeUnsubscribeRef.current(); - activeUnsubscribeRef.current = null; - } - activeRequestIdRef.current = null; - activeRequestSessionIdRef.current = null; - return; - } - - // Replace the buffering listener with a real processing listener - if (activeUnsubscribeRef.current) { - activeUnsubscribeRef.current(); - } - const unsubscribe = addListener(requestId, (event) => { - const isTerminal = processEvent(event); - if (isTerminal) { - unsubscribe(); - if (activeRequestIdRef.current === requestId) { - activeUnsubscribeRef.current = null; - activeRequestIdRef.current = null; - activeRequestSessionIdRef.current = null; - } - } - }); - activeUnsubscribeRef.current = unsubscribe; - }, - [sessionId, addListener, onFinish, reloadConversation] - ); - doReattachRef.current = doReattach; - - // Reattach to in-flight replies discovered via the SSE ActiveRequests event. - // This handles the case where the chat view remounts while a reply is still - // running on the server — the new hook instance picks up the existing request - // and starts processing its events. - useEffect(() => { - setActiveRequestsHandler((requestIds: string[]) => { - // Only reattach if we don't already have an active request - if (activeRequestIdRef.current) return; - if (requestIds.length === 0) return; - - // Reattach to the first (most recent) active request. - // Multiple concurrent requests per session aren't supported in the UI. - const requestId = requestIds[0]; - const currentMessages = stateRef.current.messages; - - if (currentMessages.length === 0) { - // Cold mount: resumeAgent hasn't populated messages yet. - // Defer event processing until session load completes so the - // processor starts with the full conversation history. - // Register a buffering listener NOW so replayed events aren't - // lost while we wait. - pendingReattachRequestIdRef.current = requestId; - pendingReattachBufferRef.current = []; - activeRequestIdRef.current = requestId; - activeRequestSessionIdRef.current = sessionId; - dispatch({ type: 'SET_CHAT_STATE', payload: ChatState.Streaming }); - dispatch({ type: 'SET_SESSION_LOAD_ERROR', payload: undefined }); - - const unsubscribe = addListener(requestId, (event) => { - pendingReattachBufferRef.current.push(event); - }); - activeUnsubscribeRef.current = unsubscribe; - return; - } - - doReattach(requestId, currentMessages); - }); - - return () => { - setActiveRequestsHandler(null); - }; - }, [sessionId, addListener, onFinish, reloadConversation, setActiveRequestsHandler, doReattach]); - - /** - * Submit a message via the new POST+SSE pattern. - * 1. Generate request_id - * 2. Register SSE listener BEFORE POST (no race condition) - * 3. POST to /sessions/{id}/reply - * 4. Events arrive on the long-lived SSE connection - */ - const submitToSession = useCallback( - async ( - targetSessionId: string, - userMessage: Message, - currentMessages: Message[], - overrideConversation?: Message[] - ) => { - const requestId = uuidv7(); - const abortController = new AbortController(); - activeRequestIdRef.current = requestId; - activeRequestSessionIdRef.current = targetSessionId; - activeAbortRef.current = abortController; - - // Create event processor and register listener BEFORE the POST - const processEvent = createEventProcessor( - currentMessages, - dispatch, - onFinish, - targetSessionId, - reloadConversation - ); - - const unsubscribe = addListener(requestId, (event) => { - const isTerminal = processEvent(event); - if (isTerminal) { - unsubscribe(); - // Only clear global refs if this request is still the active one. - // A newer request may have already replaced them. - if (activeRequestIdRef.current === requestId) { - activeUnsubscribeRef.current = null; - activeRequestIdRef.current = null; - activeRequestSessionIdRef.current = null; - activeAbortRef.current = null; - } - } - }); - activeUnsubscribeRef.current = unsubscribe; - - try { - await sessionReply({ - path: { id: targetSessionId }, - body: { - request_id: requestId, - user_message: userMessage, - override_conversation: overrideConversation, - }, - signal: abortController.signal, - throwOnError: true, - }); - } catch (error) { - // Abort is expected when stopStreaming races with the POST - if (abortController.signal.aborted) return; - // POST failed — clean up listener and report error. - // Only clear global refs if this request is still the active one; - // a newer request may have already replaced them. - unsubscribe(); - if (activeRequestIdRef.current === requestId) { - activeUnsubscribeRef.current = null; - activeRequestIdRef.current = null; - activeRequestSessionIdRef.current = null; - activeAbortRef.current = null; - } - const msg = errorMessage(error); - if (msg.includes('already has an active request')) { - dispatch({ type: 'SET_CHAT_STATE', payload: ChatState.Idle }); - } else { - onFinish('Submit error: ' + msg); - } - } - }, - [addListener, onFinish, reloadConversation] - ); - - // Load session on mount or sessionId change - useEffect(() => { - if (!sessionId) return; - - const cached = resultsCache.get(sessionId); - if (cached) { - dispatch({ - type: 'SESSION_LOADED', - payload: { - session: cached.session, - messages: cached.messages, - tokenState: { - inputTokens: cached.session?.usage?.input_tokens ?? 0, - outputTokens: cached.session?.usage?.output_tokens ?? 0, - totalTokens: cached.session?.usage?.total_tokens ?? 0, - accumulatedInputTokens: cached.session?.accumulated_usage?.input_tokens ?? 0, - accumulatedOutputTokens: cached.session?.accumulated_usage?.output_tokens ?? 0, - accumulatedTotalTokens: cached.session?.accumulated_usage?.total_tokens ?? 0, - }, - }, - }); - window.dispatchEvent( - new CustomEvent(AppEvents.SESSION_EXTENSIONS_LOADED, { detail: { sessionId } }) - ); - onSessionLoaded?.(); - return; - } - - dispatch({ type: 'RESET_FOR_NEW_SESSION' }); - - let cancelled = false; - - (async () => { - try { - const response = await resumeAgent({ - body: { - session_id: sessionId, - load_model_and_extensions: true, - }, - throwOnError: true, - }); - - if (cancelled) { - return; - } - - const resumeData = response.data; - const loadedSession = resumeData?.session; - const extensionResults = resumeData?.extension_results; - - showExtensionLoadResults(extensionResults); - window.dispatchEvent( - new CustomEvent(AppEvents.SESSION_EXTENSIONS_LOADED, { detail: { sessionId } }) - ); - - const pendingRequestId = pendingReattachRequestIdRef.current; - const reattachedToActiveRequest = activeRequestIdRef.current !== null; - - if (pendingRequestId) { - // Cold-mount reattach: ActiveRequests arrived before resumeAgent - // returned. Load session state first, then complete the reattach - // with the full conversation so the event processor has context. - dispatch({ - type: 'SESSION_LOADED', - payload: { - session: loadedSession!, - messages: loadedSession?.conversation || [], - tokenState: { - inputTokens: loadedSession?.usage?.input_tokens ?? 0, - outputTokens: loadedSession?.usage?.output_tokens ?? 0, - totalTokens: loadedSession?.usage?.total_tokens ?? 0, - accumulatedInputTokens: loadedSession?.accumulated_usage?.input_tokens ?? 0, - accumulatedOutputTokens: loadedSession?.accumulated_usage?.output_tokens ?? 0, - accumulatedTotalTokens: loadedSession?.accumulated_usage?.total_tokens ?? 0, - }, - }, - }); - // Now complete the deferred reattach with the loaded messages - doReattachRef.current?.(pendingRequestId, loadedSession?.conversation || []); - } else if (reattachedToActiveRequest) { - // ActiveRequests already wired up an event processor with existing - // messages — only load session metadata, don't overwrite messages - // with the stale DB snapshot. - dispatch({ type: 'SET_SESSION', payload: loadedSession }); - dispatch({ - type: 'SET_TOKEN_STATE', - payload: { - inputTokens: loadedSession?.usage?.input_tokens ?? 0, - outputTokens: loadedSession?.usage?.output_tokens ?? 0, - totalTokens: loadedSession?.usage?.total_tokens ?? 0, - accumulatedInputTokens: loadedSession?.accumulated_usage?.input_tokens ?? 0, - accumulatedOutputTokens: loadedSession?.accumulated_usage?.output_tokens ?? 0, - accumulatedTotalTokens: loadedSession?.accumulated_usage?.total_tokens ?? 0, - }, - }); - } else { - dispatch({ - type: 'SESSION_LOADED', - payload: { - session: loadedSession!, - messages: loadedSession?.conversation || [], - tokenState: { - inputTokens: loadedSession?.usage?.input_tokens ?? 0, - outputTokens: loadedSession?.usage?.output_tokens ?? 0, - totalTokens: loadedSession?.usage?.total_tokens ?? 0, - accumulatedInputTokens: loadedSession?.accumulated_usage?.input_tokens ?? 0, - accumulatedOutputTokens: loadedSession?.accumulated_usage?.output_tokens ?? 0, - accumulatedTotalTokens: loadedSession?.accumulated_usage?.total_tokens ?? 0, - }, - }, - }); - } - - listMcpApps(sessionId).catch((err) => { - console.warn('Failed to populate apps cache:', err); - }); - - onSessionLoaded?.(); - } catch (error) { - if (cancelled) return; - - dispatch({ type: 'STREAM_ERROR', payload: errorMessage(error) }); - } - })(); - - return () => { - cancelled = true; - }; - }, [sessionId, onSessionLoaded]); - - const handleSubmit = useCallback( - async (input: UserInput) => { - const { msg: userMessage, images } = input; - const currentState = stateRef.current; - - if ( - !currentState.session || - currentState.chatState === ChatState.LoadingConversation || - currentState.chatState === ChatState.Streaming || - currentState.chatState === ChatState.Thinking || - currentState.chatState === ChatState.Compacting - ) { - return; - } - - const hasExistingMessages = currentState.messages.length > 0; - const hasNewMessage = userMessage.trim().length > 0 || images.length > 0; - - if (!hasNewMessage && !hasExistingMessages) { - return; - } - - // Emit session-created event for first message in a new session - if (!hasExistingMessages && hasNewMessage) { - window.dispatchEvent(new CustomEvent(AppEvents.SESSION_CREATED)); - - const pollForName = async (attempts = 0) => { - if (attempts >= 20) return; - - try { - const response = await getSession({ - path: { session_id: sessionId }, - throwOnError: true, - }); - const currentState = stateRef.current; - const currentName = currentState.session?.name; - const currentMessageCount = currentState.session?.message_count; - const newName = response.data?.name; - const newMessageCount = response.data?.message_count; - const hasNameChange = Boolean(newName && newName !== currentName); - const hasMessageCountChange = - newMessageCount !== undefined && newMessageCount !== currentMessageCount; - - if (newName && (hasNameChange || hasMessageCountChange)) { - dispatch({ - type: 'SET_SESSION', - payload: currentState.session - ? { - ...currentState.session, - name: newName, - ...(newMessageCount !== undefined && { message_count: newMessageCount }), - } - : undefined, - }); - if (hasNameChange) { - window.dispatchEvent( - new CustomEvent(AppEvents.SESSION_RENAMED, { - detail: { sessionId, newName }, - }) - ); - return; - } - } - } catch { - // Silently continue polling - } - - const latestState = stateRef.current; - if ( - latestState.chatState === ChatState.Streaming || - latestState.chatState === ChatState.Thinking || - latestState.chatState === ChatState.Compacting - ) { - namePollingRef.current = setTimeout(() => pollForName(attempts + 1), 500); - } - }; - - namePollingRef.current = setTimeout(() => pollForName(0), 1000); - } - - const newMessage = hasNewMessage - ? createUserMessage(userMessage, images) - : currentState.messages[currentState.messages.length - 1]; - const currentMessages = hasNewMessage - ? [...currentState.messages, newMessage] - : [...currentState.messages]; - - if (hasNewMessage) { - dispatch({ type: 'SET_MESSAGES', payload: currentMessages }); - } - - dispatch({ type: 'START_STREAMING' }); - - await submitToSession(sessionId, newMessage, currentMessages); - }, - [sessionId, submitToSession] - ); - - const submitElicitationResponse = useCallback( - async (elicitationId: string, userData: Record) => { - const currentState = stateRef.current; - - if (!currentState.session || currentState.chatState === ChatState.LoadingConversation) { - return true; - } - - // An elicitation response unblocks an in-flight tool call on the original - // request's SSE stream — don't start a new stream or flip chat state. - const responseMessage = createElicitationResponseMessage(elicitationId, userData); - const nextMessages = [...currentState.messages, responseMessage]; - dispatch({ type: 'SET_MESSAGES', payload: nextMessages }); - - try { - await sessionReply({ - path: { id: sessionId }, - body: { - request_id: uuidv7(), - user_message: responseMessage, - }, - throwOnError: true, - }); - return true; - } catch (error) { - onFinish('Submit error: ' + errorMessage(error)); - return true; - } - }, - [sessionId, onFinish] - ); - - const setRecipeUserParams = useCallback( - async (user_recipe_values: Record) => { - const currentState = stateRef.current; - - if (currentState.session) { - await updateSessionUserRecipeValues({ - path: { - session_id: sessionId, - }, - body: { - userRecipeValues: user_recipe_values, - }, - throwOnError: true, - }); - dispatch({ - type: 'SET_SESSION', - payload: { - ...currentState.session, - user_recipe_values, - }, - }); - } else { - dispatch({ - type: 'SET_SESSION_LOAD_ERROR', - payload: "can't call setRecipeParams without a session", - }); - } - }, - [sessionId] - ); - - useEffect(() => { - if (state.session) { - updateFromSession({ - body: { - session_id: state.session.id, - }, - throwOnError: true, - }); - } - }, [state.session]); - - const stopStreaming = useCallback(() => { - const requestId = activeRequestIdRef.current; - const requestSessionId = activeRequestSessionIdRef.current; - - // Abort the in-flight POST so the reply never starts if cancel wins the race - if (activeAbortRef.current) { - activeAbortRef.current.abort(); - activeAbortRef.current = null; - } - - if (requestId && requestSessionId) { - // Cancel against the session that originally started the request, - // not the current sessionId (which may have changed if user navigated). - sessionCancel({ - path: { id: requestSessionId }, - body: { request_id: requestId }, - }).catch((e) => { - console.warn('Failed to cancel request:', e); - }); - } - - // Clean up listener - if (activeUnsubscribeRef.current) { - activeUnsubscribeRef.current(); - activeUnsubscribeRef.current = null; - } - activeRequestIdRef.current = null; - activeRequestSessionIdRef.current = null; - - dispatch({ type: 'SET_CHAT_STATE', payload: ChatState.Idle }); - }, []); - - const onMessageUpdate = useCallback( - async (messageId: string, newContent: string, editType: 'fork' | 'edit' = 'fork') => { - const currentState = stateRef.current; - - dispatch({ type: 'SET_CHAT_STATE', payload: ChatState.Thinking }); - - try { - const { forkSession } = await import('../api'); - const message = currentState.messages.find((m) => m.id === messageId); - - if (!message) { - throw new Error(`Message with id ${messageId} not found in current messages`); - } - - const response = await forkSession({ - path: { - session_id: sessionId, - }, - body: { - timestamp: message.created, - truncate: true, - copy: editType === 'fork', - }, - throwOnError: true, - }); - - const targetSessionId = response.data?.sessionId; - if (!targetSessionId) { - throw new Error('No session ID returned from fork'); - } - - if (editType === 'fork') { - dispatch({ type: 'SET_CHAT_STATE', payload: ChatState.Idle }); - const event = new CustomEvent(AppEvents.SESSION_FORKED, { - detail: { - newSessionId: targetSessionId, - shouldStartAgent: true, - editedMessage: newContent, - }, - }); - window.dispatchEvent(event); - window.electron.logInfo(`Dispatched session-forked event for session ${targetSessionId}`); - } else { - const { getSession } = await import('../api'); - const sessionResponse = await getSession({ - path: { session_id: targetSessionId }, - throwOnError: true, - }); - - if (sessionResponse.data?.conversation) { - const truncatedMessages = [...sessionResponse.data.conversation]; - const updatedUserMessage = createUserMessage(newContent); - - for (const content of message.content) { - if (content.type === 'image') { - updatedUserMessage.content.push(content); - } - } - - const messagesForUI = [...truncatedMessages, updatedUserMessage]; - dispatch({ type: 'SET_MESSAGES', payload: messagesForUI }); - dispatch({ type: 'START_STREAMING' }); - - await submitToSession(targetSessionId, updatedUserMessage, messagesForUI); - } else { - await handleSubmit({ msg: newContent, images: [] }); - } - } - } catch (error) { - dispatch({ type: 'SET_CHAT_STATE', payload: ChatState.Idle }); - const errorMsg = errorMessage(error); - console.error('Failed to edit message:', error); - const { toastError } = await import('../toasts'); - toastError({ - title: 'Failed to edit message', - msg: errorMsg, - }); - } - }, - [sessionId, handleSubmit, submitToSession] - ); - - const setChatState = useCallback((newState: ChatState) => { - dispatch({ type: 'SET_CHAT_STATE', payload: newState }); - }, []); - - const updateSession = useCallback( - (updater: (session: Session) => Session) => { - const cached = resultsCache.get(sessionId); - const currentSession = stateRef.current.session ?? cached?.session; - if (!currentSession) return; - - const nextSession = updater(currentSession); - dispatch({ type: 'SET_SESSION', payload: nextSession }); - resultsCache.set(sessionId, { - session: nextSession, - messages: stateRef.current.messages.length - ? stateRef.current.messages - : cached?.messages || [], - }); - }, - [sessionId] - ); - - const cached = resultsCache.get(sessionId); - const maybe_cached_messages = state.session ? state.messages : cached?.messages || []; - const maybe_cached_session = state.session ?? cached?.session; - - const notificationsMap = useMemo(() => { - return state.notifications.reduce((map, notification) => { - const key = notification.request_id; - if (!map.has(key)) { - map.set(key, []); - } - map.get(key)!.push(notification); - return map; - }, new Map()); - }, [state.notifications]); - - return { - sessionLoadError: state.sessionLoadError, - messages: maybe_cached_messages, - session: maybe_cached_session, - chatState: state.chatState, - setChatState, - updateSession, - handleSubmit, - submitElicitationResponse, - stopStreaming, - setRecipeUserParams, - tokenState: state.tokenState, - notifications: notificationsMap, - pauseQueueOnStop: false, - queueProcessingBlocked: false, - onMessageUpdate, - }; -} diff --git a/ui/desktop/src/hooks/useSessionEvents.test.tsx b/ui/desktop/src/hooks/useSessionEvents.test.tsx deleted file mode 100644 index 71eace5dd..000000000 --- a/ui/desktop/src/hooks/useSessionEvents.test.tsx +++ /dev/null @@ -1,94 +0,0 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { act, renderHook } from '@testing-library/react'; -import { useSessionEvents, type SessionEvent } from './useSessionEvents'; - -vi.mock('../api', () => ({ - sessionEvents: vi.fn(), -})); - -import { sessionEvents } from '../api'; - -const sessionEventsMock = sessionEvents as unknown as ReturnType; - -function emptyStream() { - return { - stream: (async function* () { - // no events - })(), - }; -} - -function boundedMock( - limit: number, - factory: (callIndex: number) => Promise<{ stream: AsyncGenerator }> -) { - let calls = 0; - return () => { - const idx = calls++; - if (idx >= limit) { - return new Promise(() => {}); - } - return factory(idx); - }; -} - -async function flush(times = 200) { - for (let i = 0; i < times; i++) { - await Promise.resolve(); - } -} - -describe('useSessionEvents reconnect (issue #8717)', () => { - let originalSetTimeout: typeof globalThis.setTimeout; - - beforeEach(() => { - sessionEventsMock.mockReset(); - originalSetTimeout = globalThis.setTimeout; - globalThis.setTimeout = ((cb: () => void) => { - globalThis.queueMicrotask(cb); - return 0 as unknown as ReturnType; - }) as typeof globalThis.setTimeout; - }); - - afterEach(() => { - globalThis.setTimeout = originalSetTimeout; - }); - - it('synthesises a terminal Error for active listeners after a sustained failure streak', async () => { - const realDateNow = Date.now; - let fakeNow = 1_000_000; - Date.now = () => fakeNow; - - try { - sessionEventsMock.mockImplementation( - boundedMock(60, () => { - fakeNow += 10_000; - return Promise.resolve(emptyStream()); - }) - ); - - const { result, unmount } = renderHook(() => useSessionEvents('sess-1')); - - const handler = vi.fn(); - act(() => { - result.current.addListener('req-1', handler); - }); - - await flush(); - - const errorCalls = handler.mock.calls.filter( - (args) => (args[0] as SessionEvent).type === 'Error' - ); - expect(errorCalls.length).toBeGreaterThanOrEqual(1); - - const firstError = errorCalls[0][0] as SessionEvent & { error: string }; - expect(firstError.error).toBe('Lost connection to server'); - expect(firstError.request_id).toBe('req-1'); - expect(firstError.chat_request_id).toBe('req-1'); - - unmount(); - } finally { - Date.now = realDateNow; - } - }); -}); diff --git a/ui/desktop/src/hooks/useSessionEvents.ts b/ui/desktop/src/hooks/useSessionEvents.ts deleted file mode 100644 index 36b10b7ed..000000000 --- a/ui/desktop/src/hooks/useSessionEvents.ts +++ /dev/null @@ -1,161 +0,0 @@ -import { useEffect, useRef, useState, useCallback } from 'react'; -import { sessionEvents, type MessageEvent } from '../api'; - -/** - * An SSE event with an optional request_id (added by the server at the - * SSE framing layer, not part of the generated MessageEvent type). - */ -export type SessionEvent = MessageEvent & { - request_id?: string; - chat_request_id?: string; -}; - -type EventHandler = (event: SessionEvent) => void; -type ActiveRequestsHandler = (requestIds: string[]) => void; - -export function useSessionEvents(sessionId: string) { - const listenersRef = useRef(new Map>()); - const activeRequestsHandlerRef = useRef(null); - const abortRef = useRef(null); - const [connected, setConnected] = useState(false); - - useEffect(() => { - if (!sessionId) return; - - const abortController = new AbortController(); - abortRef.current = abortController; - - (async () => { - let retryDelay = 500; - const MAX_RETRY_DELAY = 10_000; - const TERMINAL_ERROR_AFTER_MS = 5 * 60 * 1000; - let lastEventId: string | undefined; - let failureStreakStartedAt: number | null = null; - - const broadcastTerminalErrorIfStuck = () => { - if (failureStreakStartedAt === null) return; - if (Date.now() - failureStreakStartedAt < TERMINAL_ERROR_AFTER_MS) return; - if (listenersRef.current.size === 0) { - failureStreakStartedAt = Date.now(); - return; - } - - const errorEvent: SessionEvent = { - type: 'Error', - error: 'Lost connection to server', - } as SessionEvent; - for (const [id, handlers] of listenersRef.current) { - for (const handler of [...handlers]) { - handler({ ...errorEvent, request_id: id, chat_request_id: id }); - } - } - failureStreakStartedAt = Date.now(); - }; - - while (!abortController.signal.aborted) { - try { - const { stream } = await sessionEvents({ - path: { id: sessionId }, - signal: abortController.signal, - headers: lastEventId ? { 'Last-Event-ID': lastEventId } : undefined, - sseMaxRetryAttempts: 1, - onSseEvent: (event) => { - if (event.id) { - lastEventId = event.id; - } - }, - }); - - let receivedEvent = false; - - for await (const event of stream) { - if (abortController.signal.aborted) break; - - if (!receivedEvent) { - receivedEvent = true; - setConnected(true); - retryDelay = 500; - failureStreakStartedAt = null; - } - - const sessionEvent = event as SessionEvent; - const routingId = sessionEvent.chat_request_id ?? sessionEvent.request_id; - - if (sessionEvent.type === 'ActiveRequests') { - const ids = (sessionEvent as unknown as { request_ids: string[] }).request_ids; - activeRequestsHandlerRef.current?.(ids); - continue; - } - - if (!routingId && sessionEvent.type === 'Error') { - for (const [id, handlers] of listenersRef.current) { - for (const handler of handlers) { - handler({ ...sessionEvent, request_id: id, chat_request_id: id }); - } - } - } else if (routingId) { - const handlers = listenersRef.current.get(routingId); - if (handlers) { - for (const handler of handlers) { - handler(sessionEvent); - } - } - } - } - - if (abortController.signal.aborted) break; - setConnected(false); - - if (!receivedEvent) { - if (failureStreakStartedAt === null) failureStreakStartedAt = Date.now(); - broadcastTerminalErrorIfStuck(); - await new Promise((r) => setTimeout(r, retryDelay)); - retryDelay = Math.min(retryDelay * 2, MAX_RETRY_DELAY); - } - } catch (error) { - if (abortController.signal.aborted) break; - console.warn('SSE connection error, reconnecting:', error); - setConnected(false); - - if (failureStreakStartedAt === null) failureStreakStartedAt = Date.now(); - broadcastTerminalErrorIfStuck(); - await new Promise((r) => setTimeout(r, retryDelay)); - retryDelay = Math.min(retryDelay * 2, MAX_RETRY_DELAY); - } - } - - setConnected(false); - })(); - - const listeners = listenersRef.current; - return () => { - abortController.abort(); - abortRef.current = null; - listeners.clear(); - setConnected(false); - }; - }, [sessionId]); - - const addListener = useCallback((requestId: string, handler: EventHandler): (() => void) => { - if (!listenersRef.current.has(requestId)) { - listenersRef.current.set(requestId, new Set()); - } - listenersRef.current.get(requestId)!.add(handler); - - return () => { - const set = listenersRef.current.get(requestId); - if (set) { - set.delete(handler); - if (set.size === 0) { - listenersRef.current.delete(requestId); - } - } - }; - }, []); - - const setActiveRequestsHandler = useCallback((handler: ActiveRequestsHandler | null) => { - activeRequestsHandlerRef.current = handler; - }, []); - - return { connected, addListener, setActiveRequestsHandler }; -} diff --git a/ui/desktop/src/sessions.ts b/ui/desktop/src/sessions.ts index 67de82af7..49c7f90b1 100644 --- a/ui/desktop/src/sessions.ts +++ b/ui/desktop/src/sessions.ts @@ -1,9 +1,8 @@ -import { Session, startAgent, ExtensionConfig } from './api'; +import { Session, ExtensionConfig } from './api'; import { DEFAULT_CHAT_TITLE } from './contexts/ChatContext'; import type { setViewType } from './hooks/useNavigation'; import type { FixedExtensionEntry } from './components/ConfigContext'; import { AppEvents } from './constants/events'; -import { USE_ACP_CHAT } from './acpChatFeatureFlag'; import { acpChatSessionController } from './acp/chatSessionController'; import { getConfiguredGooseExtensions, gooseExtensionName } from './acp/extensions'; @@ -85,35 +84,7 @@ export async function createSession( workingDir: string, options?: CreateSessionOptions ): Promise { - if (USE_ACP_CHAT) { - return createAcpSession(workingDir, options); - } - - const body: { - working_dir: string; - recipe_deeplink?: string; - recipe_id?: string; - extension_overrides?: ExtensionConfig[]; - } = { - working_dir: workingDir, - }; - - if (options?.recipeId) { - body.recipe_id = options.recipeId; - } else if (options?.recipeDeeplink) { - body.recipe_deeplink = options.recipeDeeplink; - } - - const extensionConfigs = selectedExtensionConfigs(options); - if (extensionConfigs.length > 0) { - body.extension_overrides = extensionConfigs; - } - - const newAgent = await startAgent({ - body, - throwOnError: true, - }); - return newAgent.data; + return createAcpSession(workingDir, options); } export async function startNewSession( diff --git a/ui/desktop/src/types/message.ts b/ui/desktop/src/types/message.ts index 9adfdd94e..dc3ab844e 100644 --- a/ui/desktop/src/types/message.ts +++ b/ui/desktop/src/types/message.ts @@ -14,9 +14,6 @@ export type ToolConfirmationRequestContent = ToolConfirmationRequest & { }; export type NotificationEvent = Extract; -// Compaction response message - must match backend constant -const COMPACTION_THINKING_TEXT = 'goose is compacting the conversation...'; - export interface ImageData { data: string; // base64 encoded image data mimeType: string; @@ -53,28 +50,6 @@ export function createUserMessage(text: string, images?: ImageData[]): Message { }; } -export function createElicitationResponseMessage( - elicitationId: string, - userData: Record -): Message { - return { - id: generateMessageId(), - role: 'user', - created: Math.floor(Date.now() / 1000), - content: [ - { - type: 'actionRequired', - data: { - actionType: 'elicitationResponse', - id: elicitationId, - user_data: userData, - }, - }, - ], - metadata: { userVisible: false, agentVisible: true }, - }; -} - export function generateMessageId(): string { return Math.random().toString(36).substring(2, 10); } @@ -242,19 +217,3 @@ export function getThinkingMessage(message: Message | undefined): string | undef return undefined; } - -export function getCompactingMessage(message: Message | undefined): string | undefined { - if (!message || message.role !== 'assistant') { - return undefined; - } - - for (const content of message.content) { - if (content.type === 'systemNotification' && content.notificationType === 'thinkingMessage') { - if (content.msg === COMPACTION_THINKING_TEXT) { - return content.msg; - } - } - } - - return undefined; -}