feat: add session persistence to CLI

- Add save-only endpoint (POST /conversations/{id}/save) for persisting
  messages without triggering agent execution
- Add sessions command to list previous conversation sessions
- Add --resume flag to chat command for resuming sessions by ID
- Buffer streamed responses and save after completion
- Update AGENTS.md with session commands and remove outdated limitation
- Add 3 new tests for save endpoint (208 total)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-14 09:50:25 +01:00
co-authored by Claude Opus 4.5
parent b7956f88ed
commit daa9543790
7 changed files with 576 additions and 22 deletions
+230 -1
View File
@@ -7,7 +7,8 @@ Supports permission modes for controlling agent tool access.
import json
import httpx
from collections.abc import AsyncIterator
from dataclasses import dataclass
from dataclasses import dataclass, field
from datetime import datetime
from enum import Enum
from typing import Any
@@ -42,6 +43,47 @@ class AgentInfo:
description: str
@dataclass
class Message:
"""A message in a conversation."""
id: str
role: str
content: str
token_count: int
is_summary: bool
created_at: datetime
@dataclass
class Conversation:
"""A conversation session."""
id: str
agent_type: str
title: str | None
working_dir: str
total_tokens: int
created_at: datetime
updated_at: datetime | None
messages: list[Message] = field(default_factory=list)
@dataclass
class AddMessageResult:
"""Result of adding a message to a conversation."""
user_message: Message
assistant_message: Message
total_tokens: int
summarized: bool
@dataclass
class SaveMessagesResult:
"""Result of saving a message pair without agent execution."""
user_message: Message
assistant_message: Message
total_tokens: int
class WebberClient:
"""
Client for the Webber API.
@@ -203,6 +245,193 @@ class WebberClient:
except json.JSONDecodeError:
continue
# === Conversation API ===
def _parse_message(self, data: dict) -> Message:
"""Parse a Message from API response data."""
return Message(
id=data["id"],
role=data["role"],
content=data["content"],
token_count=data["token_count"],
is_summary=data["is_summary"],
created_at=datetime.fromisoformat(data["created_at"].replace("Z", "+00:00")),
)
def _parse_conversation(self, data: dict, with_messages: bool = False) -> Conversation:
"""Parse a Conversation from API response data."""
messages = []
if with_messages and "messages" in data:
messages = [self._parse_message(m) for m in data["messages"]]
updated_at = None
if data.get("updated_at"):
updated_at = datetime.fromisoformat(data["updated_at"].replace("Z", "+00:00"))
return Conversation(
id=data["id"],
agent_type=data["agent_type"],
title=data.get("title"),
working_dir=data["working_dir"],
total_tokens=data["total_tokens"],
created_at=datetime.fromisoformat(data["created_at"].replace("Z", "+00:00")),
updated_at=updated_at,
messages=messages,
)
async def list_conversations(
self,
limit: int = 50,
offset: int = 0,
) -> tuple[list[Conversation], int]:
"""
List user's conversations.
Args:
limit: Maximum number of conversations to return
offset: Offset for pagination
Returns:
Tuple of (conversations, total_count)
"""
client = await self._get_client()
response = await client.get(
"/conversations/",
params={"limit": limit, "offset": offset},
)
response.raise_for_status()
data = response.json()
conversations = [self._parse_conversation(c) for c in data["conversations"]]
return conversations, data["total"]
async def get_conversation(self, conversation_id: str) -> Conversation | None:
"""
Get a conversation with all messages.
Args:
conversation_id: UUID of the conversation
Returns:
Conversation with messages, or None if not found
"""
client = await self._get_client()
response = await client.get(f"/conversations/{conversation_id}")
if response.status_code == 404:
return None
response.raise_for_status()
return self._parse_conversation(response.json(), with_messages=True)
async def create_conversation(
self,
agent_type: str = "task",
working_dir: str = ".",
title: str | None = None,
) -> Conversation:
"""
Create a new conversation.
Args:
agent_type: Type of agent to use
working_dir: Working directory for the agent
title: Optional title for the conversation
Returns:
The created conversation
"""
client = await self._get_client()
response = await client.post(
"/conversations/",
json={
"agent_type": agent_type,
"working_dir": working_dir,
"title": title,
},
)
response.raise_for_status()
return self._parse_conversation(response.json())
async def add_message(
self,
conversation_id: str,
content: str,
) -> AddMessageResult:
"""
Add a message to a conversation and get agent response.
Args:
conversation_id: UUID of the conversation
content: Message content
Returns:
AddMessageResult with user and assistant messages
"""
client = await self._get_client()
response = await client.post(
f"/conversations/{conversation_id}/messages",
json={"content": content},
)
response.raise_for_status()
data = response.json()
return AddMessageResult(
user_message=self._parse_message(data["user_message"]),
assistant_message=self._parse_message(data["assistant_message"]),
total_tokens=data["total_tokens"],
summarized=data.get("summarized", False),
)
async def save_messages(
self,
conversation_id: str,
user_content: str,
assistant_content: str,
) -> SaveMessagesResult:
"""
Save a user/assistant message pair without triggering agent execution.
Used when streaming responses separately via run_agent_stream().
Allows persisting the exchange after streaming completes.
Args:
conversation_id: UUID of the conversation
user_content: User message content
assistant_content: Assistant response content
Returns:
SaveMessagesResult with both messages
"""
client = await self._get_client()
response = await client.post(
f"/conversations/{conversation_id}/save",
json={
"user_content": user_content,
"assistant_content": assistant_content,
},
)
response.raise_for_status()
data = response.json()
return SaveMessagesResult(
user_message=self._parse_message(data["user_message"]),
assistant_message=self._parse_message(data["assistant_message"]),
total_tokens=data["total_tokens"],
)
async def delete_conversation(self, conversation_id: str) -> bool:
"""
Delete a conversation.
Args:
conversation_id: UUID of the conversation
Returns:
True if deleted, False if not found
"""
client = await self._get_client()
response = await client.delete(f"/conversations/{conversation_id}")
if response.status_code == 404:
return False
response.raise_for_status()
return True
async def __aenter__(self) -> "WebberClient":
"""Async context manager entry."""
return self