diff --git a/bun.lock b/bun.lock index 879b924..2140777 100644 --- a/bun.lock +++ b/bun.lock @@ -18,6 +18,7 @@ "dependencies": { "@modelcontextprotocol/sdk": "^1.25.1", "koa-compose": "^4.1.0", + "xstate": "^5.25.0", "zod": "^4.3.5", }, "devDependencies": { @@ -528,6 +529,8 @@ "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], + "xstate": ["xstate@5.25.0", "", {}, "sha512-yyWzfhVRoTHNLjLoMmdwZGagAYfmnzpm9gPjlX2MhJZsDojXGqRxODDOi4BsgGRKD46NZRAdcLp6CKOyvQS0Bw=="], + "yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], "yoctocolors": ["yoctocolors@2.1.2", "", {}, "sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug=="], diff --git a/packages/core/package.json b/packages/core/package.json index b4e6c9f..5be2386 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -11,6 +11,7 @@ "dependencies": { "@modelcontextprotocol/sdk": "^1.25.1", "koa-compose": "^4.1.0", + "xstate": "^5.25.0", "zod": "^4.3.5" }, "devDependencies": { diff --git a/packages/core/src/e2e.test.ts b/packages/core/src/e2e.test.ts index 0f16e41..f75bb06 100644 --- a/packages/core/src/e2e.test.ts +++ b/packages/core/src/e2e.test.ts @@ -39,8 +39,9 @@ describe("Core E2E", () => { }); expect(session.state).toBe(SessionState.CREATED); - // 2. Transition to ACTIVE - sessionManager.updateState(session.id, SessionState.ACTIVE); + // 2. Transition through proper lifecycle to ACTIVE + sessionManager.initialize(session.id); + sessionManager.activate(session.id); expect(sessionManager.get(session.id)?.state).toBe(SessionState.ACTIVE); // 3. Setup middleware pipeline @@ -258,12 +259,12 @@ describe("Core E2E", () => { expect(session.state).toBe(SessionState.CREATED); - sessionManager.updateState(session.id, SessionState.INITIALIZING); + sessionManager.initialize(session.id); expect(sessionManager.get(session.id)?.state).toBe( SessionState.INITIALIZING, ); - sessionManager.updateState(session.id, SessionState.ACTIVE); + sessionManager.activate(session.id); expect(sessionManager.get(session.id)?.state).toBe(SessionState.ACTIVE); sessionManager.updateCapabilities( diff --git a/packages/core/src/index.test.ts b/packages/core/src/index.test.ts index 94dfcda..ad517b5 100644 --- a/packages/core/src/index.test.ts +++ b/packages/core/src/index.test.ts @@ -46,7 +46,8 @@ describe("@say2/core", () => { // Verify it actually works, not just exists expect(session.id).toBeDefined(); expect(session.state).toBe(SessionState.CREATED); - expect(manager.get(session.id)).toBe(session); + // Note: get() returns a snapshot-converted session, so we compare by ID + expect(manager.get(session.id)?.id).toBe(session.id); }); it("exports MessageStore that can store and retrieve messages", () => { diff --git a/packages/core/src/session/index.ts b/packages/core/src/session/index.ts index 1318368..2090532 100644 --- a/packages/core/src/session/index.ts +++ b/packages/core/src/session/index.ts @@ -1 +1,13 @@ -export { SessionManager, sessionManager } from "./manager"; +export { + SessionManager, + sessionManager, + type TransitionResult, +} from "./manager"; +export { + type MachineStateValue, + type SessionContext, + type SessionEvent, + type SessionInput, + STATE_VALUE_MAP, + sessionMachine, +} from "./session-machine"; diff --git a/packages/core/src/session/manager.test.ts b/packages/core/src/session/manager.test.ts index 819ce93..fe0f6b6 100644 --- a/packages/core/src/session/manager.test.ts +++ b/packages/core/src/session/manager.test.ts @@ -81,6 +81,10 @@ describe("SessionManager", () => { const config = { name: "test", transport: "stdio" as const }; const session1 = manager.create(config); manager.create(config); + + // Must go through valid transitions to close + manager.initialize(session1.id); + manager.activate(session1.id); manager.close(session1.id); const sessions = manager.list(); @@ -92,7 +96,7 @@ describe("SessionManager", () => { const config = { name: "test", transport: "stdio" as const }; const session1 = manager.create(config); manager.create(config); - manager.updateState(session1.id, SessionState.ERROR); + manager.markError(session1.id, "Test error"); const sessions = manager.list(); @@ -107,9 +111,13 @@ describe("SessionManager", () => { const s2 = manager.create(config); const s3 = manager.create(config); - // Close s1, set s2 to error + // Close s1 (must go through valid transitions) + manager.initialize(s1.id); + manager.activate(s1.id); manager.close(s1.id); - manager.updateState(s2.id, SessionState.ERROR); + + // Set s2 to error + manager.markError(s2.id, "Test error"); // list() should only return s3 expect(manager.list().length).toBe(1); @@ -124,84 +132,204 @@ describe("SessionManager", () => { }); }); - describe("close", () => { - test("updates session state to CLOSED", () => { + describe("state transitions", () => { + test("initialize transitions from CREATED to INITIALIZING", () => { const config = { name: "test", transport: "stdio" as const }; const session = manager.create(config); - manager.close(session.id); - const updated = manager.get(session.id); + const result = manager.initialize(session.id); - expect(updated?.state).toBe(SessionState.CLOSED); + expect(result.success).toBe(true); + expect(manager.get(session.id)?.state).toBe(SessionState.INITIALIZING); }); - test("updates updatedAt timestamp", async () => { + test("activate transitions from INITIALIZING to ACTIVE", () => { const config = { name: "test", transport: "stdio" as const }; const session = manager.create(config); - const originalUpdatedAt = session.updatedAt; - // Actual delay to ensure timestamp differs - await new Promise((r) => setTimeout(r, 5)); - manager.close(session.id); + manager.initialize(session.id); + const result = manager.activate(session.id); - const updated = manager.get(session.id); - // Use > not >= to ensure timestamp actually changed - expect(updated?.updatedAt.getTime()).toBeGreaterThan( - originalUpdatedAt.getTime(), + expect(result.success).toBe(true); + expect(manager.get(session.id)?.state).toBe(SessionState.ACTIVE); + }); + + test("activate stores capabilities", () => { + const config = { name: "test", transport: "stdio" as const }; + const session = manager.create(config); + + manager.initialize(session.id); + manager.activate( + session.id, + { tools: true }, + { resources: true }, + "1.0.0", ); + + const updated = manager.get(session.id); + expect(updated?.clientCapabilities).toEqual({ tools: true }); + expect(updated?.serverCapabilities).toEqual({ resources: true }); + expect(updated?.protocolVersion).toBe("1.0.0"); }); - }); - describe("updateState", () => { - test("transitions through lifecycle states", () => { + test("close transitions from ACTIVE to CLOSED", () => { + const config = { name: "test", transport: "stdio" as const }; + const session = manager.create(config); + + manager.initialize(session.id); + manager.activate(session.id); + const result = manager.close(session.id); + + expect(result.success).toBe(true); + expect(manager.get(session.id)?.state).toBe(SessionState.CLOSED); + }); + + test("markError transitions to ERROR from any state", () => { + const config = { name: "test", transport: "stdio" as const }; + + // From CREATED + const s1 = manager.create(config); + expect(manager.markError(s1.id, "Error 1").success).toBe(true); + expect(manager.get(s1.id)?.state).toBe(SessionState.ERROR); + + // From INITIALIZING + const s2 = manager.create(config); + manager.initialize(s2.id); + expect(manager.markError(s2.id, "Error 2").success).toBe(true); + expect(manager.get(s2.id)?.state).toBe(SessionState.ERROR); + + // From ACTIVE + const s3 = manager.create(config); + manager.initialize(s3.id); + manager.activate(s3.id); + expect(manager.markError(s3.id, "Error 3").success).toBe(true); + expect(manager.get(s3.id)?.state).toBe(SessionState.ERROR); + }); + + test("transitions through full lifecycle", () => { const config = { name: "test", transport: "stdio" as const }; const session = manager.create(config); expect(session.state).toBe(SessionState.CREATED); - manager.updateState(session.id, SessionState.INITIALIZING); + manager.initialize(session.id); expect(manager.get(session.id)?.state).toBe(SessionState.INITIALIZING); - manager.updateState(session.id, SessionState.ACTIVE); + manager.activate(session.id); expect(manager.get(session.id)?.state).toBe(SessionState.ACTIVE); - manager.updateState(session.id, SessionState.CLOSED); + manager.close(session.id); expect(manager.get(session.id)?.state).toBe(SessionState.CLOSED); }); }); - describe("updateCapabilities", () => { - test("stores client capabilities", () => { + describe("invalid transitions", () => { + test("cannot activate from CREATED state", () => { const config = { name: "test", transport: "stdio" as const }; const session = manager.create(config); - manager.updateCapabilities(session.id, { tools: true }, undefined); + const result = manager.activate(session.id); - const updated = manager.get(session.id); - expect(updated?.clientCapabilities).toEqual({ tools: true }); + expect(result.success).toBe(false); + expect(result.error).toContain("Invalid transition"); + expect(manager.get(session.id)?.state).toBe(SessionState.CREATED); }); - test("stores server capabilities", () => { + test("cannot close from CREATED state", () => { const config = { name: "test", transport: "stdio" as const }; const session = manager.create(config); - manager.updateCapabilities(session.id, undefined, { resources: true }); + const result = manager.close(session.id); - const updated = manager.get(session.id); - expect(updated?.serverCapabilities).toEqual({ resources: true }); + expect(result.success).toBe(false); + expect(result.error).toContain("Invalid transition"); + expect(manager.get(session.id)?.state).toBe(SessionState.CREATED); }); - test("only updates clientCapabilities when serverCapabilities is undefined", () => { + test("cannot close from INITIALIZING state", () => { + const config = { name: "test", transport: "stdio" as const }; + const session = manager.create(config); + manager.initialize(session.id); + + const result = manager.close(session.id); + + expect(result.success).toBe(false); + expect(result.error).toContain("Invalid transition"); + expect(manager.get(session.id)?.state).toBe(SessionState.INITIALIZING); + }); + + test("cannot transition from terminal CLOSED state", () => { + const config = { name: "test", transport: "stdio" as const }; + const session = manager.create(config); + manager.initialize(session.id); + manager.activate(session.id); + manager.close(session.id); + + const result = manager.initialize(session.id); + + expect(result.success).toBe(false); + expect(result.error).toContain("terminal state"); + }); + + test("cannot transition from terminal ERROR state", () => { + const config = { name: "test", transport: "stdio" as const }; + const session = manager.create(config); + manager.markError(session.id, "Test error"); + + const result = manager.initialize(session.id); + + expect(result.success).toBe(false); + expect(result.error).toContain("terminal state"); + }); + }); + + describe("updateState (deprecated)", () => { + test("still works with valid transitions", () => { + const config = { name: "test", transport: "stdio" as const }; + const session = manager.create(config); + + const result = manager.updateState(session.id, SessionState.INITIALIZING); + + expect(result.success).toBe(true); + expect(manager.get(session.id)?.state).toBe(SessionState.INITIALIZING); + }); + + test("rejects invalid transitions", () => { const config = { name: "test", transport: "stdio" as const }; const session = manager.create(config); - // First set both - manager.updateCapabilities( + const result = manager.updateState(session.id, SessionState.ACTIVE); + + expect(result.success).toBe(false); + expect(manager.get(session.id)?.state).toBe(SessionState.CREATED); + }); + }); + + describe("updateCapabilities", () => { + test("updates capabilities in ACTIVE state", () => { + const config = { name: "test", transport: "stdio" as const }; + const session = manager.create(config); + manager.initialize(session.id); + manager.activate(session.id); + + const result = manager.updateCapabilities( session.id, { tools: true }, { resources: true }, ); + expect(result.success).toBe(true); + const updated = manager.get(session.id); + expect(updated?.clientCapabilities).toEqual({ tools: true }); + expect(updated?.serverCapabilities).toEqual({ resources: true }); + }); + + test("only updates clientCapabilities when serverCapabilities is undefined", () => { + const config = { name: "test", transport: "stdio" as const }; + const session = manager.create(config); + manager.initialize(session.id); + manager.activate(session.id, { tools: true }, { resources: true }); + // Now update only client manager.updateCapabilities(session.id, { prompts: true }, undefined); @@ -215,13 +343,8 @@ describe("SessionManager", () => { test("only updates serverCapabilities when clientCapabilities is undefined", () => { const config = { name: "test", transport: "stdio" as const }; const session = manager.create(config); - - // First set both - manager.updateCapabilities( - session.id, - { tools: true }, - { resources: true }, - ); + manager.initialize(session.id); + manager.activate(session.id, { tools: true }, { resources: true }); // Now update only server manager.updateCapabilities(session.id, undefined, { sampling: true }); @@ -233,16 +356,29 @@ describe("SessionManager", () => { expect(updated?.serverCapabilities).toEqual({ sampling: true }); }); - test("does nothing for unknown session ID", () => { - // This should not throw - manager.updateCapabilities( + test("fails for non-ACTIVE sessions", () => { + const config = { name: "test", transport: "stdio" as const }; + const session = manager.create(config); + + const result = manager.updateCapabilities( + session.id, + { tools: true }, + undefined, + ); + + expect(result.success).toBe(false); + expect(result.error).toContain("Cannot update capabilities"); + }); + + test("returns error for unknown session ID", () => { + const result = manager.updateCapabilities( "non-existent", { tools: true }, { resources: true }, ); - // Verify session still doesn't exist - expect(manager.get("non-existent")).toBeUndefined(); + expect(result.success).toBe(false); + expect(result.error).toBe("Session not found"); }); }); @@ -273,4 +409,21 @@ describe("SessionManager", () => { expect(manager.count()).toBe(2); }); }); + + describe("timestamp updates", () => { + test("updates updatedAt on state transitions", async () => { + const config = { name: "test", transport: "stdio" as const }; + const session = manager.create(config); + const originalUpdatedAt = session.updatedAt; + + // Actual delay to ensure timestamp differs + await new Promise((r) => setTimeout(r, 5)); + manager.initialize(session.id); + + const updated = manager.get(session.id); + expect(updated?.updatedAt.getTime()).toBeGreaterThan( + originalUpdatedAt.getTime(), + ); + }); + }); }); diff --git a/packages/core/src/session/manager.ts b/packages/core/src/session/manager.ts index 775bd52..5c1ac8a 100644 --- a/packages/core/src/session/manager.ts +++ b/packages/core/src/session/manager.ts @@ -1,107 +1,241 @@ /** * Session Manager * - * Manages session lifecycle: create, get, list, close, updateState + * Manages MCP session lifecycle using XState actors. + * Enforces valid state transitions through the session state machine. */ +import { type ActorRefFrom, createActor } from "xstate"; +import { type ServerConfig, type Session, SessionState } from "../types"; import { - createSession, - type ServerConfig, - type Session, - SessionState, -} from "../types"; + type MachineStateValue, + type SessionContext, + STATE_VALUE_MAP, + sessionMachine, +} from "./session-machine"; + +type SessionActor = ActorRefFrom; + +/** + * Result of a state transition attempt. + */ +export interface TransitionResult { + success: boolean; + error?: string; +} export class SessionManager { - private sessions: Map = new Map(); + private actors: Map = new Map(); /** * Create a new session with the given server configuration. */ create(config: ServerConfig): Session { - const session = createSession(config); - this.sessions.set(session.id, session); - return session; + const id = crypto.randomUUID(); + + const actor = createActor(sessionMachine, { + input: { id, config }, + }); + actor.start(); + + this.actors.set(id, actor); + return this.snapshotToSession(actor); } /** * Get a session by ID. */ get(id: string): Session | undefined { - return this.sessions.get(id); + const actor = this.actors.get(id); + if (!actor) return undefined; + return this.snapshotToSession(actor); } /** * List all active sessions (not CLOSED or ERROR). */ list(): Session[] { - return Array.from(this.sessions.values()).filter( - (session) => - session.state !== SessionState.CLOSED && - session.state !== SessionState.ERROR, - ); + return Array.from(this.actors.values()) + .map((actor) => this.snapshotToSession(actor)) + .filter( + (session) => + session.state !== SessionState.CLOSED && + session.state !== SessionState.ERROR, + ); } /** * List all sessions including closed ones. */ listAll(): Session[] { - return Array.from(this.sessions.values()); + return Array.from(this.actors.values()).map((actor) => + this.snapshotToSession(actor), + ); } /** - * Close a session. + * Initialize a session (CREATED → INITIALIZING). */ - close(id: string): void { - const session = this.sessions.get(id); - if (session) { - session.state = SessionState.CLOSED; - session.updatedAt = new Date(); - } + initialize(id: string): TransitionResult { + return this.sendEvent(id, { type: "INITIALIZE" }); + } + + /** + * Activate a session with capabilities (INITIALIZING → ACTIVE). + */ + activate( + id: string, + clientCapabilities?: Record, + serverCapabilities?: Record, + protocolVersion?: string, + ): TransitionResult { + return this.sendEvent(id, { + type: "ACTIVATE", + clientCapabilities, + serverCapabilities, + protocolVersion, + }); + } + + /** + * Close a session (ACTIVE → CLOSED). + */ + close(id: string): TransitionResult { + return this.sendEvent(id, { type: "CLOSE" }); + } + + /** + * Mark a session as error (any state → ERROR). + */ + markError(id: string, reason?: string): TransitionResult { + return this.sendEvent(id, { type: "ERROR", reason }); } /** * Update session state. + * @deprecated Use specific transition methods (initialize, activate, close, markError) instead. + * This method is kept for backward compatibility but validates transitions. */ - updateState(id: string, state: SessionState): void { - const session = this.sessions.get(id); - if (session) { - session.state = state; - session.updatedAt = new Date(); + updateState(id: string, state: SessionState): TransitionResult { + // Map SessionState to events + const eventMap: Record = { + [SessionState.INITIALIZING]: { type: "INITIALIZE" }, + [SessionState.ACTIVE]: { type: "ACTIVATE" }, + [SessionState.CLOSED]: { type: "CLOSE" }, + [SessionState.ERROR]: { type: "ERROR" }, + }; + + const event = eventMap[state]; + if (!event) { + return { success: false, error: `Cannot transition to state: ${state}` }; } + + return this.sendEvent(id, event as Parameters[0]); } /** - * Update session capabilities. + * Update session capabilities (only valid in ACTIVE state). */ updateCapabilities( id: string, clientCapabilities?: Record, serverCapabilities?: Record, - ): void { - const session = this.sessions.get(id); - if (session) { - if (clientCapabilities) { - session.clientCapabilities = clientCapabilities; - } - if (serverCapabilities) { - session.serverCapabilities = serverCapabilities; - } - session.updatedAt = new Date(); + ): TransitionResult { + const actor = this.actors.get(id); + if (!actor) { + return { success: false, error: "Session not found" }; + } + + // Only allow capability updates in active state + const currentState = actor.getSnapshot().value as MachineStateValue; + if (currentState !== "active") { + return { + success: false, + error: `Cannot update capabilities in state: ${STATE_VALUE_MAP[currentState]}`, + }; } + + return this.sendEvent(id, { + type: "UPDATE_CAPABILITIES", + clientCapabilities, + serverCapabilities, + }); } /** - * Delete a session (remove from memory). + * Delete a session (remove from memory and stop actor). */ delete(id: string): boolean { - return this.sessions.delete(id); + const actor = this.actors.get(id); + if (!actor) return false; + + actor.stop(); + return this.actors.delete(id); } /** * Get count of sessions. */ count(): number { - return this.sessions.size; + return this.actors.size; + } + + /** + * Send an event to a session actor. + */ + private sendEvent( + id: string, + event: Parameters[0], + ): TransitionResult { + const actor = this.actors.get(id); + if (!actor) { + return { success: false, error: "Session not found" }; + } + + const beforeState = actor.getSnapshot().value as MachineStateValue; + + // Check if the actor is in a final state + if (actor.getSnapshot().status === "done") { + return { + success: false, + error: `Session is in terminal state: ${STATE_VALUE_MAP[beforeState]}`, + }; + } + + actor.send(event); + + const afterState = actor.getSnapshot().value as MachineStateValue; + + // If state didn't change and it wasn't an UPDATE_CAPABILITIES event, the transition was invalid + if (beforeState === afterState && event.type !== "UPDATE_CAPABILITIES") { + return { + success: false, + error: `Invalid transition: ${STATE_VALUE_MAP[beforeState]} + ${event.type}`, + }; + } + + return { success: true }; + } + + /** + * Convert actor snapshot to Session interface for backward compatibility. + */ + private snapshotToSession(actor: SessionActor): Session { + const snapshot = actor.getSnapshot(); + const context = snapshot.context as SessionContext; + const stateValue = snapshot.value as MachineStateValue; + + return { + id: context.id, + state: STATE_VALUE_MAP[stateValue], + createdAt: context.createdAt, + updatedAt: context.updatedAt, + config: context.config, + protocol: context.protocol, + protocolVersion: context.protocolVersion, + clientCapabilities: context.clientCapabilities, + serverCapabilities: context.serverCapabilities, + }; } } diff --git a/packages/core/src/session/session-machine.test.ts b/packages/core/src/session/session-machine.test.ts new file mode 100644 index 0000000..7668050 --- /dev/null +++ b/packages/core/src/session/session-machine.test.ts @@ -0,0 +1,324 @@ +/** + * Session State Machine Tests + * + * Tests the XState machine definition directly. + */ + +import { describe, expect, test } from "bun:test"; +import { createActor } from "xstate"; +import { STATE_VALUE_MAP, sessionMachine } from "./session-machine"; + +describe("Session State Machine", () => { + const testConfig = { + name: "test-server", + transport: "stdio" as const, + }; + + describe("initial state", () => { + test("starts in 'created' state", () => { + const actor = createActor(sessionMachine, { + input: { id: "test-id", config: testConfig }, + }); + actor.start(); + + expect(actor.getSnapshot().value).toBe("created"); + }); + + test("initializes context from input", () => { + const actor = createActor(sessionMachine, { + input: { + id: "custom-id", + config: testConfig, + protocol: "acp", + }, + }); + actor.start(); + + const context = actor.getSnapshot().context; + expect(context.id).toBe("custom-id"); + expect(context.config).toEqual(testConfig); + expect(context.protocol).toBe("acp"); + }); + + test("defaults protocol to 'mcp' when not specified", () => { + const actor = createActor(sessionMachine, { + input: { id: "test-id", config: testConfig }, + }); + actor.start(); + + expect(actor.getSnapshot().context.protocol).toBe("mcp"); + }); + }); + + describe("INITIALIZE event", () => { + test("transitions from 'created' to 'initializing'", () => { + const actor = createActor(sessionMachine, { + input: { id: "test-id", config: testConfig }, + }); + actor.start(); + + actor.send({ type: "INITIALIZE" }); + + expect(actor.getSnapshot().value).toBe("initializing"); + }); + + test("updates timestamp on transition", () => { + const actor = createActor(sessionMachine, { + input: { id: "test-id", config: testConfig }, + }); + actor.start(); + const _originalUpdatedAt = actor.getSnapshot().context.updatedAt; + + // Small delay to ensure timestamp difference + actor.send({ type: "INITIALIZE" }); + + // Timestamp should be updated (might be same if too fast, so just check it exists) + expect(actor.getSnapshot().context.updatedAt).toBeDefined(); + expect(actor.getSnapshot().context.updatedAt).toBeInstanceOf(Date); + }); + + test("is ignored in 'initializing' state", () => { + const actor = createActor(sessionMachine, { + input: { id: "test-id", config: testConfig }, + }); + actor.start(); + actor.send({ type: "INITIALIZE" }); + + // Send again + actor.send({ type: "INITIALIZE" }); + + // Should still be in 'initializing' + expect(actor.getSnapshot().value).toBe("initializing"); + }); + }); + + describe("ACTIVATE event", () => { + test("transitions from 'initializing' to 'active'", () => { + const actor = createActor(sessionMachine, { + input: { id: "test-id", config: testConfig }, + }); + actor.start(); + actor.send({ type: "INITIALIZE" }); + + actor.send({ type: "ACTIVATE" }); + + expect(actor.getSnapshot().value).toBe("active"); + }); + + test("stores capabilities in context", () => { + const actor = createActor(sessionMachine, { + input: { id: "test-id", config: testConfig }, + }); + actor.start(); + actor.send({ type: "INITIALIZE" }); + + actor.send({ + type: "ACTIVATE", + clientCapabilities: { tools: true }, + serverCapabilities: { resources: true }, + protocolVersion: "2024-11-05", + }); + + const context = actor.getSnapshot().context; + expect(context.clientCapabilities).toEqual({ tools: true }); + expect(context.serverCapabilities).toEqual({ resources: true }); + expect(context.protocolVersion).toBe("2024-11-05"); + }); + + test("is ignored in 'created' state", () => { + const actor = createActor(sessionMachine, { + input: { id: "test-id", config: testConfig }, + }); + actor.start(); + + actor.send({ type: "ACTIVATE" }); + + expect(actor.getSnapshot().value).toBe("created"); + }); + }); + + describe("CLOSE event", () => { + test("transitions from 'active' to 'closed'", () => { + const actor = createActor(sessionMachine, { + input: { id: "test-id", config: testConfig }, + }); + actor.start(); + actor.send({ type: "INITIALIZE" }); + actor.send({ type: "ACTIVATE" }); + + actor.send({ type: "CLOSE" }); + + expect(actor.getSnapshot().value).toBe("closed"); + }); + + test("is ignored in 'created' state", () => { + const actor = createActor(sessionMachine, { + input: { id: "test-id", config: testConfig }, + }); + actor.start(); + + actor.send({ type: "CLOSE" }); + + expect(actor.getSnapshot().value).toBe("created"); + }); + + test("is ignored in 'initializing' state", () => { + const actor = createActor(sessionMachine, { + input: { id: "test-id", config: testConfig }, + }); + actor.start(); + actor.send({ type: "INITIALIZE" }); + + actor.send({ type: "CLOSE" }); + + expect(actor.getSnapshot().value).toBe("initializing"); + }); + }); + + describe("ERROR event", () => { + test("transitions from 'created' to 'error'", () => { + const actor = createActor(sessionMachine, { + input: { id: "test-id", config: testConfig }, + }); + actor.start(); + + actor.send({ type: "ERROR", reason: "Connection failed" }); + + expect(actor.getSnapshot().value).toBe("error"); + expect(actor.getSnapshot().context.errorReason).toBe("Connection failed"); + }); + + test("transitions from 'initializing' to 'error'", () => { + const actor = createActor(sessionMachine, { + input: { id: "test-id", config: testConfig }, + }); + actor.start(); + actor.send({ type: "INITIALIZE" }); + + actor.send({ type: "ERROR", reason: "Init timeout" }); + + expect(actor.getSnapshot().value).toBe("error"); + }); + + test("transitions from 'active' to 'error'", () => { + const actor = createActor(sessionMachine, { + input: { id: "test-id", config: testConfig }, + }); + actor.start(); + actor.send({ type: "INITIALIZE" }); + actor.send({ type: "ACTIVATE" }); + + actor.send({ type: "ERROR", reason: "Server crashed" }); + + expect(actor.getSnapshot().value).toBe("error"); + }); + }); + + describe("UPDATE_CAPABILITIES event", () => { + test("updates capabilities in 'active' state", () => { + const actor = createActor(sessionMachine, { + input: { id: "test-id", config: testConfig }, + }); + actor.start(); + actor.send({ type: "INITIALIZE" }); + actor.send({ type: "ACTIVATE" }); + + actor.send({ + type: "UPDATE_CAPABILITIES", + clientCapabilities: { prompts: true }, + }); + + expect(actor.getSnapshot().context.clientCapabilities).toEqual({ + prompts: true, + }); + }); + + test("preserves existing capabilities when updating one side", () => { + const actor = createActor(sessionMachine, { + input: { id: "test-id", config: testConfig }, + }); + actor.start(); + actor.send({ type: "INITIALIZE" }); + actor.send({ + type: "ACTIVATE", + clientCapabilities: { tools: true }, + serverCapabilities: { resources: true }, + }); + + // Update only client capabilities + actor.send({ + type: "UPDATE_CAPABILITIES", + clientCapabilities: { prompts: true }, + }); + + const context = actor.getSnapshot().context; + expect(context.clientCapabilities).toEqual({ prompts: true }); + expect(context.serverCapabilities).toEqual({ resources: true }); // Preserved + }); + + test("is ignored in non-active states", () => { + const actor = createActor(sessionMachine, { + input: { id: "test-id", config: testConfig }, + }); + actor.start(); + + actor.send({ + type: "UPDATE_CAPABILITIES", + clientCapabilities: { tools: true }, + }); + + expect(actor.getSnapshot().context.clientCapabilities).toBeUndefined(); + }); + }); + + describe("terminal states", () => { + test("'closed' is a final state", () => { + const actor = createActor(sessionMachine, { + input: { id: "test-id", config: testConfig }, + }); + actor.start(); + actor.send({ type: "INITIALIZE" }); + actor.send({ type: "ACTIVATE" }); + actor.send({ type: "CLOSE" }); + + expect(actor.getSnapshot().status).toBe("done"); + }); + + test("'error' is a final state", () => { + const actor = createActor(sessionMachine, { + input: { id: "test-id", config: testConfig }, + }); + actor.start(); + actor.send({ type: "ERROR" }); + + expect(actor.getSnapshot().status).toBe("done"); + }); + + test("no events affect 'closed' state", () => { + const actor = createActor(sessionMachine, { + input: { id: "test-id", config: testConfig }, + }); + actor.start(); + actor.send({ type: "INITIALIZE" }); + actor.send({ type: "ACTIVATE" }); + actor.send({ type: "CLOSE" }); + + // Try all events + actor.send({ type: "INITIALIZE" }); + actor.send({ type: "ACTIVATE" }); + actor.send({ type: "ERROR" }); + + expect(actor.getSnapshot().value).toBe("closed"); + }); + }); + + describe("STATE_VALUE_MAP", () => { + test("maps all machine states to SessionState values", () => { + expect(STATE_VALUE_MAP.created).toBe("CREATED"); + expect(STATE_VALUE_MAP.initializing).toBe("INITIALIZING"); + expect(STATE_VALUE_MAP.active).toBe("ACTIVE"); + expect(STATE_VALUE_MAP.closed).toBe("CLOSED"); + expect(STATE_VALUE_MAP.error).toBe("ERROR"); + }); + }); +}); diff --git a/packages/core/src/session/session-machine.ts b/packages/core/src/session/session-machine.ts new file mode 100644 index 0000000..53f5c07 --- /dev/null +++ b/packages/core/src/session/session-machine.ts @@ -0,0 +1,169 @@ +/** + * Session State Machine + * + * XState v5 machine definition for session lifecycle management. + * Enforces valid state transitions and provides type-safe events. + */ + +import { assign, setup } from "xstate"; +import type { ServerConfig } from "../types"; + +// ============================================================================= +// Types +// ============================================================================= + +export interface SessionContext { + id: string; + config: ServerConfig; + protocol: "mcp" | "acp" | "a2a"; + protocolVersion?: string; + clientCapabilities?: Record; + serverCapabilities?: Record; + createdAt: Date; + updatedAt: Date; + errorReason?: string; +} + +export type SessionEvent = + | { type: "INITIALIZE" } + | { + type: "ACTIVATE"; + clientCapabilities?: Record; + serverCapabilities?: Record; + protocolVersion?: string; + } + | { type: "CLOSE" } + | { type: "ERROR"; reason?: string } + | { + type: "UPDATE_CAPABILITIES"; + clientCapabilities?: Record; + serverCapabilities?: Record; + }; + +export interface SessionInput { + id: string; + config: ServerConfig; + protocol?: "mcp" | "acp" | "a2a"; +} + +// ============================================================================= +// Machine Definition +// ============================================================================= + +export const sessionMachine = setup({ + types: { + context: {} as SessionContext, + events: {} as SessionEvent, + input: {} as SessionInput, + }, + actions: { + updateTimestamp: assign({ + updatedAt: () => new Date(), + }), + setCapabilities: assign({ + clientCapabilities: ({ context, event }) => { + if (event.type === "ACTIVATE" || event.type === "UPDATE_CAPABILITIES") { + return event.clientCapabilities ?? context.clientCapabilities; + } + return context.clientCapabilities; + }, + serverCapabilities: ({ context, event }) => { + if (event.type === "ACTIVATE" || event.type === "UPDATE_CAPABILITIES") { + return event.serverCapabilities ?? context.serverCapabilities; + } + return context.serverCapabilities; + }, + protocolVersion: ({ context, event }) => { + if (event.type === "ACTIVATE" && event.protocolVersion) { + return event.protocolVersion; + } + return context.protocolVersion; + }, + updatedAt: () => new Date(), + }), + setError: assign({ + errorReason: ({ event }) => { + if (event.type === "ERROR") { + return event.reason; + } + return undefined; + }, + updatedAt: () => new Date(), + }), + }, +}).createMachine({ + id: "session", + initial: "created", + context: ({ input }) => ({ + id: input.id, + config: input.config, + protocol: input.protocol ?? "mcp", + createdAt: new Date(), + updatedAt: new Date(), + }), + states: { + created: { + on: { + INITIALIZE: { + target: "initializing", + actions: "updateTimestamp", + }, + ERROR: { + target: "error", + actions: "setError", + }, + }, + }, + initializing: { + on: { + ACTIVATE: { + target: "active", + actions: "setCapabilities", + }, + ERROR: { + target: "error", + actions: "setError", + }, + }, + }, + active: { + on: { + UPDATE_CAPABILITIES: { + actions: "setCapabilities", + }, + CLOSE: { + target: "closed", + actions: "updateTimestamp", + }, + ERROR: { + target: "error", + actions: "setError", + }, + }, + }, + closed: { + type: "final", + }, + error: { + type: "final", + }, + }, +}); + +// ============================================================================= +// State Mapping +// ============================================================================= + +/** + * Maps XState state values to SessionState enum values. + * This maintains backward compatibility with existing code. + */ +export const STATE_VALUE_MAP = { + created: "CREATED", + initializing: "INITIALIZING", + active: "ACTIVE", + closed: "CLOSED", + error: "ERROR", +} as const; + +export type MachineStateValue = keyof typeof STATE_VALUE_MAP; diff --git a/packages/core/src/store/message-store.ts b/packages/core/src/store/message-store.ts index 48ec305..03182e0 100644 --- a/packages/core/src/store/message-store.ts +++ b/packages/core/src/store/message-store.ts @@ -77,11 +77,13 @@ export class MessageStore { } if (filter.startTime) { - results = results.filter((m) => m.timestamp >= filter.startTime!); + const startTime = filter.startTime; + results = results.filter((m) => m.timestamp >= startTime); } if (filter.endTime) { - results = results.filter((m) => m.timestamp <= filter.endTime!); + const endTime = filter.endTime; + results = results.filter((m) => m.timestamp <= endTime); } return results; diff --git a/scripts/check-assertion-density.ts b/scripts/check-assertion-density.ts index 1debb68..c2682d8 100644 --- a/scripts/check-assertion-density.ts +++ b/scripts/check-assertion-density.ts @@ -26,7 +26,7 @@ interface FileStats { // Configuration const MIN_DENSITY = 1.0; // Minimum assertions per test (1.0 is baseline, 1.5 is recommended) -const TEST_PATTERNS = ["*.test.ts", "*.spec.ts"]; +const _TEST_PATTERNS = ["*.test.ts", "*.spec.ts"]; // For documentation const EXCLUDE_PATTERNS = ["node_modules", "dist", ".stryker-tmp"]; // Patterns to count