mirror of
https://github.com/block/goose.git
synced 2026-07-09 16:09:22 +00:00
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
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:
parent
d2687643da
commit
8878452785
27 changed files with 423 additions and 2110 deletions
|
|
@ -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),
|
||||
};
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -7,10 +7,6 @@ import {
|
|||
resolveAcpRecipeParamRequest,
|
||||
} from '../recipeParamRequests';
|
||||
|
||||
vi.mock('../../acpChatFeatureFlag', () => ({
|
||||
USE_ACP_CHAT: true,
|
||||
}));
|
||||
|
||||
function recipeParamRequest(): RequestRecipeParams_unstable {
|
||||
return {
|
||||
sessionId: 'session-1',
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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()}`,
|
||||
|
|
|
|||
|
|
@ -1 +0,0 @@
|
|||
export const USE_ACP_CHAT = false;
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -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 };
|
||||
}
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue