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 efe0693..4dbb6bf 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; @@ -84,7 +79,10 @@ export class RemoteState { if (this.cachedState === null) { this.cachedState = {} as ConversationInfo; } - const stateValue = event.value?.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) {