From 72eb0827ec96e34838bdd7753385f8fd3f381c13 Mon Sep 17 00:00:00 2001 From: Vineeth Voruganti <13438633+VVoruganti@users.noreply.github.com> Date: Thu, 4 Dec 2025 14:51:39 -0500 Subject: [PATCH] Add session cloning to Ergonomic SDKs (#285) * feat: Add session cloning to Ergonomic SDKs * chore: docs nit --- docs/v2/documentation/reference/platform.mdx | 7 +- docs/v2/documentation/reference/sdk.mdx | 16 ++ .../python/src/honcho/async_client/session.py | 46 ++++++ sdks/python/src/honcho/session.py | 46 ++++++ sdks/typescript/__tests__/session.test.ts | 69 +++++++++ sdks/typescript/src/session.ts | 38 +++++ src/crud/session.py | 14 +- tests/sdk/test_session.py | 142 ++++++++++++++++++ 8 files changed, 372 insertions(+), 6 deletions(-) diff --git a/docs/v2/documentation/reference/platform.mdx b/docs/v2/documentation/reference/platform.mdx index 3b01717a..426af86f 100644 --- a/docs/v2/documentation/reference/platform.mdx +++ b/docs/v2/documentation/reference/platform.mdx @@ -34,13 +34,12 @@ the Welcome page with integration guidance and links to documentation. Each organization has dedicated infrastructure running to isolate your -workloads. Until you activate a subscription under the -[Billing](https://app.honcho.dev/billing) page, the infrastructure will remain -inactive. +workloads. Once you add a valid payment method under the +[Billing](https://app.honcho.dev/billing) page, your instance will turn on. ## 2. Activate your Honcho instance -Navigate to the [Billing](https://app.honcho.dev/billing) page to activate your subscription. Your Honcho instance provisions automatically, and you can monitor the deployment on the [Instance Status](https://app.honcho.dev/status) page until all systems show a green check mark. +Navigate to the [Billing](https://app.honcho.dev/billing) page to add a payment method. Your Honcho instance provisions automatically, and you can monitor the deployment on the [Instance Status](https://app.honcho.dev/status) page until all systems show a green check mark. Instance Status Page diff --git a/docs/v2/documentation/reference/sdk.mdx b/docs/v2/documentation/reference/sdk.mdx index 8cf1fd90..f16005b6 100644 --- a/docs/v2/documentation/reference/sdk.mdx +++ b/docs/v2/documentation/reference/sdk.mdx @@ -509,6 +509,14 @@ messages = session.upload_file( created_at="2024-01-15T10:30:00Z" ) +# Clone a session (creates a copy with all data) +# Copies: messages, metadata, configuration, peers, and peer configurations +cloned = session.clone() + +# Clone up to a specific message (inclusive) +# Only messages up to and including the specified message are copied +cloned_partial = session.clone(message_id="msg-123") + # Delete session (async - returns 202) session.delete() @@ -581,6 +589,14 @@ const messages = await session.uploadFile( } ); +// Clone a session (creates a copy with all data) +// Copies: messages, metadata, configuration, peers, and peer configurations +const cloned = await session.clone(); + +// Clone up to a specific message (inclusive) +// Only messages up to and including the specified message are copied +const clonedPartial = await session.clone("msg-123"); + // Delete session (async - returns 202) await session.delete(); diff --git a/sdks/python/src/honcho/async_client/session.py b/sdks/python/src/honcho/async_client/session.py index 70390716..01215a14 100644 --- a/sdks/python/src/honcho/async_client/session.py +++ b/sdks/python/src/honcho/async_client/session.py @@ -416,6 +416,52 @@ class AsyncSession(BaseModel): workspace_id=self.workspace_id, ) + async def clone( + self, + *, + message_id: str | None = None, + ) -> "AsyncSession": + """ + Clone this session, optionally up to a specific message. + + Makes an async API call to create a copy of this session with a new ID. + All messages and peers from the original session are copied to the new session. + If a message_id is provided, only messages up to and including that message + are copied. + + Args: + message_id: Optional message ID to cut off the clone at. If provided, + the cloned session will only contain messages up to and + including this message. + + Returns: + A new AsyncSession object representing the cloned session + + Example: + ```python + # Clone entire session + cloned = await session.clone() + + # Clone session up to a specific message + cloned = await session.clone(message_id="msg_abc123") + ``` + """ + # Make the API call using the core SDK's clone method + cloned_session_data = await self._client.workspaces.sessions.clone( + session_id=self.id, + workspace_id=self.workspace_id, + message_id=message_id if message_id is not None else omit, + ) + + # Return a new AsyncSession object with the cloned session's data + return AsyncSession( + cloned_session_data.id, + self.workspace_id, + self._client, + metadata=cloned_session_data.metadata, + config=cloned_session_data.configuration, + ) + async def get_metadata(self) -> dict[str, object]: """ Get metadata for this session. diff --git a/sdks/python/src/honcho/session.py b/sdks/python/src/honcho/session.py index ef8e1e01..e8278c1c 100644 --- a/sdks/python/src/honcho/session.py +++ b/sdks/python/src/honcho/session.py @@ -406,6 +406,52 @@ class Session(BaseModel): workspace_id=self.workspace_id, ) + def clone( + self, + *, + message_id: str | None = None, + ) -> "Session": + """ + Clone this session, optionally up to a specific message. + + Makes an API call to create a copy of this session with a new ID. + All messages and peers from the original session are copied to the new session. + If a message_id is provided, only messages up to and including that message + are copied. + + Args: + message_id: Optional message ID to cut off the clone at. If provided, + the cloned session will only contain messages up to and + including this message. + + Returns: + A new Session object representing the cloned session + + Example: + ```python + # Clone entire session + cloned = session.clone() + + # Clone session up to a specific message + cloned = session.clone(message_id="msg_abc123") + ``` + """ + # Make the API call using the core SDK's clone method + cloned_session_data = self._client.workspaces.sessions.clone( + session_id=self.id, + workspace_id=self.workspace_id, + message_id=message_id if message_id is not None else omit, + ) + + # Return a new Session object with the cloned session's data + return Session( + cloned_session_data.id, + self.workspace_id, + self._client, + metadata=cloned_session_data.metadata, + config=cloned_session_data.configuration, + ) + @validate_call def set_metadata( self, diff --git a/sdks/typescript/__tests__/session.test.ts b/sdks/typescript/__tests__/session.test.ts index e53063c4..2be051b1 100644 --- a/sdks/typescript/__tests__/session.test.ts +++ b/sdks/typescript/__tests__/session.test.ts @@ -26,6 +26,7 @@ jest.mock('@honcho-ai/core', () => { getOrCreate: jest.fn(), update: jest.fn(), delete: jest.fn(), + clone: jest.fn(), getContext: jest.fn(), search: jest.fn(), }, @@ -1055,6 +1056,74 @@ describe('Session', () => { }) }) + describe('clone', () => { + it('should clone the session without messageId', async () => { + const mockClonedSession = { + id: 'cloned-session-id', + workspace_id: 'test-workspace', + metadata: { cloned: true }, + configuration: { test: 'config' }, + } + mockClient.workspaces.sessions.clone.mockResolvedValue(mockClonedSession) + + const clonedSession = await session.clone() + + expect(clonedSession).toBeInstanceOf(Session) + expect(clonedSession.id).toBe('cloned-session-id') + expect(clonedSession.workspaceId).toBe('test-workspace') + expect(clonedSession.metadata).toEqual({ cloned: true }) + expect(clonedSession.configuration).toEqual({ test: 'config' }) + expect(mockClient.workspaces.sessions.clone).toHaveBeenCalledWith( + 'test-workspace', + 'test-session', + {} + ) + }) + + it('should clone the session with messageId cutoff', async () => { + const mockClonedSession = { + id: 'cloned-session-cutoff', + workspace_id: 'test-workspace', + metadata: null, + configuration: null, + } + mockClient.workspaces.sessions.clone.mockResolvedValue(mockClonedSession) + + const clonedSession = await session.clone('msg-123') + + expect(clonedSession).toBeInstanceOf(Session) + expect(clonedSession.id).toBe('cloned-session-cutoff') + expect(mockClient.workspaces.sessions.clone).toHaveBeenCalledWith( + 'test-workspace', + 'test-session', + { message_id: 'msg-123' } + ) + }) + + it('should handle null metadata and configuration', async () => { + const mockClonedSession = { + id: 'cloned-session-null', + workspace_id: 'test-workspace', + metadata: null, + configuration: null, + } + mockClient.workspaces.sessions.clone.mockResolvedValue(mockClonedSession) + + const clonedSession = await session.clone() + + expect(clonedSession.metadata).toBeUndefined() + expect(clonedSession.configuration).toBeUndefined() + }) + + it('should handle API errors', async () => { + mockClient.workspaces.sessions.clone.mockRejectedValue( + new Error('Failed to clone session') + ) + + await expect(session.clone()).rejects.toThrow('Failed to clone session') + }) + }) + describe('getDeriverStatus', () => { it('should return deriver status without options', async () => { const mockStatus = { diff --git a/sdks/typescript/src/session.ts b/sdks/typescript/src/session.ts index 9ab7df55..b8d2dc30 100644 --- a/sdks/typescript/src/session.ts +++ b/sdks/typescript/src/session.ts @@ -552,6 +552,44 @@ export class Session { await this._client.workspaces.sessions.delete(this.workspaceId, this.id) } + /** + * Clone this session, optionally up to a specific message. + * + * Makes an API call to create a copy of this session with a new ID. + * All messages and peers from the original session are copied to the new session. + * If a messageId is provided, only messages up to and including that message + * are copied. + * + * @param messageId - Optional message ID to cut off the clone at. If provided, + * the cloned session will only contain messages up to and + * including this message. + * @returns Promise resolving to a new Session object representing the cloned session + * + * @example + * ```typescript + * // Clone entire session + * const cloned = await session.clone() + * + * // Clone session up to a specific message + * const cloned = await session.clone('msg_abc123') + * ``` + */ + async clone(messageId?: string): Promise { + const clonedSessionData = await this._client.workspaces.sessions.clone( + this.workspaceId, + this.id, + messageId ? { message_id: messageId } : {} + ) + + return new Session( + clonedSessionData.id, + this.workspaceId, + this._client, + clonedSessionData.metadata ?? undefined, + clonedSessionData.configuration ?? undefined + ) + } + /** * Get optimized context for this session within a token limit. * diff --git a/src/crud/session.py b/src/crud/session.py index 81ea1969..e447ba62 100644 --- a/src/crud/session.py +++ b/src/crud/session.py @@ -480,9 +480,17 @@ async def clone_session( cutoff_message_id: str | None = None, ) -> models.Session: """ - Clone a session and its messages. If cutoff_message_id is provided, + Clone a session and its data. If cutoff_message_id is provided, only clone messages up to and including that message. + The following data is copied to the new session: + - Session metadata + - Session configuration + - All messages (or up to cutoff_message_id) with their content, metadata, and peer associations + - Session-peer associations with their configurations (observe_me, observe_others) + + The new session gets a unique ID (nanoid) and fresh timestamps. + Args: db: SQLAlchemy session workspace_name: Name of the workspace the target session is in @@ -522,6 +530,7 @@ async def clone_session( workspace_name=workspace_name, name=generate_nanoid(), h_metadata=original_session.h_metadata, + configuration=original_session.configuration, ) db.add(new_session) await db.flush() # Flush to get the new session ID @@ -557,7 +566,7 @@ async def clone_session( insert_stmt = insert(models.Message).returning(models.Message) result = await db.execute(insert_stmt, new_messages) - # Clone peers from original session to new session + # Clone peers from original session to new session (including their configurations) stmt = select(models.SessionPeer).where( models.SessionPeer.session_name == original_session_name ) @@ -568,6 +577,7 @@ async def clone_session( session_name=new_session.name, peer_name=session_peer.peer_name, workspace_name=workspace_name, + configuration=session_peer.configuration, ) db.add(new_session_peer) diff --git a/tests/sdk/test_session.py b/tests/sdk/test_session.py index e10c53d3..0be49af6 100644 --- a/tests/sdk/test_session.py +++ b/tests/sdk/test_session.py @@ -578,3 +578,145 @@ async def test_session_poll_deriver_status( ): status = session.poll_deriver_status(observer_id=peer.id, sender_id=peer.id) assert isinstance(status, DeriverStatus) + + +@pytest.mark.asyncio +async def test_session_clone(client_fixture: tuple[Honcho | AsyncHoncho, str]): + """ + Tests cloning a session and verifying the cloned session has copied messages. + """ + honcho_client, client_type = client_fixture + + if client_type == "async": + assert isinstance(honcho_client, AsyncHoncho) + session = await honcho_client.session(id="test-session-clone-async") + assert isinstance(session, AsyncSession) + user = await honcho_client.peer(id="user-clone-async") + assert isinstance(user, AsyncPeer) + + # Add messages to the session (implicitly creates session and adds peer) + await session.add_messages( + [ + user.message("First message"), + user.message("Second message"), + ] + ) + + # Clone the entire session + cloned = await session.clone() + assert isinstance(cloned, AsyncSession) + assert cloned.id != session.id # Should have a different ID + + # Verify cloned session has the same messages + cloned_messages_page = await cloned.get_messages() + cloned_messages = cloned_messages_page.items + assert len(cloned_messages) == 2 + + # Verify original session still has messages + original_messages_page = await session.get_messages() + original_messages = original_messages_page.items + assert len(original_messages) == 2 + else: + assert isinstance(honcho_client, Honcho) + session = honcho_client.session(id="test-session-clone-sync") + assert isinstance(session, Session) + user = honcho_client.peer(id="user-clone-sync") + assert isinstance(user, Peer) + + # Add messages to the session (implicitly creates session and adds peer) + session.add_messages( + [ + user.message("First message"), + user.message("Second message"), + ] + ) + + # Clone the entire session + cloned = session.clone() + assert isinstance(cloned, Session) + assert cloned.id != session.id # Should have a different ID + + # Verify cloned session has the same messages + cloned_messages_page = cloned.get_messages() + cloned_messages = list(cloned_messages_page) + assert len(cloned_messages) == 2 + + # Verify original session still has messages + original_messages_page = session.get_messages() + original_messages = list(original_messages_page) + assert len(original_messages) == 2 + + +@pytest.mark.asyncio +async def test_session_clone_with_cutoff( + client_fixture: tuple[Honcho | AsyncHoncho, str], +): + """ + Tests cloning a session up to a specific message. + """ + honcho_client, client_type = client_fixture + + if client_type == "async": + assert isinstance(honcho_client, AsyncHoncho) + session = await honcho_client.session(id="test-session-clone-cutoff-async") + assert isinstance(session, AsyncSession) + user = await honcho_client.peer(id="user-clone-cutoff-async") + assert isinstance(user, AsyncPeer) + + # Add messages to the session (implicitly creates session and adds peer) + messages = await session.add_messages( + [ + user.message("First message"), + user.message("Second message"), + user.message("Third message"), + ] + ) + + # Clone up to the first message + first_message_id = messages[0].id + cloned = await session.clone(message_id=first_message_id) + assert isinstance(cloned, AsyncSession) + assert cloned.id != session.id + + # Verify cloned session only has 1 message + cloned_messages_page = await cloned.get_messages() + cloned_messages = cloned_messages_page.items + assert len(cloned_messages) == 1 + assert cloned_messages[0].content == "First message" + + # Verify original session still has all 3 messages + original_messages_page = await session.get_messages() + original_messages = original_messages_page.items + assert len(original_messages) == 3 + else: + assert isinstance(honcho_client, Honcho) + session = honcho_client.session(id="test-session-clone-cutoff-sync") + assert isinstance(session, Session) + user = honcho_client.peer(id="user-clone-cutoff-sync") + assert isinstance(user, Peer) + + # Add messages to the session (implicitly creates session and adds peer) + messages = session.add_messages( + [ + user.message("First message"), + user.message("Second message"), + user.message("Third message"), + ] + ) + + # Clone up to the first message + first_message_id = messages[0].id + cloned = session.clone(message_id=first_message_id) + assert isinstance(cloned, Session) + assert cloned.id != session.id + + # Verify cloned session only has 1 message + cloned_messages_page = cloned.get_messages() + cloned_messages = list(cloned_messages_page) + assert len(cloned_messages) == 1 + assert cloned_messages[0].content == "First message" + + # Verify original session still has all 3 messages + original_messages_page = session.get_messages() + original_messages = list(original_messages_page) + assert len(original_messages) == 3