From 2ab246b1400d148ddd1b60b46462a48c255d94a7 Mon Sep 17 00:00:00 2001 From: VascoSch92 Date: Mon, 29 Jun 2026 13:52:06 +0200 Subject: [PATCH 1/2] refactor(events): deduplicate ConversationStateUpdateEvent definition ConversationStateUpdateEvent was defined twice: the canonical one in events/types.ts (extends BaseEvent, value: unknown, has previous_value) and a divergent local copy in conversation/remote-state.ts (extends Event, value: any, no previous_value). Remove the local copy and import the canonical type from events/types. Nothing imported the remote-state copy (index.ts re-exports the RemoteState class only, and the type already comes from events/types), so this is non-breaking. The snapshot accessor keeps reading value.full_state via a cast, matching the surrounding `as any` style now that value is the stricter `unknown`. Closes #112 --- src/__tests__/remote-state-events.test.ts | 76 +++++++++++++++++++++++ src/conversation/remote-state.ts | 9 +-- 2 files changed, 78 insertions(+), 7 deletions(-) create mode 100644 src/__tests__/remote-state-events.test.ts diff --git a/src/__tests__/remote-state-events.test.ts b/src/__tests__/remote-state-events.test.ts new file mode 100644 index 0000000..1bb617d --- /dev/null +++ b/src/__tests__/remote-state-events.test.ts @@ -0,0 +1,76 @@ +import { HttpClient } from '../client/http-client'; +import { RemoteState } from '../conversation/remote-state'; +import { ConversationStateUpdateEvent } from '../events/types'; + +const originalFetch = global.fetch; + +/** A fetch mock that fails the test if the network is touched. */ +function makeStateWithNoNetwork(): { state: RemoteState; fetchMock: jest.Mock } { + const fetchMock = jest + .fn() + .mockRejectedValue(new Error('network should not be called')) as jest.Mock; + global.fetch = fetchMock as typeof fetch; + const client = new HttpClient({ baseUrl: 'http://example.com' }); + return { state: new RemoteState(client, 'abc'), fetchMock }; +} + +describe('RemoteState.updateStateFromEvent with the canonical ConversationStateUpdateEvent', () => { + afterEach(() => { + global.fetch = originalFetch; + jest.restoreAllMocks(); + }); + + it('applies a __full_state__ snapshot event and serves it from cache', async () => { + const { state, fetchMock } = makeStateWithNoNetwork(); + + // Uses the deduplicated type from events/types: BaseEvent shape (id/timestamp) + // plus the optional previous_value field that the old local copy lacked. + const event: ConversationStateUpdateEvent = { + id: 'evt-1', + kind: 'ConversationStateUpdateEvent', + timestamp: '2024-01-01T00:00:00Z', + source: 'agent', + key: '__full_state__', + value: { full_state: { execution_status: 'running', persistence_dir: '/data/abc' } }, + previous_value: undefined, + }; + + await state.updateStateFromEvent(event); + + await expect(state.getExecutionStatus()).resolves.toBe('running'); + await expect(state.getPersistenceDir()).resolves.toBe('/data/abc'); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('applies a single-key update event', async () => { + const { state } = makeStateWithNoNetwork(); + + const event: ConversationStateUpdateEvent = { + id: 'evt-2', + kind: 'ConversationStateUpdateEvent', + timestamp: '2024-01-01T00:00:00Z', + key: 'execution_status', + value: 'idle', + }; + + await state.updateStateFromEvent(event); + await expect(state.getExecutionStatus()).resolves.toBe('idle'); + }); + + it('routes ConversationStateUpdateEvent through createStateUpdateCallback', async () => { + const { state } = makeStateWithNoNetwork(); + const callback = state.createStateUpdateCallback(); + + callback({ + id: 'evt-3', + kind: 'ConversationStateUpdateEvent', + timestamp: '2024-01-01T00:00:00Z', + key: 'execution_status', + value: 'finished', + } as ConversationStateUpdateEvent); + + // The callback dispatches asynchronously; allow the microtask queue to drain. + await new Promise((resolve) => setTimeout(resolve, 0)); + await expect(state.getExecutionStatus()).resolves.toBe('finished'); + }); +}); diff --git a/src/conversation/remote-state.ts b/src/conversation/remote-state.ts index edd7063..6ad2940 100644 --- a/src/conversation/remote-state.ts +++ b/src/conversation/remote-state.ts @@ -15,15 +15,10 @@ import { ConversationCallbackType, } from '../types/base'; import { ConversationInfo } from '../models/conversation'; +import { ConversationStateUpdateEvent } from '../events/types'; const FULL_STATE_KEY = '__full_state__'; -export interface ConversationStateUpdateEvent extends Event { - kind: 'ConversationStateUpdateEvent'; - key: string; - value: any; -} - export class RemoteState { private client: HttpClient; private conversationId: string; @@ -80,7 +75,7 @@ export class RemoteState { if (this.cachedState === null) { this.cachedState = {} as ConversationInfo; } - const stateValue = event.value?.full_state ?? event.value; + const stateValue = (event.value as any)?.full_state ?? event.value; Object.assign(this.cachedState, stateValue); } else { if (this.cachedState === null) { From 9360e15f069196bfa592125718c9c747db1aa2cc Mon Sep 17 00:00:00 2001 From: enyst Date: Fri, 3 Jul 2026 01:16:24 +0000 Subject: [PATCH 2/2] refactor(events): remove any cast in state update Co-authored-by: openhands --- src/conversation/remote-state.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/conversation/remote-state.ts b/src/conversation/remote-state.ts index 6ad2940..b6c4598 100644 --- a/src/conversation/remote-state.ts +++ b/src/conversation/remote-state.ts @@ -75,7 +75,10 @@ export class RemoteState { if (this.cachedState === null) { this.cachedState = {} as ConversationInfo; } - const stateValue = (event.value as any)?.full_state ?? event.value; + const stateValue = + typeof event.value === 'object' && event.value !== null && 'full_state' in event.value + ? (event.value.full_state ?? event.value) + : event.value; Object.assign(this.cachedState, stateValue); } else { if (this.cachedState === null) {