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
+23 -8
View File
@@ -88,13 +88,29 @@ cd webber-cli
# Check API connection # Check API connection
.venv/bin/webber-cli status .venv/bin/webber-cli status
# Explore a directory # Interactive chat (default mode - full capabilities)
.venv/bin/webber-cli explore "find all python files" -d ../webber-sandbox
# Interactive chat mode
.venv/bin/webber-cli chat -d ../webber-sandbox .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 <session-id>
``` ```
**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 <path>`
**Note:** The API server must be running for CLI commands to work. **Note:** The API server must be running for CLI commands to work.
--- ---
@@ -153,9 +169,10 @@ pytest tests/ -v
# 1. Load the template # 1. Load the template
./sandbox.sh load calculator-cli ./sandbox.sh load calculator-cli
# 2. Have Webber explore it # 2. Have Webber explore it (plan mode = read-only)
cd webber-cli 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 # 3. Check TASKS.md for expected bugs
cat ../webber-sandbox/TASKS.md cat ../webber-sandbox/TASKS.md
@@ -258,7 +275,5 @@ cd webber-api
## Known Limitations ## Known Limitations
1. **Model hallucination** - Mistral Nemo sometimes makes up file contents instead of using tool results 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. See `webber-api/docs/COVERAGE.md` for full feature coverage status.
+12 -10
View File
@@ -2,9 +2,9 @@
> Tracking progress towards Claude Code-like functionality > 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 | | **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 | | **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 | | **Todo tracking** | CLI | Built-in task list (`/todo`) | Medium |
| **Git integration** | CLI | Auto-commit, branch management | 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 | | ~~**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 ### Low Priority
@@ -149,14 +151,14 @@ Last updated: 2026-01-11
| API tests | 11 | 11 | ✅ | | API tests | 11 | 11 | ✅ |
| Plan agent tests | 15 | 15 | ✅ | | Plan agent tests | 15 | 15 | ✅ |
| Task agent tests | 15 | 15 | ✅ | | Task agent tests | 15 | 15 | ✅ |
| Conversation tests | 19 | 19 | ✅ | | Conversation tests | 22 | 22 | ✅ |
| Token tests | 6 | 6 | ✅ | | Token tests | 6 | 6 | ✅ |
| Retry tests | 29 | 29 | ✅ | | Retry tests | 29 | 29 | ✅ |
| Security tests | 14 | 14 | ✅ | | Security tests | 14 | 14 | ✅ |
| Integration tests | 10 | 10 | ✅ Agent + real LLM | | Integration tests | 10 | 10 | ✅ Agent + real LLM |
| E2E tests | 12 | 12 | ✅ Full API workflow | | E2E tests | 12 | 12 | ✅ Full API workflow |
**Total: 205 tests passing** **Total: 208 tests passing**
**Test breakdown:** **Test breakdown:**
- Read/Glob/Grep tools: 17 tests - Read/Glob/Grep tools: 17 tests
@@ -167,7 +169,7 @@ Last updated: 2026-01-11
- API endpoints: 11 tests - API endpoints: 11 tests
- Plan agent: 15 tests - Plan agent: 15 tests
- Task agent: 15 tests - Task agent: 15 tests
- Conversations: 19 tests - Conversations: 22 tests
- Tokens: 6 tests - Tokens: 6 tests
- Retry: 29 tests - Retry: 29 tests
- Security: 14 tests - Security: 14 tests
@@ -224,9 +226,9 @@ cd webber-api && ./wakeup.sh
# CLI commands (from webber-cli/) # CLI commands (from webber-cli/)
.venv/bin/webber-cli status # Check API connection .venv/bin/webber-cli status # Check API connection
.venv/bin/webber-cli explore "find tests" # One-shot exploration .venv/bin/webber-cli chat # Interactive mode (Task agent, full tools)
.venv/bin/webber-cli explore "query" --no-stream # Batch mode .venv/bin/webber-cli chat --mode plan # Read-only mode (safe exploration)
.venv/bin/webber-cli chat # Interactive mode .venv/bin/webber-cli chat --mode auto_accept # No approval prompts (use with caution)
# API endpoints # API endpoints
curl http://localhost:8095/health curl http://localhost:8095/health
@@ -15,6 +15,8 @@ from src.domains.conversations.schemas import (
ConversationResponse, ConversationResponse,
CreateConversationRequest, CreateConversationRequest,
MessageResponse, MessageResponse,
SaveMessagesRequest,
SaveMessagesResponse,
) )
from src.domains.conversations.service import ConversationService from src.domains.conversations.service import ConversationService
from src.shared.auth import require_auth from src.shared.auth import require_auth
@@ -187,3 +189,51 @@ async def add_message(
total_tokens=conversation.total_tokens if conversation else 0, total_tokens=conversation.total_tokens if conversation else 0,
summarized=summarized, 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") 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 === # === Response Schemas ===
class MessageResponse(BaseModel): class MessageResponse(BaseModel):
@@ -77,3 +86,10 @@ class AddMessageResponse(BaseModel):
default=False, default=False,
description="Whether context was summarized due to token limit" 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 Summary]" in formatted
assert "Previous context 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
+230 -1
View File
@@ -7,7 +7,8 @@ Supports permission modes for controlling agent tool access.
import json import json
import httpx import httpx
from collections.abc import AsyncIterator from collections.abc import AsyncIterator
from dataclasses import dataclass from dataclasses import dataclass, field
from datetime import datetime
from enum import Enum from enum import Enum
from typing import Any from typing import Any
@@ -42,6 +43,47 @@ class AgentInfo:
description: str 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: class WebberClient:
""" """
Client for the Webber API. Client for the Webber API.
@@ -203,6 +245,193 @@ class WebberClient:
except json.JSONDecodeError: except json.JSONDecodeError:
continue 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 def __aenter__(self) -> "WebberClient":
"""Async context manager entry.""" """Async context manager entry."""
return self return self
+161 -3
View File
@@ -107,6 +107,9 @@ console = get_console()
# Development port is 8095, production is 8086 # Development port is 8095, production is 8086
DEFAULT_API_URL = os.environ.get("WEBBER_API_URL", "http://localhost:8095") 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: def version_callback(value: bool) -> None:
"""Display version and exit.""" """Display version and exit."""
@@ -148,6 +151,69 @@ def main(
pass 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 <id>'.
"""
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 <id>[/]")
@app.command() @app.command()
def chat( def chat(
directory: str = typer.Option( directory: str = typer.Option(
@@ -168,6 +234,12 @@ def chat(
"-m", "-m",
help="Permission mode: default, plan (read-only), auto_accept (no prompts)", 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( stream: bool = typer.Option(
True, True,
"--stream/--no-stream", "--stream/--no-stream",
@@ -188,6 +260,8 @@ def chat(
- default: Full capabilities with approval prompts for writes - default: Full capabilities with approval prompts for writes
- plan: Read-only mode for safe exploration and planning - plan: Read-only mode for safe exploration and planning
- auto_accept: Full capabilities without approval prompts (use with caution) - auto_accept: Full capabilities without approval prompts (use with caution)
Use --resume to continue a previous session.
""" """
working_dir = str(Path(directory).resolve()) working_dir = str(Path(directory).resolve())
@@ -210,7 +284,7 @@ def chat(
permission_mode = PermissionMode.default permission_mode = PermissionMode.default
try: 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: except KeyboardInterrupt:
console.print("\n[dim]Goodbye![/]") console.print("\n[dim]Goodbye![/]")
@@ -220,12 +294,15 @@ async def _chat_loop(
working_dir: str, working_dir: str,
mode: PermissionMode, mode: PermissionMode,
stream: bool = True, stream: bool = True,
resume_id: str | None = None,
) -> None: ) -> None:
"""Interactive chat loop with the Task agent.""" """Interactive chat loop with the Task agent."""
theme = get_theme() theme = get_theme()
agent_type = "task" 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 # Check API health
if not await client.health_check(): if not await client.health_check():
console.print(f"[error]Error:[/] Cannot connect to Webber API at {api_url}") 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") console.print(f"[error]Error:[/] Task agent not found")
return 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
mode_display = { mode_display = {
PermissionMode.default: "[info]default[/] (full with approvals)", PermissionMode.default: "[info]default[/] (full with approvals)",
@@ -318,14 +449,30 @@ async def _chat_loop(
console.print() console.print()
if stream: if stream:
# Stream response in real-time # Stream response in real-time, buffering for persistence
response_chunks: list[str] = []
try: try:
async for chunk in client.run_agent_stream( async for chunk in client.run_agent_stream(
agent_type, user_input, working_dir, current_mode agent_type, user_input, working_dir, current_mode
): ):
sys.stdout.write(chunk) sys.stdout.write(chunk)
sys.stdout.flush() sys.stdout.flush()
response_chunks.append(chunk)
console.print() # Newline after streaming 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: except Exception as e:
console.print(f"\n[error]Stream error:[/] {e}") console.print(f"\n[error]Stream error:[/] {e}")
else: else:
@@ -337,6 +484,17 @@ async def _chat_loop(
if result.success: if result.success:
console.print(Markdown(result.response)) 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: else:
console.print(f"[error]Error:[/] {result.error}") console.print(f"[error]Error:[/] {result.error}")