Skip to content

Commit 9e2fcbc

Browse files
dimakisclaude
andcommitted
feat(transport): p0 foundation — session state machine, crash recovery, SSE default
Transport SSOT Phase 0: server-authoritative session state foundation. - Add ClientSessionState type (idle/running/requires_action) with exhaustive toClientState() mapping from 7-state SessionState - Emit session_state_changed events on every setSessionState() call - Sync is_active column from state in the same UPDATE (backwards-compatible) - Add recoverStaleSessions() for crash recovery (ACTIVE/STARTING/ DETACHED/SUSPENDED/CLOSING → ENDED) - Re-export ClientSessionState and SessionStateEvent from package index - Flip default transport from WS to SSE (Transport SSOT P0) - Add protocol-parser handler for session_state_changed (P0: observability) - 151 tests covering state mapping, is_active sync, crash recovery, and protocol-parser event handling Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent cedd6ad commit 9e2fcbc

9 files changed

Lines changed: 370 additions & 7 deletions

File tree

frontend/src/client-store.ts

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,13 +17,14 @@ import { eventBus } from './lib/event-bus-singleton';
1717
import { getPreferredModel } from './lib/model-preference';
1818

1919
/**
20-
* Transport selector — set localStorage 'mitzo:transport' to 'sse' to use
21-
* SSE + HTTP POST instead of WebSocket. Default is 'ws'.
20+
* Transport selector — SSE + HTTP POST is the default transport (Transport SSOT P0).
21+
* This is an intentional flip from WS-default per the transport-ssot design doc.
22+
* Set localStorage 'mitzo:transport' to 'ws' to fall back to WebSocket.
2223
*
23-
* Toggle from console: localStorage.setItem('mitzo:transport', 'sse'); location.reload();
24-
* Revert: localStorage.removeItem('mitzo:transport'); location.reload();
24+
* Force WS: localStorage.setItem('mitzo:transport', 'ws'); location.reload();
25+
* Revert SSE: localStorage.removeItem('mitzo:transport'); location.reload();
2526
*/
26-
const useSSE = typeof window !== 'undefined' && localStorage.getItem('mitzo:transport') === 'sse';
27+
const useSSE = typeof window !== 'undefined' && localStorage.getItem('mitzo:transport') !== 'ws';
2728

2829
const sseConfig: SseConnectionConfig | undefined = useSSE
2930
? {

packages/client/__tests__/protocol-parser.test.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -965,6 +965,48 @@ describe('boot_context', () => {
965965
});
966966
});
967967

968+
// ─── Session state (Transport SSOT P0) ──────────────────────────────────────
969+
970+
describe('session_state_changed', () => {
971+
it('produces no message actions (P0: observability only)', () => {
972+
const r = parseServerMessage(
973+
{
974+
type: 'session_state_changed',
975+
sessionId: 'sid-1',
976+
state: 'running',
977+
internalState: 'ACTIVE',
978+
timestamp: 1234567890,
979+
},
980+
makeState(),
981+
makeCallbacks(),
982+
POOL_KEY,
983+
);
984+
expect(r.messagesActions).toHaveLength(0);
985+
});
986+
987+
it('logs via console.debug', () => {
988+
const spy = vi.spyOn(console, 'debug').mockImplementation(() => {});
989+
parseServerMessage(
990+
{
991+
type: 'session_state_changed',
992+
sessionId: 'sid-1',
993+
state: 'idle',
994+
internalState: 'ENDED',
995+
timestamp: 1234567890,
996+
},
997+
makeState(),
998+
makeCallbacks(),
999+
POOL_KEY,
1000+
);
1001+
expect(spy).toHaveBeenCalledWith('[mitzo] session_state_changed', {
1002+
sessionId: 'sid-1',
1003+
state: 'idle',
1004+
internalState: 'ENDED',
1005+
});
1006+
spy.mockRestore();
1007+
});
1008+
});
1009+
9681010
// ─── Subagent cancellation ───────────────────────────────────────────────────
9691011

