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.
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