Add session cloning to Ergonomic SDKs (#285)

* feat: Add session cloning to Ergonomic SDKs

* chore: docs nit
This commit is contained in:
Vineeth Voruganti 2025-12-04 14:51:39 -05:00 committed by GitHub
parent e3d345b961
commit 72eb0827ec
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 372 additions and 6 deletions

View File

@ -34,13 +34,12 @@ the Welcome page with integration guidance and links to documentation.
</Frame>
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.
<Frame>
<img src="/images/app-screenshots/status-page.png" alt="Instance Status Page" width="1200" height="800" loading="lazy" decoding="async" fetchpriority="low" />

View File

@ -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();

View File

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

View File

@ -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,

View File

@ -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 = {

View File

@ -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<Session> {
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.
*

View File

@ -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)

View File

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