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
+161 -3
View File
@@ -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 <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()
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}")