diff --git a/AGENTS.md b/AGENTS.md index 9587ea3..7bc8d8c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -88,13 +88,29 @@ cd webber-cli # Check API connection .venv/bin/webber-cli status -# Explore a directory -.venv/bin/webber-cli explore "find all python files" -d ../webber-sandbox - -# Interactive chat mode +# Interactive chat (default mode - full capabilities) .venv/bin/webber-cli chat -d ../webber-sandbox + +# Read-only mode (safe exploration and planning) +.venv/bin/webber-cli chat --mode plan -d ../webber-sandbox + +# Auto-accept mode (no approval prompts - use with caution) +.venv/bin/webber-cli chat --mode auto_accept -d ../webber-sandbox + +# List previous sessions +.venv/bin/webber-cli sessions + +# Resume a previous session +.venv/bin/webber-cli chat --resume ``` +**CLI Features:** +- **Tab completion** for commands and file paths +- **Command history** persisted to `~/.webber_history` +- **Session persistence** - conversations saved and resumable +- **Runtime mode switching** via `mode plan|default|auto_accept` +- **Directory navigation** via `cd ` + **Note:** The API server must be running for CLI commands to work. --- @@ -153,9 +169,10 @@ pytest tests/ -v # 1. Load the template ./sandbox.sh load calculator-cli -# 2. Have Webber explore it +# 2. Have Webber explore it (plan mode = read-only) cd webber-cli -.venv/bin/webber-cli explore "find all bugs in the code" -d ../webber-sandbox +.venv/bin/webber-cli chat --mode plan -d ../webber-sandbox +# Then ask: "find all bugs in the code" # 3. Check TASKS.md for expected bugs cat ../webber-sandbox/TASKS.md @@ -258,7 +275,5 @@ cd webber-api ## Known Limitations 1. **Model hallucination** - Mistral Nemo sometimes makes up file contents instead of using tool results -2. **No conversation memory** - CLI chat mode doesn't persist between sessions -3. **No streaming** - Responses appear all at once See `webber-api/docs/COVERAGE.md` for full feature coverage status. diff --git a/webber-api/docs/COVERAGE.md b/webber-api/docs/COVERAGE.md index c19873f..f2674c6 100644 --- a/webber-api/docs/COVERAGE.md +++ b/webber-api/docs/COVERAGE.md @@ -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 | Explore → Plan → 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 diff --git a/webber-api/src/domains/conversations/router.py b/webber-api/src/domains/conversations/router.py index 225f0e9..2882db4 100644 --- a/webber-api/src/domains/conversations/router.py +++ b/webber-api/src/domains/conversations/router.py @@ -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, + ) diff --git a/webber-api/src/domains/conversations/schemas.py b/webber-api/src/domains/conversations/schemas.py index 8525162..1197bff 100644 --- a/webber-api/src/domains/conversations/schemas.py +++ b/webber-api/src/domains/conversations/schemas.py @@ -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 diff --git a/webber-api/tests/test_conversations.py b/webber-api/tests/test_conversations.py index cd2bbb9..845e034 100644 --- a/webber-api/tests/test_conversations.py +++ b/webber-api/tests/test_conversations.py @@ -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 diff --git a/webber-cli/webber_cli/client.py b/webber-cli/webber_cli/client.py index d111ad0..5fa7a78 100644 --- a/webber-cli/webber_cli/client.py +++ b/webber-cli/webber_cli/client.py @@ -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 diff --git a/webber-cli/webber_cli/main.py b/webber-cli/webber_cli/main.py index 3f6f1f9..d4def7e 100644 --- a/webber-cli/webber_cli/main.py +++ b/webber-cli/webber_cli/main.py @@ -107,6 +107,9 @@ console = get_console() # Development port is 8095, production is 8086 DEFAULT_API_URL = os.environ.get("WEBBER_API_URL", "http://localhost:8095") +# Default API key for conversation API (dev mode accepts any non-empty key) +DEFAULT_API_KEY = os.environ.get("WEBBER_API_KEY", "webber-cli-dev-key") + def version_callback(value: bool) -> None: """Display version and exit.""" @@ -148,6 +151,69 @@ def main( pass +@app.command() +def sessions( + api_url: str = typer.Option( + DEFAULT_API_URL, + "--api", + "-a", + help="Webber API URL", + ), + limit: int = typer.Option( + 20, + "--limit", + "-n", + help="Maximum number of sessions to show", + ), +) -> None: + """ + List previous conversation sessions. + + Shows recent sessions that can be resumed with 'chat --resume '. + """ + asyncio.run(_list_sessions(api_url, limit)) + + +async def _list_sessions(api_url: str, limit: int) -> None: + """List conversation sessions.""" + async with WebberClient(api_url, api_key=DEFAULT_API_KEY) as client: + # Check API health + if not await client.health_check(): + console.print(f"[error]Error:[/] Cannot connect to Webber API at {api_url}") + return + + try: + conversations, total = await client.list_conversations(limit=limit) + except Exception as e: + console.print(f"[error]Error listing sessions:[/] {e}") + return + + if not conversations: + console.print("[dim]No sessions found. Start one with 'webber-cli chat'[/]") + return + + console.print(f"[title]Sessions[/] [dim]({len(conversations)} of {total})[/]\n") + + for conv in conversations: + # Format the date + date_str = conv.created_at.strftime("%Y-%m-%d %H:%M") + + # Title or first message preview + title = conv.title or "[dim]untitled[/]" + + # Truncate ID for display + short_id = conv.id[:8] + + console.print( + f" [info]{short_id}[/] {date_str} " + f"[path]{conv.working_dir}[/] {title} " + f"[dim]({conv.total_tokens} tokens)[/]" + ) + + console.print() + console.print("[dim]Resume with: webber-cli chat --resume [/]") + + @app.command() def chat( directory: str = typer.Option( @@ -168,6 +234,12 @@ def chat( "-m", help="Permission mode: default, plan (read-only), auto_accept (no prompts)", ), + resume: str = typer.Option( + None, + "--resume", + "-r", + help="Resume a previous session by ID (use 'sessions' to list)", + ), stream: bool = typer.Option( True, "--stream/--no-stream", @@ -188,6 +260,8 @@ def chat( - default: Full capabilities with approval prompts for writes - plan: Read-only mode for safe exploration and planning - auto_accept: Full capabilities without approval prompts (use with caution) + + Use --resume to continue a previous session. """ working_dir = str(Path(directory).resolve()) @@ -210,7 +284,7 @@ def chat( permission_mode = PermissionMode.default try: - asyncio.run(_chat_loop(api_url, working_dir, permission_mode, stream)) + asyncio.run(_chat_loop(api_url, working_dir, permission_mode, stream, resume)) except KeyboardInterrupt: console.print("\n[dim]Goodbye![/]") @@ -220,12 +294,15 @@ async def _chat_loop( working_dir: str, mode: PermissionMode, stream: bool = True, + resume_id: str | None = None, ) -> None: """Interactive chat loop with the Task agent.""" theme = get_theme() agent_type = "task" + conversation_id: str | None = None + conversation_title: str | None = None - async with WebberClient(api_url) as client: + async with WebberClient(api_url, api_key=DEFAULT_API_KEY) as client: # Check API health if not await client.health_check(): console.print(f"[error]Error:[/] Cannot connect to Webber API at {api_url}") @@ -238,6 +315,60 @@ async def _chat_loop( console.print(f"[error]Error:[/] Task agent not found") return + # Handle resume or create new conversation + if resume_id: + # Try to find conversation by ID prefix + try: + conversations, _ = await client.list_conversations(limit=100) + matching = [c for c in conversations if c.id.startswith(resume_id)] + if not matching: + console.print(f"[error]Error:[/] Session not found: {resume_id}") + console.print("[dim]Use 'webber-cli sessions' to list available sessions[/]") + return + if len(matching) > 1: + console.print(f"[error]Error:[/] Ambiguous ID, multiple matches: {resume_id}") + for m in matching: + console.print(f" - {m.id[:8]} ({m.title or 'untitled'})") + return + + # Load the conversation with messages + conv = await client.get_conversation(matching[0].id) + if not conv: + console.print(f"[error]Error:[/] Could not load session") + return + + conversation_id = conv.id + conversation_title = conv.title + working_dir = conv.working_dir # Use the session's working directory + + # Display conversation history + console.print(f"\n[title]Resuming session[/] [dim]{conv.id[:8]}[/]") + if conv.messages: + console.print(f"[dim]({len(conv.messages)} messages, {conv.total_tokens} tokens)[/]\n") + for msg in conv.messages[-6:]: # Show last 6 messages + if msg.role == "user": + console.print(f"[prompt]>[/] {msg.content[:100]}{'...' if len(msg.content) > 100 else ''}") + else: + preview = msg.content[:200].replace('\n', ' ') + console.print(f"[dim]{preview}{'...' if len(msg.content) > 200 else ''}[/]\n") + + except Exception as e: + console.print(f"[error]Error resuming session:[/] {e}") + return + else: + # Create a new conversation + try: + conv = await client.create_conversation( + agent_type=agent_type, + working_dir=working_dir, + title=None, # Will be set later based on first message + ) + conversation_id = conv.id + console.print(f"[dim]Session: {conv.id[:8]}[/]") + except Exception as e: + # If conversation API fails, continue without persistence + console.print(f"[dim]Note: Session persistence unavailable ({e})[/]") + # Mode display mode_display = { PermissionMode.default: "[info]default[/] (full with approvals)", @@ -318,14 +449,30 @@ async def _chat_loop( console.print() if stream: - # Stream response in real-time + # Stream response in real-time, buffering for persistence + response_chunks: list[str] = [] try: async for chunk in client.run_agent_stream( agent_type, user_input, working_dir, current_mode ): sys.stdout.write(chunk) sys.stdout.flush() + response_chunks.append(chunk) console.print() # Newline after streaming + + # Save messages to conversation if we have a session + if conversation_id and response_chunks: + try: + full_response = "".join(response_chunks) + await client.save_messages( + conversation_id, + user_input, + full_response, + ) + except Exception as save_error: + # Log but don't fail the interaction + console.print(f"[dim]Note: Could not save to session ({save_error})[/]") + except Exception as e: console.print(f"\n[error]Stream error:[/] {e}") else: @@ -337,6 +484,17 @@ async def _chat_loop( if result.success: console.print(Markdown(result.response)) + + # Save messages to conversation if we have a session + if conversation_id: + try: + await client.save_messages( + conversation_id, + user_input, + result.response, + ) + except Exception as save_error: + console.print(f"[dim]Note: Could not save to session ({save_error})[/]") else: console.print(f"[error]Error:[/] {result.error}")