Add session cloning to Ergonomic SDKs (#285)
* feat: Add session cloning to Ergonomic SDKs * chore: docs nit
This commit is contained in:
parent
e3d345b961
commit
72eb0827ec
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
*
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue