diff --git a/packages/core/src/e2e.test.ts b/packages/core/src/e2e.test.ts index f75bb06..bab932a 100644 --- a/packages/core/src/e2e.test.ts +++ b/packages/core/src/e2e.test.ts @@ -40,6 +40,7 @@ describe("Core E2E", () => { expect(session.state).toBe(SessionState.CREATED); // 2. Transition through proper lifecycle to ACTIVE + sessionManager.connect(session.id); sessionManager.initialize(session.id); sessionManager.activate(session.id); expect(sessionManager.get(session.id)?.state).toBe(SessionState.ACTIVE); @@ -259,6 +260,11 @@ describe("Core E2E", () => { expect(session.state).toBe(SessionState.CREATED); + sessionManager.connect(session.id); + expect(sessionManager.get(session.id)?.state).toBe( + SessionState.CONNECTING, + ); + sessionManager.initialize(session.id); expect(sessionManager.get(session.id)?.state).toBe( SessionState.INITIALIZING, diff --git a/packages/core/src/index.test.ts b/packages/core/src/index.test.ts index ad517b5..700079c 100644 --- a/packages/core/src/index.test.ts +++ b/packages/core/src/index.test.ts @@ -21,6 +21,7 @@ describe("@say2/core", () => { it("exports SessionState enum with all lifecycle states", () => { // Verify all required states exist expect(SessionState.CREATED).toBe("CREATED"); + expect(SessionState.CONNECTING).toBe("CONNECTING"); expect(SessionState.INITIALIZING).toBe("INITIALIZING"); expect(SessionState.ACTIVE).toBe("ACTIVE"); expect(SessionState.CLOSED).toBe("CLOSED"); diff --git a/packages/core/src/session/manager.test.ts b/packages/core/src/session/manager.test.ts index fe0f6b6..04d3899 100644 --- a/packages/core/src/session/manager.test.ts +++ b/packages/core/src/session/manager.test.ts @@ -83,6 +83,7 @@ describe("SessionManager", () => { manager.create(config); // Must go through valid transitions to close + manager.connect(session1.id); manager.initialize(session1.id); manager.activate(session1.id); manager.close(session1.id); @@ -112,6 +113,7 @@ describe("SessionManager", () => { const s3 = manager.create(config); // Close s1 (must go through valid transitions) + manager.connect(s1.id); manager.initialize(s1.id); manager.activate(s1.id); manager.close(s1.id); @@ -133,10 +135,21 @@ describe("SessionManager", () => { }); describe("state transitions", () => { - test("initialize transitions from CREATED to INITIALIZING", () => { + test("connect transitions from CREATED to CONNECTING", () => { const config = { name: "test", transport: "stdio" as const }; const session = manager.create(config); + const result = manager.connect(session.id); + + expect(result.success).toBe(true); + expect(manager.get(session.id)?.state).toBe(SessionState.CONNECTING); + }); + + test("initialize transitions from CONNECTING to INITIALIZING", () => { + const config = { name: "test", transport: "stdio" as const }; + const session = manager.create(config); + + manager.connect(session.id); const result = manager.initialize(session.id); expect(result.success).toBe(true); @@ -147,6 +160,7 @@ describe("SessionManager", () => { const config = { name: "test", transport: "stdio" as const }; const session = manager.create(config); + manager.connect(session.id); manager.initialize(session.id); const result = manager.activate(session.id); @@ -158,6 +172,7 @@ describe("SessionManager", () => { const config = { name: "test", transport: "stdio" as const }; const session = manager.create(config); + manager.connect(session.id); manager.initialize(session.id); manager.activate( session.id, @@ -176,6 +191,7 @@ describe("SessionManager", () => { const config = { name: "test", transport: "stdio" as const }; const session = manager.create(config); + manager.connect(session.id); manager.initialize(session.id); manager.activate(session.id); const result = manager.close(session.id); @@ -192,14 +208,22 @@ describe("SessionManager", () => { expect(manager.markError(s1.id, "Error 1").success).toBe(true); expect(manager.get(s1.id)?.state).toBe(SessionState.ERROR); + // From CONNECTING + const s1b = manager.create(config); + manager.connect(s1b.id); + expect(manager.markError(s1b.id, "Error 1b").success).toBe(true); + expect(manager.get(s1b.id)?.state).toBe(SessionState.ERROR); + // From INITIALIZING const s2 = manager.create(config); + manager.connect(s2.id); 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.connect(s3.id); manager.initialize(s3.id); manager.activate(s3.id); expect(manager.markError(s3.id, "Error 3").success).toBe(true); @@ -212,6 +236,9 @@ describe("SessionManager", () => { expect(session.state).toBe(SessionState.CREATED); + manager.connect(session.id); + expect(manager.get(session.id)?.state).toBe(SessionState.CONNECTING); + manager.initialize(session.id); expect(manager.get(session.id)?.state).toBe(SessionState.INITIALIZING); @@ -249,6 +276,7 @@ describe("SessionManager", () => { test("cannot close from INITIALIZING state", () => { const config = { name: "test", transport: "stdio" as const }; const session = manager.create(config); + manager.connect(session.id); manager.initialize(session.id); const result = manager.close(session.id); @@ -261,11 +289,12 @@ describe("SessionManager", () => { test("cannot transition from terminal CLOSED state", () => { const config = { name: "test", transport: "stdio" as const }; const session = manager.create(config); + manager.connect(session.id); manager.initialize(session.id); manager.activate(session.id); manager.close(session.id); - const result = manager.initialize(session.id); + const result = manager.connect(session.id); expect(result.success).toBe(false); expect(result.error).toContain("terminal state"); @@ -276,39 +305,18 @@ describe("SessionManager", () => { const session = manager.create(config); manager.markError(session.id, "Test error"); - const result = manager.initialize(session.id); + const result = manager.connect(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); - - 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.connect(session.id); manager.initialize(session.id); manager.activate(session.id); @@ -327,6 +335,7 @@ describe("SessionManager", () => { test("only updates clientCapabilities when serverCapabilities is undefined", () => { const config = { name: "test", transport: "stdio" as const }; const session = manager.create(config); + manager.connect(session.id); manager.initialize(session.id); manager.activate(session.id, { tools: true }, { resources: true }); @@ -343,6 +352,7 @@ describe("SessionManager", () => { test("only updates serverCapabilities when clientCapabilities is undefined", () => { const config = { name: "test", transport: "stdio" as const }; const session = manager.create(config); + manager.connect(session.id); manager.initialize(session.id); manager.activate(session.id, { tools: true }, { resources: true }); @@ -418,7 +428,7 @@ describe("SessionManager", () => { // Actual delay to ensure timestamp differs await new Promise((r) => setTimeout(r, 5)); - manager.initialize(session.id); + manager.connect(session.id); const updated = manager.get(session.id); expect(updated?.updatedAt.getTime()).toBeGreaterThan( diff --git a/packages/core/src/session/manager.ts b/packages/core/src/session/manager.ts index 5c1ac8a..e826918 100644 --- a/packages/core/src/session/manager.ts +++ b/packages/core/src/session/manager.ts @@ -74,7 +74,16 @@ export class SessionManager { } /** - * Initialize a session (CREATED → INITIALIZING). + * Connect a session (CREATED → CONNECTING). + * This initiates the transport connection. + */ + connect(id: string): TransitionResult { + return this.sendEvent(id, { type: "CONNECT" }); + } + + /** + * Initialize a session (CONNECTING → INITIALIZING). + * This begins the MCP protocol handshake. */ initialize(id: string): TransitionResult { return this.sendEvent(id, { type: "INITIALIZE" }); @@ -111,28 +120,6 @@ export class SessionManager { 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): 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 (only valid in ACTIVE state). */ diff --git a/packages/core/src/session/session-machine.test.ts b/packages/core/src/session/session-machine.test.ts index 7668050..2156737 100644 --- a/packages/core/src/session/session-machine.test.ts +++ b/packages/core/src/session/session-machine.test.ts @@ -50,16 +50,16 @@ describe("Session State Machine", () => { }); }); - describe("INITIALIZE event", () => { - test("transitions from 'created' to 'initializing'", () => { + describe("CONNECT event", () => { + test("transitions from 'created' to 'connecting'", () => { const actor = createActor(sessionMachine, { input: { id: "test-id", config: testConfig }, }); actor.start(); - actor.send({ type: "INITIALIZE" }); + actor.send({ type: "CONNECT" }); - expect(actor.getSnapshot().value).toBe("initializing"); + expect(actor.getSnapshot().value).toBe("connecting"); }); test("updates timestamp on transition", () => { @@ -70,18 +70,58 @@ describe("Session State Machine", () => { const _originalUpdatedAt = actor.getSnapshot().context.updatedAt; // Small delay to ensure timestamp difference - actor.send({ type: "INITIALIZE" }); + actor.send({ type: "CONNECT" }); // 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 'connecting' state", () => { + const actor = createActor(sessionMachine, { + input: { id: "test-id", config: testConfig }, + }); + actor.start(); + actor.send({ type: "CONNECT" }); + + // Send again + actor.send({ type: "CONNECT" }); + + // Should still be in 'connecting' + expect(actor.getSnapshot().value).toBe("connecting"); + }); + }); + + describe("INITIALIZE event", () => { + test("transitions from 'connecting' to 'initializing'", () => { + const actor = createActor(sessionMachine, { + input: { id: "test-id", config: testConfig }, + }); + actor.start(); + actor.send({ type: "CONNECT" }); + + actor.send({ type: "INITIALIZE" }); + + expect(actor.getSnapshot().value).toBe("initializing"); + }); + + test("is ignored in 'created' state", () => { + const actor = createActor(sessionMachine, { + input: { id: "test-id", config: testConfig }, + }); + actor.start(); + + actor.send({ type: "INITIALIZE" }); + + 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: "CONNECT" }); actor.send({ type: "INITIALIZE" }); // Send again @@ -98,6 +138,7 @@ describe("Session State Machine", () => { input: { id: "test-id", config: testConfig }, }); actor.start(); + actor.send({ type: "CONNECT" }); actor.send({ type: "INITIALIZE" }); actor.send({ type: "ACTIVATE" }); @@ -110,6 +151,7 @@ describe("Session State Machine", () => { input: { id: "test-id", config: testConfig }, }); actor.start(); + actor.send({ type: "CONNECT" }); actor.send({ type: "INITIALIZE" }); actor.send({ @@ -135,6 +177,18 @@ describe("Session State Machine", () => { expect(actor.getSnapshot().value).toBe("created"); }); + + test("is ignored in 'connecting' state", () => { + const actor = createActor(sessionMachine, { + input: { id: "test-id", config: testConfig }, + }); + actor.start(); + actor.send({ type: "CONNECT" }); + + actor.send({ type: "ACTIVATE" }); + + expect(actor.getSnapshot().value).toBe("connecting"); + }); }); describe("CLOSE event", () => { @@ -143,6 +197,7 @@ describe("Session State Machine", () => { input: { id: "test-id", config: testConfig }, }); actor.start(); + actor.send({ type: "CONNECT" }); actor.send({ type: "INITIALIZE" }); actor.send({ type: "ACTIVATE" }); @@ -162,11 +217,24 @@ describe("Session State Machine", () => { expect(actor.getSnapshot().value).toBe("created"); }); + test("is ignored in 'connecting' state", () => { + const actor = createActor(sessionMachine, { + input: { id: "test-id", config: testConfig }, + }); + actor.start(); + actor.send({ type: "CONNECT" }); + + actor.send({ type: "CLOSE" }); + + expect(actor.getSnapshot().value).toBe("connecting"); + }); + test("is ignored in 'initializing' state", () => { const actor = createActor(sessionMachine, { input: { id: "test-id", config: testConfig }, }); actor.start(); + actor.send({ type: "CONNECT" }); actor.send({ type: "INITIALIZE" }); actor.send({ type: "CLOSE" }); @@ -182,6 +250,19 @@ describe("Session State Machine", () => { }); actor.start(); + actor.send({ type: "ERROR", reason: "Config error" }); + + expect(actor.getSnapshot().value).toBe("error"); + expect(actor.getSnapshot().context.errorReason).toBe("Config error"); + }); + + test("transitions from 'connecting' to 'error'", () => { + const actor = createActor(sessionMachine, { + input: { id: "test-id", config: testConfig }, + }); + actor.start(); + actor.send({ type: "CONNECT" }); + actor.send({ type: "ERROR", reason: "Connection failed" }); expect(actor.getSnapshot().value).toBe("error"); @@ -193,6 +274,7 @@ describe("Session State Machine", () => { input: { id: "test-id", config: testConfig }, }); actor.start(); + actor.send({ type: "CONNECT" }); actor.send({ type: "INITIALIZE" }); actor.send({ type: "ERROR", reason: "Init timeout" }); @@ -205,6 +287,7 @@ describe("Session State Machine", () => { input: { id: "test-id", config: testConfig }, }); actor.start(); + actor.send({ type: "CONNECT" }); actor.send({ type: "INITIALIZE" }); actor.send({ type: "ACTIVATE" }); @@ -220,6 +303,7 @@ describe("Session State Machine", () => { input: { id: "test-id", config: testConfig }, }); actor.start(); + actor.send({ type: "CONNECT" }); actor.send({ type: "INITIALIZE" }); actor.send({ type: "ACTIVATE" }); @@ -238,6 +322,7 @@ describe("Session State Machine", () => { input: { id: "test-id", config: testConfig }, }); actor.start(); + actor.send({ type: "CONNECT" }); actor.send({ type: "INITIALIZE" }); actor.send({ type: "ACTIVATE", @@ -277,6 +362,7 @@ describe("Session State Machine", () => { input: { id: "test-id", config: testConfig }, }); actor.start(); + actor.send({ type: "CONNECT" }); actor.send({ type: "INITIALIZE" }); actor.send({ type: "ACTIVATE" }); actor.send({ type: "CLOSE" }); @@ -299,11 +385,13 @@ describe("Session State Machine", () => { input: { id: "test-id", config: testConfig }, }); actor.start(); + actor.send({ type: "CONNECT" }); actor.send({ type: "INITIALIZE" }); actor.send({ type: "ACTIVATE" }); actor.send({ type: "CLOSE" }); // Try all events + actor.send({ type: "CONNECT" }); actor.send({ type: "INITIALIZE" }); actor.send({ type: "ACTIVATE" }); actor.send({ type: "ERROR" }); @@ -315,6 +403,7 @@ describe("Session State Machine", () => { describe("STATE_VALUE_MAP", () => { test("maps all machine states to SessionState values", () => { expect(STATE_VALUE_MAP.created).toBe("CREATED"); + expect(STATE_VALUE_MAP.connecting).toBe("CONNECTING"); expect(STATE_VALUE_MAP.initializing).toBe("INITIALIZING"); expect(STATE_VALUE_MAP.active).toBe("ACTIVE"); expect(STATE_VALUE_MAP.closed).toBe("CLOSED"); diff --git a/packages/core/src/session/session-machine.ts b/packages/core/src/session/session-machine.ts index 53f5c07..d55c4fc 100644 --- a/packages/core/src/session/session-machine.ts +++ b/packages/core/src/session/session-machine.ts @@ -25,6 +25,7 @@ export interface SessionContext { } export type SessionEvent = + | { type: "CONNECT" } | { type: "INITIALIZE" } | { type: "ACTIVATE"; @@ -103,6 +104,18 @@ export const sessionMachine = setup({ }), states: { created: { + on: { + CONNECT: { + target: "connecting", + actions: "updateTimestamp", + }, + ERROR: { + target: "error", + actions: "setError", + }, + }, + }, + connecting: { on: { INITIALIZE: { target: "initializing", @@ -160,6 +173,7 @@ export const sessionMachine = setup({ */ export const STATE_VALUE_MAP = { created: "CREATED", + connecting: "CONNECTING", initializing: "INITIALIZING", active: "ACTIVE", closed: "CLOSED", diff --git a/packages/core/src/types/index.test.ts b/packages/core/src/types/index.test.ts index d56d12c..d5c43d2 100644 --- a/packages/core/src/types/index.test.ts +++ b/packages/core/src/types/index.test.ts @@ -20,6 +20,7 @@ describe("Core Types", () => { describe("Enums", () => { test("SessionState has correct values", () => { expect(SessionState.CREATED).toBe("CREATED"); + expect(SessionState.CONNECTING).toBe("CONNECTING"); expect(SessionState.INITIALIZING).toBe("INITIALIZING"); expect(SessionState.ACTIVE).toBe("ACTIVE"); expect(SessionState.CLOSED).toBe("CLOSED"); @@ -103,7 +104,14 @@ describe("Core Types", () => { }); test("tracks lifecycle state", () => { - const states = ["CREATED", "INITIALIZING", "ACTIVE", "CLOSED", "ERROR"]; + const states = [ + "CREATED", + "CONNECTING", + "INITIALIZING", + "ACTIVE", + "CLOSED", + "ERROR", + ]; for (const state of states) { const session = { id: crypto.randomUUID(), diff --git a/packages/core/src/types/index.ts b/packages/core/src/types/index.ts index b027a82..6fc597b 100644 --- a/packages/core/src/types/index.ts +++ b/packages/core/src/types/index.ts @@ -12,6 +12,7 @@ import { z } from "zod"; export const SessionState = { CREATED: "CREATED", + CONNECTING: "CONNECTING", INITIALIZING: "INITIALIZING", ACTIVE: "ACTIVE", CLOSED: "CLOSED", @@ -65,7 +66,14 @@ export type ServerConfig = z.infer; export const SessionSchema = z.object({ id: z.string().uuid(), - state: z.enum(["CREATED", "INITIALIZING", "ACTIVE", "CLOSED", "ERROR"]), + state: z.enum([ + "CREATED", + "CONNECTING", + "INITIALIZING", + "ACTIVE", + "CLOSED", + "ERROR", + ]), createdAt: z.date(), updatedAt: z.date(), config: ServerConfigSchema, diff --git a/packages/core/src/types/property-based.test.ts b/packages/core/src/types/property-based.test.ts index 5341882..156ec95 100644 --- a/packages/core/src/types/property-based.test.ts +++ b/packages/core/src/types/property-based.test.ts @@ -308,7 +308,7 @@ describe("Property-Based Tests", () => { ), ).toBe(true); // Property: Has exactly 5 states - expect(Object.values(SessionState).length).toBe(5); + expect(Object.values(SessionState).length).toBe(6); }); }); });