9701012
describe('subagent_cancelled', () => {

packages/client/src/protocol-parser.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -292,6 +292,15 @@ export function parseServerMessage(
292292
callbacks.onSessionRenamed?.(msg.name as string);
293293
break;
294294

295+
case 'session_state_changed':
296+
// P0: log for observability, no UI action yet (Phase 1 will bind to running state)
297+
console.debug('[mitzo] session_state_changed', {
298+
sessionId: msg.sessionId,
299+
state: msg.state,
300+
internalState: msg.internalState,
301+
});
302+
break;
303+
295304
case 'message_start':
296305
result.messagesActions.push({
297306
type: 'MESSAGE_START',

packages/protocol/__tests__/event-store.test.ts

Lines changed: 209 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -642,6 +642,215 @@ describe('EventStore', () => {
642642
});
643643
});
644644

645+
describe('toClientState mapping (via session_state_changed events)', () => {
646+
const sid = 'client-state-test';
647+
648+
beforeEach(() => {
649+
store.upsertSession({ sessionId: sid });
650+
});
651+
652+
it('maps CREATED to idle', () => {
653+
store.setSessionState(sid, 'CREATED', { force: true });
654+
const events = store.getSessionEvents(sid);
655+
const stateEvent = events.find((e) => e.type === 'session_state_changed');
656+
expect(stateEvent?.payload.state).toBe('idle');
657+
expect(stateEvent?.payload.internalState).toBe('CREATED');
658+
});
659+
660+
it('maps STARTING to running', () => {
661+
store.setSessionState(sid, 'STARTING', { force: true });
662+
const events = store.getSessionEvents(sid);
663+
const stateEvent = events.find((e) => e.type === 'session_state_changed');
664+
expect(stateEvent?.payload.state).toBe('running');
665+
});
666+
667+
it('maps ACTIVE to running', () => {
668+
store.setSessionState(sid, 'ACTIVE', { force: true });
669+
const events = store.getSessionEvents(sid);
670+
const stateEvent = events.find((e) => e.type === 'session_state_changed');
671+
expect(stateEvent?.payload.state).toBe('running');
672+
});
673+
674+
it('maps DETACHED to idle', () => {
675+
store.setSessionState(sid, 'DETACHED', { force: true });
676+
const events = store.getSessionEvents(sid);
677+
const stateEvent = events.find((e) => e.type === 'session_state_changed');
678+
expect(stateEvent?.payload.state).toBe('idle');
679+
});
680+
681+
it('maps SUSPENDED to idle', () => {
682+
store.setSessionState(sid, 'SUSPENDED', { force: true });
683+
const events = store.getSessionEvents(sid);
684+
const stateEvent = events.find((e) => e.type === 'session_state_changed');
685+
expect(stateEvent?.payload.state).toBe('idle');
686+
});
687+
688+
it('maps CLOSING to idle', () => {
689+
store.setSessionState(sid, 'CLOSING', { force: true });
690+
const events = store.getSessionEvents(sid);
691+
const stateEvent = events.find((e) => e.type === 'session_state_changed');
692+
expect(stateEvent?.payload.state).toBe('idle');
693+
});
694+
695+
it('maps ENDED to idle', () => {
696+
store.setSessionState(sid, 'ENDED', { force: true });
697+
const events = store.getSessionEvents(sid);
698+
const stateEvent = events.find((e) => e.type === 'session_state_changed');
699+
expect(stateEvent?.payload.state).toBe('idle');
700+
});
701+
702+
it('includes timestamp in event payload', () => {
703+
const before = Date.now();
704+
store.setSessionState(sid, 'ACTIVE', { force: true });
705+
const events = store.getSessionEvents(sid);
706+
const stateEvent = events.find((e) => e.type === 'session_state_changed');
707+
expect(stateEvent?.payload.timestamp).toBeGreaterThanOrEqual(before);
708+
});
709+
});
710+
711+
describe('setSessionState syncs is_active', () => {
712+
const sid = 'is-active-sync-test';
713+
714+
beforeEach(() => {
715+
store.upsertSession({ sessionId: sid });
716+
});
717+
718+
it('sets is_active=true for ACTIVE', () => {
719+
store.setSessionState(sid, 'ACTIVE', { force: true });
720+
expect(store.getSession(sid)!.isActive).toBe(true);
721+
});
722+
723+
it('sets is_active=true for DETACHED', () => {
724+
store.setSessionState(sid, 'DETACHED', { force: true });
725+
expect(store.getSession(sid)!.isActive).toBe(true);
726+
});
727+
728+
it('sets is_active=true for SUSPENDED', () => {
729+
store.setSessionState(sid, 'SUSPENDED', { force: true });
730+
expect(store.getSession(sid)!.isActive).toBe(true);
731+
});
732+
733+
it('sets is_active=false for ENDED', () => {
734+
store.setSessionState(sid, 'ENDED', { force: true });
735+
expect(store.getSession(sid)!.isActive).toBe(false);
736+
});
737+
738+
it('sets is_active=false for CLOSING', () => {
739+
store.setSessionState(sid, 'CLOSING', { force: true });
740+
expect(store.getSession(sid)!.isActive).toBe(false);
741+
});
742+
743+
it('sets is_active=true for CREATED', () => {
744+
store.setSessionState(sid, 'CREATED', { force: true });
745+
expect(store.getSession(sid)!.isActive).toBe(true);
746+
});
747+
748+
it('sets is_active=true for STARTING', () => {
749+
store.setSessionState(sid, 'STARTING', { force: true });
750+
expect(store.getSession(sid)!.isActive).toBe(true);
751+
});
752+
});
753+
754+
describe('recoverStaleSessions', () => {
755+
it('transitions ACTIVE sessions to ENDED', () => {
756+
store.upsertSession({ sessionId: 'active-1' });
757+
store.setSessionState('active-1', 'ACTIVE', { force: true });
758+
759+
const count = store.recoverStaleSessions();
760+
761+
expect(count).toBe(1);
762+
expect(store.getSessionState('active-1')).toBe('ENDED');
763+
});
764+
765+
it('transitions STARTING sessions to ENDED', () => {
766+
store.upsertSession({ sessionId: 'starting-1' });
767+
store.setSessionState('starting-1', 'STARTING', { force: true });
768+
769+
store.recoverStaleSessions();
770+
771+
expect(store.getSessionState('starting-1')).toBe('ENDED');
772+
});
773+
774+
it('transitions DETACHED sessions to ENDED', () => {
775+
store.upsertSession({ sessionId: 'detached-1' });
776+
store.setSessionState('detached-1', 'DETACHED', { force: true });
777+
778+
store.recoverStaleSessions();
779+
780+
expect(store.getSessionState('detached-1')).toBe('ENDED');
781+
});
782+
783+
it('transitions SUSPENDED sessions to ENDED', () => {
784+
store.upsertSession({ sessionId: 'suspended-1' });
785+
store.setSessionState('suspended-1', 'SUSPENDED', { force: true });
786+
787+
store.recoverStaleSessions();
788+
789+
expect(store.getSessionState('suspended-1')).toBe('ENDED');
790+
});
791+
792+
it('transitions CLOSING sessions to ENDED', () => {
793+
store.upsertSession({ sessionId: 'closing-1' });
794+
store.setSessionState('closing-1', 'CLOSING', { force: true });
795+
796+
store.recoverStaleSessions();
797+
798+
expect(store.getSessionState('closing-1')).toBe('ENDED');
799+
});
800+
801+
it('does not touch ENDED sessions', () => {
802+
store.upsertSession({ sessionId: 'ended-1' });
803+
store.setSessionState('ended-1', 'ENDED', { force: true });
804+
805+
const count = store.recoverStaleSessions();
806+
807+
expect(count).toBe(0);
808+
expect(store.getSessionState('ended-1')).toBe('ENDED');
809+
});
810+
811+
it('does not touch CREATED sessions', () => {
812+
store.upsertSession({ sessionId: 'created-1' });
813+
store.setSessionState('created-1', 'CREATED', { force: true });
814+
815+
const count = store.recoverStaleSessions();
816+
817+
expect(count).toBe(0);
818+
expect(store.getSessionState('created-1')).toBe('CREATED');
819+
});
820+
821+
it('returns correct count for multiple stale sessions', () => {
822+
store.upsertSession({ sessionId: 'stale-1' });
823+
store.upsertSession({ sessionId: 'stale-2' });
824+
store.upsertSession({ sessionId: 'ok-1' });
825+
store.setSessionState('stale-1', 'ACTIVE', { force: true });
826+
store.setSessionState('stale-2', 'DETACHED', { force: true });
827+
store.setSessionState('ok-1', 'ENDED', { force: true });
828+
829+
const count = store.recoverStaleSessions();
830+
831+
expect(count).toBe(2);
832+
});
833+
834+
it('emits session_state_changed events for recovered sessions', () => {
835+
store.upsertSession({ sessionId: 'recover-1' });
836+
store.setSessionState('recover-1', 'ACTIVE', { force: true });
837+
838+
// Clear events from setup
839+
const beforeCount = store.getSessionEvents('recover-1').length;
840+
841+
store.recoverStaleSessions();
842+
843+
const events = store.getSessionEvents('recover-1');
844+
// Should have new session_state_changed event from recovery
845+
const recoveryEvent = events
846+
.slice(beforeCount)
847+
.find((e) => e.type === 'session_state_changed');
848+
expect(recoveryEvent).toBeDefined();
849+
expect(recoveryEvent?.payload.state).toBe('idle');
850+
expect(recoveryEvent?.payload.internalState).toBe('ENDED');
851+
});
852+
});
853+
645854
describe('close', () => {
646855
it('is safe to call multiple times', () => {
647856
store.close();

0 commit comments

Comments
 (0)