Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions ui/desktop/src/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest';
import { AppInner, resolveSessionInitialMessage } from './App';
import { IntlTestWrapper } from './i18n/test-utils';
import { FeaturesProvider } from './contexts/FeaturesContext';
import { reconnectAcpAfterSystemResume } from './acp/acpConnection';

// Set up globals for jsdom
Object.defineProperty(window, 'location', {
Expand Down Expand Up @@ -51,6 +52,11 @@ vi.mock('./acp/capabilities', () => ({
getAcpFeatureCapabilities: vi.fn().mockResolvedValue({ localInference: true }),
}));

vi.mock('./acp/acpConnection', async (importOriginal) => ({
...(await importOriginal<typeof import('./acp/acpConnection')>()),
reconnectAcpAfterSystemResume: vi.fn(),
}));

// Mock the ACP providers module used by OnboardingGuard so it doesn't try to
// open a real ACP client connection during tests. Returning null defaults
// keeps the app in the "brand new" (no provider configured) onboarding state.
Expand Down Expand Up @@ -301,6 +307,23 @@ describe('App Component - Brand New State', () => {
expect(mockNavigate).toHaveBeenCalledWith('/');
});

it('should reconnect ACP when the main process emits system-resume', async () => {
render(<AppInner />, { wrapper: AppInnerTestWrapper });

await waitFor(() => {
expect(mockElectron.reactReady).toHaveBeenCalled();
});

const systemResumeHandler = mockElectron.on.mock.calls.find(
([channel]) => channel === 'system-resume'
)?.[1];
expect(systemResumeHandler).toBeDefined();

systemResumeHandler?.({} as any);

expect(reconnectAcpAfterSystemResume).toHaveBeenCalledOnce();
});

it('should seed recipe sessions with the recipe prompt when no initial message is provided', () => {
expect(
resolveSessionInitialMessage(
Expand Down
7 changes: 7 additions & 0 deletions ui/desktop/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ import { usePageViewTracking } from './hooks/useAnalytics';
import { trackErrorWithContext } from './utils/analytics';
import { AppEvents } from './constants/events';
import { registerPlatformEventHandlers } from './utils/platform_events';
import { reconnectAcpAfterSystemResume } from './acp/acpConnection';

function PageViewTracker() {
usePageViewTracking();
Expand Down Expand Up @@ -396,6 +397,12 @@ export function AppInner() {
}
}, []);

useEffect(() => {
const handleSystemResume = () => reconnectAcpAfterSystemResume();
window.electron.on('system-resume', handleSystemResume);
return () => window.electron.off('system-resume', handleSystemResume);
}, []);

useEffect(() => {
acpListSessions()
.then(({ sessions }) => {
Expand Down
214 changes: 214 additions & 0 deletions ui/desktop/src/acp/__tests__/acpConnection.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,214 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { GOOSE_SERVE_EXITED_USER_MESSAGE } from '../../gooseServeLeaseRegistry';

const sdk = vi.hoisted(() => {
const initialize = vi.fn();
const instances: MockGooseClient[] = [];

class MockGooseClient {
readonly initialize = initialize;
readonly closed: Promise<void>;
resolveClosed: () => void = () => undefined;

constructor() {
this.closed = new Promise<void>((resolve) => {
this.resolveClosed = resolve;
});
instances.push(this);
}
}

return { GooseClient: MockGooseClient, initialize, instances };
});

const transport = vi.hoisted(() => ({
createWebSocketStream: vi.fn(),
}));

vi.mock('@aaif/goose-sdk', () => ({
DEFAULT_GOOSE_MCP_HOST_CAPABILITIES: {},
GooseClient: sdk.GooseClient,
}));

vi.mock('../createWebSocketStream', () => ({
createWebSocketStream: transport.createWebSocketStream,
}));

describe('ACP connection ownership', () => {
beforeEach(() => {
vi.useFakeTimers();
vi.resetModules();
vi.spyOn(Math, 'random').mockReturnValue(0.5);
sdk.initialize.mockReset().mockResolvedValue({});
sdk.instances.length = 0;
transport.createWebSocketStream.mockReset().mockImplementation(() => ({
readable: {},
writable: {},
close: vi.fn(),
}));
window.electron.getAcpUrl = vi.fn().mockResolvedValue('ws://localhost/acp');
});

afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
});

it('shares one initialization between concurrent callers', async () => {
const { getAcpClient } = await import('../acpConnection');

const [first, second] = await Promise.all([getAcpClient(), getAcpClient()]);

expect(first).toBe(second);
expect(sdk.instances).toHaveLength(1);
expect(sdk.initialize).toHaveBeenCalledTimes(1);
expect(transport.createWebSocketStream).toHaveBeenCalledTimes(1);
});

it('automatically reconnects after close and shares the result between callers', async () => {
const { getAcpClient } = await import('../acpConnection');
const firstClient = await getAcpClient();
const firstStream = transport.createWebSocketStream.mock.results[0].value;

sdk.instances[0].resolveClosed();
await Promise.resolve();
const firstCaller = getAcpClient();
const secondCaller = getAcpClient();

await vi.advanceTimersByTimeAsync(249);
expect(sdk.instances).toHaveLength(1);

await vi.advanceTimersByTimeAsync(1);
const [firstResult, secondResult] = await Promise.all([firstCaller, secondCaller]);

expect(firstStream.close).toHaveBeenCalledOnce();
expect(firstResult).toBe(secondResult);
expect(firstResult).not.toBe(firstClient);
expect(sdk.instances).toHaveLength(2);
expect(transport.createWebSocketStream).toHaveBeenCalledTimes(2);
});

it('increases the backoff after a failed reconnect attempt', async () => {
sdk.initialize
.mockResolvedValueOnce({})
.mockRejectedValueOnce(new Error('server unavailable'))
.mockResolvedValueOnce({});
const { getAcpClient } = await import('../acpConnection');
await getAcpClient();

sdk.instances[0].resolveClosed();
await Promise.resolve();
const reconnected = getAcpClient();

await vi.advanceTimersByTimeAsync(250);
expect(sdk.instances).toHaveLength(2);

await vi.advanceTimersByTimeAsync(499);
expect(sdk.instances).toHaveLength(2);

await vi.advanceTimersByTimeAsync(1);
await reconnected;

expect(sdk.instances).toHaveLength(3);
});

it('stops reconnecting when the Goose backend has exited', async () => {
const { getAcpClient, subscribeToAcpRecovery } = await import('../acpConnection');
const listener = vi.fn();
subscribeToAcpRecovery(listener);
await getAcpClient();

const getAcpUrl = vi
.fn()
.mockRejectedValue(
new Error(`Error invoking remote method 'get-acp-url': ${GOOSE_SERVE_EXITED_USER_MESSAGE}`)
);
window.electron.getAcpUrl = getAcpUrl;
sdk.instances[0].resolveClosed();
await Promise.resolve();

const connection = expect(getAcpClient()).rejects.toThrow(GOOSE_SERVE_EXITED_USER_MESSAGE);
await vi.advanceTimersByTimeAsync(250);
await connection;

expect(listener.mock.calls).toEqual([[true], [false]]);
await vi.advanceTimersByTimeAsync(60_000);
expect(getAcpUrl).toHaveBeenCalledOnce();
});

it('reconnects immediately after system resume', async () => {
const { getAcpClient, reconnectAcpAfterSystemResume } = await import('../acpConnection');
await getAcpClient();
const firstStream = transport.createWebSocketStream.mock.results[0].value;

reconnectAcpAfterSystemResume();
const reconnected = getAcpClient();
await reconnected;

expect(firstStream.close).toHaveBeenCalledOnce();
expect(sdk.instances).toHaveLength(2);
});

it('does nothing on system resume before ACP has been used', async () => {
const { reconnectAcpAfterSystemResume } = await import('../acpConnection');

reconnectAcpAfterSystemResume();
await Promise.resolve();

expect(sdk.instances).toHaveLength(0);
expect(transport.createWebSocketStream).not.toHaveBeenCalled();
});

it('uses normal backoff when the immediate resume attempt fails', async () => {
sdk.initialize
.mockResolvedValueOnce({})
.mockRejectedValueOnce(new Error('network is not ready'))
.mockResolvedValueOnce({});
const { getAcpClient, reconnectAcpAfterSystemResume } = await import('../acpConnection');
await getAcpClient();

reconnectAcpAfterSystemResume();
const reconnected = getAcpClient();
await Promise.resolve();
expect(sdk.instances).toHaveLength(2);

await vi.advanceTimersByTimeAsync(249);
expect(sdk.instances).toHaveLength(2);

await vi.advanceTimersByTimeAsync(1);
await reconnected;

expect(sdk.instances).toHaveLength(3);
});

it('supersedes an older retry loop after system resume', async () => {
const { getAcpClient, reconnectAcpAfterSystemResume } = await import('../acpConnection');
await getAcpClient();

sdk.instances[0].resolveClosed();
await Promise.resolve();
reconnectAcpAfterSystemResume();
await getAcpClient();
expect(sdk.instances).toHaveLength(2);

await vi.advanceTimersByTimeAsync(250);

expect(sdk.instances).toHaveLength(2);
});

it('notifies subscribers while reconnecting and after recovery', async () => {
const { getAcpClient, subscribeToAcpRecovery } = await import('../acpConnection');
const listener = vi.fn();
subscribeToAcpRecovery(listener);
await getAcpClient();

sdk.instances[0].resolveClosed();
await Promise.resolve();
expect(listener).toHaveBeenCalledWith(true);

await vi.advanceTimersByTimeAsync(250);
await getAcpClient();

expect(listener).toHaveBeenLastCalledWith(false);
});
});
22 changes: 21 additions & 1 deletion ui/desktop/src/acp/__tests__/chatSessionController.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,23 @@ describe('acpChatSessionController.loadSession', () => {
loadedSession()
);
});

it('restores a cached session from the server', async () => {
vi.mocked(acpChatSessionStore.getSnapshot).mockReturnValue({
...snapshotWithActivePrompt(null),
session: loadedSession(),
});
vi.mocked(isAcpSessionLoadInFlight).mockReturnValue(false);

await acpChatSessionController.restoreSession(SESSION_ID);

expect(acpChatSessionActions.startSessionLoad).toHaveBeenCalledWith(SESSION_ID);
expect(acpLoadSession).toHaveBeenCalledWith(SESSION_ID);
expect(acpChatSessionActions.finishSessionLoad).toHaveBeenCalledWith(
SESSION_ID,
loadedSession()
);
});
});

describe('acpChatSessionController.stop', () => {
Expand Down Expand Up @@ -353,7 +370,10 @@ describe('acpChatSessionController.updateMessage', () => {
resolvePromptCancellation!();
await updatePromise;

expect(acpTruncateSessionConversation).toHaveBeenCalledWith(SESSION_ID, existingMessage.created);
expect(acpTruncateSessionConversation).toHaveBeenCalledWith(
SESSION_ID,
existingMessage.created
);
expect(acpPromptSession).toHaveBeenCalled();
expect(acpChatSessionActions.clearPromptCancellation).not.toHaveBeenCalledWith(
SESSION_ID,
Expand Down
Loading
Loading