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:
@@ -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}")
|
||||
|
||||
|
||||
Reference in New Issue
Block a user