Skip to content

Commit a72ebc4

Browse files
authored
fix(cli): re-attach session handlers registered while offline after reconnecting (#1858)
Backends register their user-message and RPC handlers once, on the offline stub when the server is unreachable at startup. The real session that replaces it never got them, so app messages, abort and kill were ignored.
1 parent fba320e commit a72ebc4

3 files changed

Lines changed: 135 additions & 5 deletions

File tree

‎packages/happy-cli/src/utils/offlineSessionStub.ts‎

Lines changed: 36 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -31,8 +31,18 @@ import type { ApiSessionClient } from '@/api/apiSession';
3131
* session = api.sessionSyncClient(response);
3232
* ```
3333
*/
34+
type OfflineRegistrations = {
35+
userMessageHandler: Parameters<ApiSessionClient['onUserMessage']>[0] | null;
36+
rpcHandlers: Map<string, (...args: any[]) => any>;
37+
};
38+
39+
const offlineRegistrations = new WeakMap<object, OfflineRegistrations>();
40+
3441
export function createOfflineSessionStub(sessionTag: string): ApiSessionClient {
35-
return {
42+
// Backends register their handlers once, on whatever session they hold at
43+
// startup. Record them so they can be attached to the real session later.
44+
const registrations: OfflineRegistrations = { userMessageHandler: null, rpcHandlers: new Map() };
45+
const stub = {
3646
sessionId: `offline-${sessionTag}`,
3747
sendCodexMessage: () => {},
3848
sendAgentMessage: () => {},
@@ -46,9 +56,31 @@ export function createOfflineSessionStub(sessionTag: string): ApiSessionClient {
4656
close: async () => {},
4757
updateMetadata: () => {},
4858
updateAgentState: () => {},
49-
onUserMessage: () => {},
59+
onUserMessage: (handler: OfflineRegistrations['userMessageHandler']) => {
60+
registrations.userMessageHandler = handler;
61+
},
5062
rpcHandlerManager: {
51-
registerHandler: () => {}
63+
registerHandler: (method: string, handler: (...args: any[]) => any) => {
64+
registrations.rpcHandlers.set(method, handler);
65+
}
5266
}
53-
} as unknown as ApiSessionClient;
67+
};
68+
offlineRegistrations.set(stub, registrations);
69+
return stub as unknown as ApiSessionClient;
70+
}
71+
72+
/**
73+
* Attaches the handlers registered on an offline stub to the real session that
74+
* replaces it after reconnection. Without this, the real session would have no
75+
* user-message listener and no RPC handlers (abort, kill, ...).
76+
*/
77+
export function transferOfflineRegistrations(stub: ApiSessionClient, session: ApiSessionClient): void {
78+
const registrations = offlineRegistrations.get(stub);
79+
if (!registrations) return;
80+
for (const [method, handler] of registrations.rpcHandlers) {
81+
session.rpcHandlerManager.registerHandler(method, handler);
82+
}
83+
if (registrations.userMessageHandler) {
84+
session.onUserMessage(registrations.userMessageHandler);
85+
}
5486
}
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
import { describe, it, expect, vi, beforeEach } from 'vitest';
2+
3+
let capturedOnReconnected: (() => Promise<unknown>) | null = null;
4+
5+
vi.mock('@/configuration', () => ({
6+
configuration: { serverUrl: 'http://server.test' },
7+
}));
8+
9+
vi.mock('@/utils/serverConnectionErrors', () => ({
10+
startOfflineReconnection: (config: { onReconnected: () => Promise<unknown> }) => {
11+
capturedOnReconnected = config.onReconnected;
12+
return { cancel: () => {}, getSession: () => null, isReconnected: () => false };
13+
},
14+
}));
15+
16+
import { setupOfflineReconnection } from './setupOfflineReconnection';
17+
18+
function makeRealSession() {
19+
return {
20+
sessionId: 'real-session',
21+
onUserMessage: vi.fn(),
22+
rpcHandlerManager: { registerHandler: vi.fn() },
23+
};
24+
}
25+
26+
function setupOffline(realSession: ReturnType<typeof makeRealSession>, onSessionSwap = vi.fn()) {
27+
const api = {
28+
getOrCreateSession: vi.fn().mockResolvedValue({ id: 'real-session' }),
29+
sessionSyncClient: vi.fn().mockReturnValue(realSession),
30+
};
31+
const result = setupOfflineReconnection({
32+
api: api as any,
33+
sessionTag: 'tag-1',
34+
metadata: {} as any,
35+
state: {} as any,
36+
response: null,
37+
onSessionSwap,
38+
});
39+
return { result, onSessionSwap };
40+
}
41+
42+
describe('setupOfflineReconnection', () => {
43+
beforeEach(() => {
44+
capturedOnReconnected = null;
45+
});
46+
47+
it('re-attaches the user message handler registered while offline', async () => {
48+
const realSession = makeRealSession();
49+
const { result } = setupOffline(realSession);
50+
const handler = vi.fn();
51+
52+
result.session.onUserMessage(handler);
53+
await capturedOnReconnected!();
54+
55+
expect(realSession.onUserMessage).toHaveBeenCalledWith(handler);
56+
});
57+
58+
it('re-registers RPC handlers registered while offline', async () => {
59+
const realSession = makeRealSession();
60+
const { result } = setupOffline(realSession);
61+
const abort = vi.fn();
62+
const kill = vi.fn();
63+
64+
result.session.rpcHandlerManager.registerHandler('abort', abort);
65+
result.session.rpcHandlerManager.registerHandler('killSession', kill);
66+
await capturedOnReconnected!();
67+
68+
expect(realSession.rpcHandlerManager.registerHandler).toHaveBeenCalledWith('abort', abort);
69+
expect(realSession.rpcHandlerManager.registerHandler).toHaveBeenCalledWith('killSession', kill);
70+
});
71+
72+
it('attaches handlers before handing the real session to the caller', async () => {
73+
const realSession = makeRealSession();
74+
const order: string[] = [];
75+
realSession.onUserMessage.mockImplementation(() => order.push('attach'));
76+
const { result } = setupOffline(realSession, vi.fn(() => order.push('swap')));
77+
78+
result.session.onUserMessage(vi.fn());
79+
await capturedOnReconnected!();
80+
81+
expect(order).toEqual(['attach', 'swap']);
82+
});
83+
84+
it('re-attaches only the latest user message handler', async () => {
85+
const realSession = makeRealSession();
86+
const { result } = setupOffline(realSession);
87+
const first = vi.fn();
88+
const second = vi.fn();
89+
90+
result.session.onUserMessage(first);
91+
result.session.onUserMessage(second);
92+
await capturedOnReconnected!();
93+
94+
expect(realSession.onUserMessage).toHaveBeenCalledTimes(1);
95+
expect(realSession.onUserMessage).toHaveBeenCalledWith(second);
96+
});
97+
});

‎packages/happy-cli/src/utils/setupOfflineReconnection.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import type { ApiClient } from '@/api/api';
1111
import type { ApiSessionClient } from '@/api/apiSession';
1212
import type { AgentState, Metadata, Session } from '@/api/types';
1313
import { configuration } from '@/configuration';
14-
import { createOfflineSessionStub } from '@/utils/offlineSessionStub';
14+
import { createOfflineSessionStub, transferOfflineRegistrations } from '@/utils/offlineSessionStub';
1515
import { startOfflineReconnection } from '@/utils/serverConnectionErrors';
1616

1717
/**
@@ -92,6 +92,7 @@ export function setupOfflineReconnection(opts: SetupOfflineReconnectionOptions):
9292
const resp = await api.getOrCreateSession({ tag: sessionTag, metadata, state });
9393
if (!resp) throw new Error('Server unavailable');
9494
const realSession = api.sessionSyncClient(resp);
95+
transferOfflineRegistrations(session, realSession);
9596
// Notify caller to swap the session reference
9697
onSessionSwap(realSession);
9798
return realSession;

0 commit comments

Comments
 (0)