feat (ui): Remove ACP chat feature flag and turn on chat using ACP (#10062)
Some checks are pending
Canary / bundle-desktop-windows-cuda (push) Blocked by required conditions
Canary / Release (push) Blocked by required conditions
Canary / build-cli (push) Blocked by required conditions
Canary / Upload Install Script (push) Blocked by required conditions
Canary / bundle-desktop (push) Blocked by required conditions
Canary / bundle-desktop-intel (push) Blocked by required conditions
Canary / bundle-desktop-linux (push) Blocked by required conditions
Canary / bundle-desktop-windows (push) Blocked by required conditions
Canary / Prepare Version (push) Waiting to run
Unused Dependencies / machete (push) Waiting to run
CI / changes (push) Waiting to run
CI / Check Rust Code Format (push) Blocked by required conditions
CI / Build and Test Rust Project (push) Blocked by required conditions
CI / Build Rust Project on Windows (push) Waiting to run
CI / Check MSRV (push) Blocked by required conditions
CI / Lint Rust Code (push) Blocked by required conditions
CI / Check Generated Schemas are Up-to-Date (push) Blocked by required conditions
CI / Test and Lint Electron Desktop App (push) Blocked by required conditions
Create Minor Release PR / check-version-bump-pr (push) Waiting to run
Create Minor Release PR / release (push) Blocked by required conditions
Live Provider Tests / check-fork (push) Waiting to run
Live Provider Tests / changes (push) Blocked by required conditions
Live Provider Tests / Build Binary (push) Blocked by required conditions
Live Provider Tests / Smoke Tests (push) Blocked by required conditions
Live Provider Tests / Smoke Tests (Code Execution) (push) Blocked by required conditions
Live Provider Tests / Compaction Tests (push) Blocked by required conditions
Live Provider Tests / goose server HTTP integration tests (push) Blocked by required conditions
Publish Docker Image / docker (push) Waiting to run
Scorecard supply-chain security / Scorecard analysis (push) Waiting to run

This commit is contained in:
Lifei Zhou 2026-06-28 09:13:34 +10:00 committed by GitHub
parent d2687643da
commit 8878452785
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
27 changed files with 423 additions and 2110 deletions

View file

@ -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),
};
});

View file

@ -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<typeof import('../acp/extensions')>();
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,
});
});
});

View file

@ -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(),

View file

@ -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: {

View file

@ -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: {

View file

@ -7,10 +7,6 @@ import {
resolveAcpRecipeParamRequest,
} from '../recipeParamRequests';
vi.mock('../../acpChatFeatureFlag', () => ({
USE_ACP_CHAT: true,
}));
function recipeParamRequest(): RequestRecipeParams_unstable {
return {
sessionId: 'session-1',

View file

@ -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;

View file

@ -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<void> {
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<void> {
if (USE_ACP_CHAT) {
acpChatSessionActions.applyAcpGooseSessionNotification(notification);
}
acpChatSessionActions.applyAcpGooseSessionNotification(notification);
return Promise.resolve();
}

View file

@ -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<CreateElicitationResponse> {
if (!USE_ACP_CHAT || !isSessionScopedFormElicitation(request)) {
if (!isSessionScopedFormElicitation(request)) {
return cancelledElicitationResponse();
}

View file

@ -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<RequestPermissionResponse>((resolve) => {
pendingRequests.set(key, { request, resolve });
acpChatSessionActions.applyPermissionRequest(request);

View file

@ -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<string, string> {
export async function requestAcpRecipeParams(
request: RequestRecipeParams_unstable
): Promise<RecipeParamsResponse_unstable> {
if (!USE_ACP_CHAT) {
return { action: 'cancel' };
}
const initialValues = configuredParameterValues();
const paramRequest: AcpRecipeParamRequest = {
id: `acp_recipe_params_${uuidv7()}`,

View file

@ -1 +0,0 @@
export const USE_ACP_CHAT = false;

View file

@ -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' && (
<ParameterInputModal
parameters={recipe.parameters}
onSubmit={setRecipeUserParams}
onClose={() => setView('chat')}
initialValues={
(window.appConfig?.get('recipeParameters') as Record<string, string> | undefined) ||
undefined
}
/>
)}
</div>
);
}

View file

@ -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<boolean>;
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)}
/>
)}

View file

@ -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<typeof confirmToolAction>
>);
renderWithIntl(
<ToolApprovalButtons
data={{
id: 'tool-call-rest',
toolName: 'developer__shell',
sessionId: 'session-1',
}}
/>
);
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();
});
});

View file

@ -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);

View file

@ -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;

View file

@ -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<SessionListViewProps> = React.memo(
window.dispatchEvent(
new CustomEvent(AppEvents.SESSION_DELETED, { detail: { sessionId: sessionToDeleteId } })
);
clearSessionCache(sessionToDeleteId);
cancelAcpPermissionRequestsForSession(sessionToDeleteId);
cancelAcpElicitationRequestsForSession(sessionToDeleteId);
acpChatSessionActions.deleteSnapshot(sessionToDeleteId);

View file

@ -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({

View file

@ -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<void> => {
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<boolean> => {
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<string, unknown>) => {
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<string, string>) => {
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<string, NotificationEvent[]>());
}, [acpSnapshot?.notifications]);
return {
sessionLoadError,
messages,
session,
chatState,
setChatState,
updateSession,
handleSubmit,
onSteerQueuedMessage,
submitElicitationResponse,
stopStreaming,
setRecipeUserParams,
tokenState,
notifications: notificationsMap,
pauseQueueOnStop: false,
queueProcessingBlocked,
onMessageUpdate,
};
}

View file

@ -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<void> => {
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<boolean> => {
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<string, unknown>) => {
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<string, NotificationEvent[]>());
}, [acpSnapshot?.notifications]);
return {
sessionLoadError,
messages,
session,
chatState,
updateSession,
handleSubmit,
onSteerQueuedMessage,
submitElicitationResponse,
stopStreaming,
tokenState,
notifications: notificationsMap,
pauseQueueOnStop: false,
queueProcessingBlocked,
onMessageUpdate,
};
}

View file

@ -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<void>;
onSteerQueuedMessage?: (input: UserInput) => Promise<boolean>;
@ -20,7 +19,6 @@ export interface UseChatSessionResult {
elicitationId: string,
userData: Record<string, unknown>
) => Promise<boolean>;
setRecipeUserParams: (values: Record<string, string>) => Promise<void>;
stopStreaming: () => void;
sessionLoadError?: string;
tokenState: TokenState;
@ -33,5 +31,3 @@ export interface UseChatSessionResult {
editType?: 'fork' | 'edit'
) => Promise<void>;
}
export type UseChatSessionHook = (params: UseChatSessionParams) => UseChatSessionResult;

File diff suppressed because it is too large Load diff

View file

@ -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<typeof vi.fn>;
function emptyStream() {
return {
stream: (async function* () {
// no events
})(),
};
}
function boundedMock(
limit: number,
factory: (callIndex: number) => Promise<{ stream: AsyncGenerator<unknown> }>
) {
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<typeof globalThis.setTimeout>;
}) 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;
}
});
});

View file

@ -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<string, Set<EventHandler>>());
const activeRequestsHandlerRef = useRef<ActiveRequestsHandler | null>(null);
const abortRef = useRef<AbortController | null>(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 };
}

View file

@ -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<Session> {
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(

View file

@ -14,9 +14,6 @@ export type ToolConfirmationRequestContent = ToolConfirmationRequest & {
};
export type NotificationEvent = Extract<MessageEvent, { type: 'Notification' }>;
// 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<string, unknown>
): 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;
}