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
+12 -10
View File
@@ -2,9 +2,9 @@
> Tracking progress towards Claude Code-like functionality
## Current Status: ~80% Complete
## Current Status: ~85% Complete
Last updated: 2026-01-11
Last updated: 2026-01-14
---
@@ -122,11 +122,13 @@ Last updated: 2026-01-11
|---------|----------|-------------|------------|
| **Web search summarizer** | Tools | Agent to extract core content from web pages (remove nav, footers, etc.) and preserve relevant links for nested fetching | Medium |
| **Tool result caching** | Infrastructure | Cache file reads for performance | Low |
| **Session persistence** | CLI | Save/resume conversations | Medium |
| ~~**Session persistence**~~ | CLI | Save/resume conversations via `sessions` and `chat --resume` | Medium |
| **Todo tracking** | CLI | Built-in task list (`/todo`) | Medium |
| **Git integration** | CLI | Auto-commit, branch management | Medium |
| **Agent handoff** | Orchestration | ExplorePlan → Task workflow | High |
| ~~**Agent handoff**~~ | Orchestration | ✅ Task agent is main agent, spawns Explore/Plan as needed (Claude Code pattern) | High |
| ~~**Retry logic**~~ | Infrastructure | ✅ Auto-retry with exponential backoff | Low |
| ~~**Permission modes**~~ | CLI | ✅ default/plan/auto_accept modes controlling tool access | Medium |
| ~~**CLI shell features**~~ | CLI | ✅ prompt_toolkit: history, tab completion, auto-suggest | Low |
### Low Priority
@@ -149,14 +151,14 @@ Last updated: 2026-01-11
| API tests | 11 | 11 | ✅ |
| Plan agent tests | 15 | 15 | ✅ |
| Task agent tests | 15 | 15 | ✅ |
| Conversation tests | 19 | 19 | ✅ |
| Conversation tests | 22 | 22 | ✅ |
| Token tests | 6 | 6 | ✅ |
| Retry tests | 29 | 29 | ✅ |
| Security tests | 14 | 14 | ✅ |
| Integration tests | 10 | 10 | ✅ Agent + real LLM |
| E2E tests | 12 | 12 | ✅ Full API workflow |
**Total: 205 tests passing**
**Total: 208 tests passing**
**Test breakdown:**
- Read/Glob/Grep tools: 17 tests
@@ -167,7 +169,7 @@ Last updated: 2026-01-11
- API endpoints: 11 tests
- Plan agent: 15 tests
- Task agent: 15 tests
- Conversations: 19 tests
- Conversations: 22 tests
- Tokens: 6 tests
- Retry: 29 tests
- Security: 14 tests
@@ -224,9 +226,9 @@ cd webber-api && ./wakeup.sh
# CLI commands (from webber-cli/)
.venv/bin/webber-cli status # Check API connection
.venv/bin/webber-cli explore "find tests" # One-shot exploration
.venv/bin/webber-cli explore "query" --no-stream # Batch mode
.venv/bin/webber-cli chat # Interactive mode
.venv/bin/webber-cli chat # Interactive mode (Task agent, full tools)
.venv/bin/webber-cli chat --mode plan # Read-only mode (safe exploration)
.venv/bin/webber-cli chat --mode auto_accept # No approval prompts (use with caution)
# API endpoints
curl http://localhost:8095/health
@@ -15,6 +15,8 @@ from src.domains.conversations.schemas import (
ConversationResponse,
CreateConversationRequest,
MessageResponse,
SaveMessagesRequest,
SaveMessagesResponse,
)
from src.domains.conversations.service import ConversationService
from src.shared.auth import require_auth
@@ -187,3 +189,51 @@ async def add_message(
total_tokens=conversation.total_tokens if conversation else 0,
summarized=summarized,
)
@router.post("/{conversation_id}/save", response_model=SaveMessagesResponse)
@logged()
async def save_messages(
conversation_id: UUID,
request: SaveMessagesRequest,
session: AsyncSession = Depends(get_session),
user=Depends(require_auth),
) -> SaveMessagesResponse:
"""
Save a user/assistant message pair without triggering agent execution.
Used by CLI when streaming responses separately via /agents/stream.
This allows persisting the exchange after streaming completes.
"""
service = ConversationService(session)
# Verify conversation exists and user owns it
conversation = await service.get(conversation_id)
if not conversation:
raise HTTPException(status_code=404, detail="Conversation not found")
if conversation.user_id != user.id:
raise HTTPException(status_code=403, detail="Not authorized")
# Save user message
user_message = await service.add_message(
conversation_id=conversation_id,
role="user",
content=request.user_content,
)
# Save assistant message
assistant_message = await service.add_message(
conversation_id=conversation_id,
role="assistant",
content=request.assistant_content,
)
# Get updated conversation for total tokens
conversation = await service.get(conversation_id)
return SaveMessagesResponse(
user_message=MessageResponse.model_validate(user_message),
assistant_message=MessageResponse.model_validate(assistant_message),
total_tokens=conversation.total_tokens if conversation else 0,
)
@@ -21,6 +21,15 @@ class AddMessageRequest(BaseModel):
content: str = Field(..., min_length=1, description="Message content")
class SaveMessagesRequest(BaseModel):
"""Request to save a message pair without triggering agent execution.
Used by CLI when streaming responses separately via /agents/stream.
"""
user_content: str = Field(..., min_length=1, description="User message content")
assistant_content: str = Field(..., min_length=1, description="Assistant response content")
# === Response Schemas ===
class MessageResponse(BaseModel):
@@ -77,3 +86,10 @@ class AddMessageResponse(BaseModel):
default=False,
description="Whether context was summarized due to token limit"
)
class SaveMessagesResponse(BaseModel):
"""Response after saving messages (no agent execution)."""
user_message: MessageResponse
assistant_message: MessageResponse
total_tokens: int
+84
View File
@@ -297,3 +297,87 @@ class TestSummarization:
assert "[Previous Summary]" in formatted
assert "Previous context summary" in formatted
class TestSaveMessagesAPI:
"""Tests for the save messages endpoint (no agent execution)."""
@pytest.mark.anyio
async def test_save_messages(self, auth_client):
"""Test saving a message pair without triggering agent."""
# First create a conversation
response = await auth_client.post(
"/conversations/",
json={"agent_type": "task", "working_dir": "."}
)
assert response.status_code == 201
conv_id = response.json()["id"]
# Save a message pair
response = await auth_client.post(
f"/conversations/{conv_id}/save",
json={
"user_content": "Find all Python files",
"assistant_content": "I found 5 Python files in the project.",
}
)
assert response.status_code == 200
data = response.json()
assert data["user_message"]["role"] == "user"
assert data["user_message"]["content"] == "Find all Python files"
assert data["assistant_message"]["role"] == "assistant"
assert data["assistant_message"]["content"] == "I found 5 Python files in the project."
assert data["total_tokens"] > 0
@pytest.mark.anyio
async def test_save_messages_not_found(self, auth_client):
"""Test saving messages to non-existent conversation."""
fake_id = uuid4()
response = await auth_client.post(
f"/conversations/{fake_id}/save",
json={
"user_content": "Test",
"assistant_content": "Response",
}
)
assert response.status_code == 404
@pytest.mark.anyio
async def test_save_messages_updates_token_count(self, auth_client):
"""Test that saving messages updates the conversation token count."""
# Create conversation
response = await auth_client.post(
"/conversations/",
json={"agent_type": "explore", "working_dir": "."}
)
conv_id = response.json()["id"]
assert response.json()["total_tokens"] == 0
# Save first message pair
response = await auth_client.post(
f"/conversations/{conv_id}/save",
json={
"user_content": "Hello",
"assistant_content": "Hi there!",
}
)
first_tokens = response.json()["total_tokens"]
assert first_tokens > 0
# Save second message pair
response = await auth_client.post(
f"/conversations/{conv_id}/save",
json={
"user_content": "How are you?",
"assistant_content": "I'm doing well, thank you for asking!",
}
)
second_tokens = response.json()["total_tokens"]
assert second_tokens > first_tokens
# Verify via get endpoint
response = await auth_client.get(f"/conversations/{conv_id}")
assert response.status_code == 200
assert response.json()["total_tokens"] == second_tokens
assert len(response.json()["messages"]) == 4