Files
ss-tools/specs/033-gradio-agent-chat/tasks.md
2026-06-09 09:43:34 +03:00

26 KiB
Raw Blame History

#region AgentChat.Tasks [C:3] [TYPE ADR] [SEMANTICS tasks,implementation,agent-chat] @BRIEF Implementation tasks for Gradio Agent Chat — phased by user story, dependency-ordered, with contract inlining for C3+ functions.

Phase 1: Setup (shared infrastructure)

  • T001 [P] Add gradio>=5.0, langgraph>=0.2, langchain-core>=0.3, langchain-openai>=0.3, langgraph-checkpoint-postgres, pdfplumber to backend/requirements.txt

  • T002 [P] Add @gradio/client to frontend/package.jsonnpm install

  • T003 [P] Create backend/src/agent/ directory with __init__.py

  • T004 [P] Create backend/tests/test_agent/ directory with __init__.py

  • T005 [P] Create frontend/src/routes/agent/ directory

  • T006 [P] Create frontend/src/types/agent.ts — TypeScript DTOs for conversation/message/stream metadata. No agent-ws.ts.

  • T007 Create docker/Dockerfile.agent — Python 3.11-slim, installs gradio/langchain/langchain-openai/pdfplumber from requirements. No shared volume mount needed — Gradio calls FastAPI tools via HTTP. RATIONALE Tool execution via REST (FR-004 revised) — Gradio container has no DB connection

  • T008 Update docker/docker-compose.yml — add ss-tools-agent service (port 7860 internal, NOT exposed to host). Env vars: LLM_API_KEY, LLM_BASE_URL, LLM_MODEL, FASTAPI_URL=http://ss-tools-api:8000, SERVICE_JWT, JWT_SECRET (shared with FastAPI for stateless JWT validation).

  • T009 Update docker/nginx.conf — add reverse proxy location /api/agent/gradiohttp://ss-tools-agent:7860. Forward Authorization header from browser request. RATIONALE Browser cannot resolve Docker service names; Gradio must be proxied through nginx

Phase 2: Foundational (data layer — blocking for all stories)

  • T010 Implement backend/src/models/agent.py — SQLAlchemy models AgentConversation (id, user_id FK→users.id, title, is_archived, created_at, updated_at) and AgentMessage (id, conversation_id FK→agent_conversations.id, role, text, state, tool_calls JSON, attachments JSON, created_at). Use uuid4_str for primary keys. @DATA_CONTRACT Models.Agent — see data-model.md §2

  • T011 Implement backend/src/schemas/agent.py — Pydantic schemas: ConversationItem, ConversationListResponse, MessageItem, HistoryResponse, DeleteResponse. Match data-model.md §3 exactly. @DATA_CONTRACT Schemas.Agent

  • T012 [P] Implement frontend/src/types/agent.ts — TypeScript interfaces: ConversationItem, ConversationListResponse, MessageItem, ToolCall, AttachmentMeta, HistoryResponse, DeleteResponse. MUST match backend/src/schemas/agent.py field-for-field. @DATA_CONTRACT AgentTypes ↔ Schemas.Agent (cross-stack)

  • T013 [P] Implement frontend/src/types/agent.ts — TypeScript interfaces for conversation/message DTOs matching backend/src/schemas/agent.py. agent-ws.ts NOT created — no custom WebSocket protocol.

  • T014 Generate Alembic migration for agent_conversations and agent_messages tables → cd backend && source .venv/bin/activate && alembic revision --autogenerate -m "add agent conversations"

Phase 3: Story 1 — Gradio Agent Engine with Streaming Chat (P1) 🎯 MVP

Backend

  • T015 [US1] Implement backend/src/agent/app.py — Gradio gr.ChatInterface(fn=agent_handler, type="messages", multimodal=True, additional_inputs=[gr.Textbox("conversation_id"), gr.Textbox("action")], examples=[["Покажи дашборды", null, null], ["Статус системы", null, null]], concurrency_limit=10). Handler: agent_handler(message, history, request: gr.Request, conversation_id=None, action=None). On action="confirm"/"deny": resume graph from checkpoint. Per-user lock via in-memory dict keyed by user_id.

  • T016 [US1] Implement backend/src/agent/langgraph_setup.pycreate_react_agent(model, tools, checkpointer=PostgresSaver(os.getenv("DATABASE_URL"))). Define dangerous tools in DANGEROUS_TOOLS set. Compile graph with interrupt_before=DANGEROUS_TOOLS. Wrap with RunnableWithMessageHistory for auto history load/save (thread_id = conversation_id). @POST Compiled StateGraph with checkpointer, interrupt_before, message history RATIONALE langgraph provides native interrupt/resume; no custom HITL middleware needed

  • T017 [US1] Implement agent_handler yield loop in app.pyasync for event in graph.astream_events({"messages": [HumanMessage(content=message["text"])]}, config={"configurable": {"thread_id": conversation_id}}): yield to_chatmessage(event). Tool events → ChatMessage(metadata={"type":"tool_start", "tool":..., "input":...}). Interrupt events → ChatMessage(metadata={"type":"confirm_required", "prompt":..., "thread_id":conversation_id}). @POST Each LangGraph event converted to ChatMessage with structured metadata; frontend receives typed objects

Frontend

  • T018 [US1] Adapt AssistantChatPanel.svelteClient.connect("/api/agent/gradio"), submit("/chat", message, [conversationId, null]). Iterate for await (event): event.data is ChatMessage with structured metadata. Render tokens, tool cards, confirmation cards based on metadata.type. @UX_STATE idle, streaming, awaiting_confirmation, error, disconnected

  • T019 [US1] Implement AgentChatModel.sendMessage()client.submit("/chat", {text: message, files}, [conversationId, null]), iterate events, append tokens. streamingState: idle→streaming.

  • T020 [US1] Implement AgentChatModel.cancelGeneration()submission.cancel().

  • T021 [US1] Implement metadata handler in AgentChatModel._onStreamData(msg) — switch on msg.metadata.type: stream_token→append, tool_start→card+spinner, tool_end→checkmark, tool_error→cross, confirm_required→confirmation card, confirm_resolved→collapse card, error→error card.

  • T022 [US1] Implement Gradio connection lifecycle — Client.connect(), retry on disconnect (5×5s), connectionState atom.

Verification

  • T024 [US1] Write backend/tests/test_agent/test_agent_handler.py — test Gradio handler: empty message returns immediately, streaming yields tokens, cancel stops generator, LLM unavailable yields error event.
  • T025 [US1] Write frontend/src/lib/models/__tests__/AgentChatModel.test.ts — L1 model tests (no DOM render): sendMessage transitions state, cancelGeneration resets, disconnect/reconnect state machine, invalid state transitions rejected.
  • T026 [US1] Verify: cd backend && source .venv/bin/activate && python -m pytest backend/tests/test_agent/ -v
  • T027 [US1] Verify: cd frontend && npm run test -- --reporter=verbose
  • T028 [US1] Verify UX against ux_reference.md: streaming tokens appear in real time, Stop button halts generation, connection dot green/red/yellow.

Phase 4: Story 2 — Autonomous Tool Selection via Agent (P1) 🎯 MVP

Backend

  • T026 [US2] Implement backend/src/agent/tools.py — native LangChain @tool-декорированные функции. Каждый @tool: Pydantic BaseModel для args_schema, docstring для description, внутри → HTTP-вызов к FastAPI REST с service JWT. Пример: @tool async def search_dashboards(query: str, environment: str = None) → str. Список тулов: все существующие @assistant_tool операции. @DATA_CONTRACT Each @tool: Pydantic args → FastAPI REST call → JSON string result RATIONALE Native @tool replaces dual @assistant_tool + StructuredTool wrapping

  • T027 [US2] Implement backend/src/agent/middleware.pyLoggingMiddleware (логирует tool-call события в audit trail), ConfirmationRiskMiddleware (определяет interrupt_on список из конфига TOOL_RISK_LEVELS).

  • T028 [US2] Register tools in langgraph_setup.pyDANGEROUS_TOOLS = {"deploy_dashboard", "execute_migration", "commit_changes"}. Compile graph with interrupt_before=DANGEROUS_TOOLS. All tools from tools.py registered as graph nodes. @POST Agent with tools, interrupt, logging, checkpoints, history — all LangChain-native

  • T029 [US2] Implement interrupt handler in app.py — when graph pauses at interrupt_before node: yield ChatMessage(metadata={"type":"confirm_required", ...}). On second submit with additional_inputs[1]="confirm"/"deny": invoke graph with Command(resume={"action": action}, config={"thread_id": conversation_id}). Graph resumes from checkpoint.

  • T031 [US2] Implement confirmation rendering in AssistantChatPanel.svelte — on metadata.type="confirm_required": warning card + Confirm/Deny. Confirm → submit("/chat", {text:"confirm"}, [conversationId, "confirm"]). Deny → submit("/chat", {text:"deny"}, [conversationId, "deny"]). On metadata.type="confirm_resolved": collapse card.

Verification

  • T034 [US2] Write backend/tests/test_agent/test_langchain_tools.py — test: REST-calling tools wrap correctly, agent selects tool, agent handles tool failure gracefully.
  • T035 [US2] Write backend/tests/test_agent/test_confirmations.py — test: confirmation created, confirmed, denied, expired flows.
  • T036 [US2] Verify: cd backend && source .venv/bin/activate && python -m pytest backend/tests/test_agent/ -v -k "tool or confirm"
  • T037 [US2] Verify UX: confirmation card appears for dangerous ops, confirm via second submit() with __resume__, deny yields ⏹️.

Phase 5: Story 3 — Tool-Call Visibility in Chat (P2)

Frontend

  • T039 [US3] Implement frontend/src/lib/components/assistant/ToolCallCard.svelte — inline card within assistant message. Props: {tool, input, output?, error?, status}. Visual: tool name in mono font, spinner (executing)/checkmark (done)/cross (error). Expandable to show input params and output result. @UX_STATE executing (spinner), completed (checkmark, expandable result), failed (cross, error detail) @RELATION DEPENDS_ON -> $lib/ui/Icon (existing) @RELATION DEPENDS_ON -> $lib/ui/Button variant="ghost" (existing, for expand/collapse) Design tokens: card=bg-surface-muted border border-border rounded-lg, tool name=text-text-muted text-xs font-mono, spinner=animate-spin text-primary, checkmark=text-success, cross=text-destructive

  • T040 [US3] Integrate ToolCallCard into AssistantChatPanel.svelte — render ToolCallCard when metadata.type="tool_start"/"tool_end"/"tool_error". On tool_start: card+spinner. On tool_end: checkmark+result. On tool_error: cross+error.

  • T041 [US3] Implement metadata dispatch in AgentChatModel._onStreamData(msg) — handle tool_start (push card), tool_end (update status), tool_error (update status). Update activeToolCalls[].

Verification

  • T042 [US3] Write frontend/src/lib/components/assistant/__tests__/ToolCallCard.test.ts — test: renders tool name, shows spinner in executing state, shows checkmark on completed, shows cross on error, expand/collapse toggles detail visibility.
  • T043 [US3] Verify: cd frontend && npm run test -- --reporter=verbose
  • T044 [US3] Verify UX: multi-tool query → each tool card appears inline, can be expanded, shows real-time status.

Phase 6: Story 4 — Multi-Turn Conversation Context (P2)

Backend

  • T045 [US4] Verify RunnableWithMessageHistory auto-loads context from PostgreSQL per thread_id=conversation_id. No manual history injection needed.

Frontend

  • T047 [US4] Implement AgentChatModel.createConversation() in frontend/src/lib/models/AgentChatModel.svelte.ts — clear messages, reset streamingState→idle, set currentConversationId=null. Existing conversation is NOT lost (persisted in DB). @ACTION createConversation(): starts clean conversation @POST messages=[], streamingState="idle", partialText="" @TEST_EDGE: create_during_streaming→cancel_first_then_create

Verification

  • T048 [US4] Write integration test: send message 1 referencing entity, send message 2 with pronoun → agent resolves pronoun from context.
  • T049 [US4] Write test: conversation exceeds 4000 tokens → older messages summarized, recent detail preserved.
  • T050 [US4] Verify: cd backend && source .venv/bin/activate && python -m pytest backend/tests/test_agent/ -v -k "memory or context"
  • T051 [US4] Verify UX: follow-up question resolves prior context, new conversation starts clean.

Phase 7: Story 5 — Conversation Persistence and Management (P3)

Backend

  • T052 [US5] Implement backend/src/api/routes/agent_conversations.py — FastAPI router: POST /api/assistant/conversations (create), GET /api/assistant/conversations (paginated list + search), GET /api/assistant/history (paginated messages), POST /api/assistant/conversations/{id}/messages (append batch after stream_end), DELETE /api/assistant/conversations/{id} (soft-delete: is_archived=true).

  • T053 [US5] Implement POST /api/assistant/conversations: @PRE User authenticated, title from first message (truncated 60 chars) @POST ConversationItem(id, title, created_at) — HTTP 201 @TEST_EDGE: unauthenticated→401 @PRE User authenticated (via existing FastAPI auth dependency) @POST ConversationListResponse(items[], has_next, active_total, archived_total) @DATA_CONTRACT Input: (page, page_size, include_archived?, search?) → Output: ConversationListResponse @TEST_EDGE: empty_result→items=[], totals=0, no_error — filter_invalid→ignored

  • T054 [US5] Implement AgentChat.Api.GetHistory in backend/src/api/routes/agent_conversations.py: @PRE conversation_id valid @POST HistoryResponse(items[], has_next, conversation_id) @TEST_EDGE: invalid_conversation_id→404 — empty_conversation→items=[], no_error

  • T055 [US5] Implement AgentChat.Api.DeleteConversation in backend/src/api/routes/agent_conversations.py: @PRE conversation_id valid, user owns conversation @POST DeleteResponse(deleted=true), conversation is_archived=true @SIDE_EFFECT Sets is_archived=true, cascades to messages (soft delete) @TEST_EDGE: already_deleted→404 — not_owner→403

  • T056 [US5] Register agent_conversations router in backend/src/app.py — prefix /api/assistant (shares namespace with existing assistant routes for backward compat FR-020).

Frontend

  • T057 [US5] Implement AgentChatModel.loadConversations() and loadHistory() in frontend/src/lib/models/AgentChatModel.svelte.ts — call existing getAssistantConversations() and getAssistantHistory() from frontend/src/lib/api/assistant.ts. Infinite scroll for both: append on next page, reset on new filter. @ACTION loadConversations(reset?): fetches from REST, manages pagination @POST conversations array updated, conversationsHasNext flag set @ACTION loadHistory(conversationId?): fetches messages from REST @POST messages array populated, historyHasNext flag set @TEST_EDGE: api_error→error state with retry, empty_response→empty state

  • T058 [US5] Implement AgentChatModel.deleteConversation() in frontend/src/lib/models/AgentChatModel.svelte.ts — optimistic removal from conversations array, DELETE via REST, rollback on failure with toast. @ACTION deleteConversation(id): optimistic delete @POST removed from list, toast on success/error @TEST_EDGE: api_failure→rollback_reinsert, active_conversation_deleted→switch_to_next

  • T059 [US5] Implement frontend/src/lib/components/assistant/ConversationList.svelte — sidebar list (240px) with: <Input> for search (debounced 300ms), grouped by date on client ($derived from conversations), infinite scroll (IntersectionObserver), delete button (with confirm() before calling model.deleteConversation), active state highlight (bg-surface-muted). Skeleton on load. @UX_STATE loading (skeleton), loaded (grouped list), empty ("Нет диалогов"), error (retry) @RELATION DEPENDS_ON -> $lib/ui/Input (existing), $lib/ui/Button variant="ghost" (existing), $lib/ui/Icon (existing) @RELATION BINDS_TO -> AgentChat.Model Design tokens: sidebar=bg-surface-card border-r border-border, skeleton=animate-pulse bg-surface-muted

  • T060 [US5] Integrate ConversationList into AssistantChatPanel.svelte (drawer header as dropdown) and into /agent page (left sidebar).

Verification

  • T061 [US5] Write backend/tests/test_agent/test_conversation_api.py — test: list returns paginated, search filters by title, history returns messages, delete archives conversation, 404 on invalid id, 403 on other user's conversation.
  • T062 [US5] Write frontend/src/lib/components/assistant/__tests__/ConversationList.test.ts — test: renders conversations, groups by date, search filters, delete shows confirm and removes, error shows retry.
  • T063 [US5] Verify: cd backend && source .venv/bin/activate && python -m pytest backend/tests/test_agent/test_conversation_api.py -v
  • T064 [US5] Verify: cd frontend && npm run test -- --reporter=verbose

Phase 8: Story 6 — File Upload and Document Analysis (P2)

Backend

  • T065 [US6] Implement backend/src/agent/document_parser.pyparse_pdf(file_path) using pdfplumber (primary, extracts text + tables), PyPDF2 (fallback for encrypted). parse_xlsx(file_path) using openpyxl (sheet names → 2D cell arrays). @PRE File exists on disk, valid extension @POST Returns extracted text (PDF) or structured dict (XLSX) @TEST_EDGE: encrypted_pdf→ParseError with message, password_xlsx→ParseError, empty_file→empty_string (no crash), unsupported_extension→ParseError

  • T066 [US6] Integrate document parser into agent_handler in backend/src/agent/app.py — before running agent, detect file attachments (Gradio gr.File component), parse content via T065, inject as system message: "Вот содержимое файла {name}:\n{parsed_text}". @PRE File uploaded via Gradio multimodal input @POST Parsed content injected as system message in conversation

  • T067 [US6] Add file size validation (10MB limit) in agent_handler — reject oversized files before parsing, return error message to user. @PRE File attached to message @POST File accepted if ≤10MB, rejected with error message if exceeded

Frontend

  • T068 [US6] Add file upload UI to AssistantChatPanel.svelte — paperclip icon button (using $lib/ui/Icon name="paperclip"), hidden <input type="file" accept=".pdf,.xlsx,.json,.csv,.txt,.png,.jpeg">, preview chip below input showing filename + size. Validate format and size before upload (10MB client-side check). @UX_FEEDBACK file chip appears below input, invalid format→toast error, oversized→toast error @RELATION DEPENDS_ON -> $lib/ui/Icon (existing)

  • T069 [US6] Pass file attachments through AgentChatModel.sendMessage() — append file data to WebSocket message payload, show parsing indicator on file chip while agent processes. @TEST_EDGE: upload_during_streaming→rejected, upload_empty_file→validation_error

Verification

  • T070 [US6] Write backend/tests/test_agent/test_document_parser.py — test: PDF extracts text, PDF with tables preserves structure, XLSX parses all sheets, encrypted/empty/invalid files handled gracefully.
  • T071 [US6] Verify: cd backend && source .venv/bin/activate && python -m pytest backend/tests/test_agent/test_document_parser.py -v
  • T072 [US6] Verify UX: upload PDF → agent analyzes content, upload XLSX → agent reads tabular data, oversized file → rejected.

Phase 9: Polish & Cross-Cutting Verification

Frontend

  • T073 Implement frontend/src/routes/agent/+page.svelte — full-page agent chat at /agent route. Two-column layout: <ConversationList> (240px left sidebar) + <AssistantChatPanel variant="embedded"> (right area). <PageHeader> title="Агент-чат". Protected route (auth check in +layout.svelte). @RELATION BINDS_TO -> AgentChat.Model (same instance) @RELATION DEPENDS_ON -> $lib/ui/PageHeader (existing) @RELATION DEPENDS_ON -> AgentChat.ConversationList @RELATION DEPENDS_ON -> AgentChat.Panel Design tokens: page=bg-surface-page max-w-7xl mx-auto, two-column=flex, sidebar=w-60 shrink-0 bg-surface-card border-r border-border, chat=flex-1

  • T074 Implement frontend/src/lib/components/assistant/ConnectionIndicator.svelte — green/red dot binding to model.connectionDotColor $derived. Tooltip: "Подключено"/"Недоступно". @RELATION BINDS_TO -> AgentChat.Model Design tokens: green=bg-success w-2 h-2 rounded-full, red=bg-destructive w-2 h-2 rounded-full

  • T075 Implement multi-tab gate in AgentChatModel.sendMessage() — before sending, call POST /api/agent/conversations/{id}/active to check for existing session. If active → reject with toast "Диалог активен в другой вкладке". @TEST_EDGE: second_tab_send→rejected_with_toast, first_tab_send→proceeds

  • T076 Add "Expand to /agent" button in drawer header — navigates to /agent route preserving current conversation context via store. @UX_FEEDBACK smooth navigation, conversation state preserved

  • T077 Implement conversation auto-title — on first agent response, set AgentConversation.title from truncated first user message (60 chars) during conversation creation in T052. No separate PATCH endpoint needed — title is set at insert time. @POST Conversation.title populated at creation

Backend

  • T078 Add audit logging to agent_handler — every user message and agent response writes to existing assistant_audit table: {user_id, conversation_id, decision: "message_sent"/"tool_called"/"confirmed"/"denied", message, payload}. @SIDE_EFFECT Writes to assistant_audit table RATIONALE FR-010: all agent interactions logged for auditability

  • T079 Implement Gradio healthcheck endpoint — GET /health returns {"status":"ok","uptime":...} for Docker healthcheck.

  • T080 [P] Implement backend/src/api/routes/auth.py — internal endpoint POST /api/auth/service-token that accepts a static service secret (env SERVICE_TOKEN_SECRET) and returns a long-lived JWT with role agent. Used by Gradio container at startup for Gradio→FastAPI HTTP calls. @DATA_CONTRACT Input: {service_secret} → Output: {access_token, expires_in} @TEST_EDGE: invalid_secret→401, expired_token→401_on_next_call RATIONALE FR-023: Gradio→FastAPI calls authenticated via JWT, not separate API key mechanism

  • T081 [P] Add i18n keys to frontend/src/lib/i18n/locales/ru/assistant.json and frontend/src/lib/i18n/locales/en/assistant.json — new keys: stop, tool_executing, tool_completed, tool_failed, confirm_required, confirm_expired, confirm_retry, file_upload, file_parse_error, file_unsupported, file_too_large, connection_lost, reconnecting, manual_reconnect, archive_dialog. RATIONALE FR-013: Russian + English i18n for all new UX strings (8+ in UX reference §3-4)

  • T082 Implement LLM malformed JSON retry in backend/src/agent/app.py agent_handler — catch OutputParserException from LangChain, retry once with "Respond with valid JSON only. Previous response was malformed." appended to prompt. If still malformed, yield stream_error with clarification request. @TEST_EDGE: first_retry_succeeds→normal_flow, second_failure→stream_error_with_clarification RATIONALE Spec Edge Cases: "LLM returns malformed tool-call JSON → retry once with stricter prompt"

  • T083 Implement rapid-fire message queue in AgentChatModel.sendMessage() in frontend/src/lib/models/AgentChatModel.svelte.ts — if streamingState !== "idle", enqueue message; process queue sequentially when state returns to idle. Show queue position badge if >1 pending. @TEST_EDGE: send_during_streaming→queued, send_during_sending→queued, queue_drains_on_idle RATIONALE Spec Edge Cases: "Simultaneous rapid-fire messages → queue and process sequentially"

Verification (cross-cutting)

  • T084 [P] Backend full test suite: cd backend && source .venv/bin/activate && python -m pytest backend/tests/ -v
  • T085 [P] Frontend full test suite: cd frontend && npm run test -- --reporter=verbose
  • T086 [P] Backend lint: cd backend && python -m ruff check src/agent/ src/api/routes/agent_conversations.py src/models/agent.py src/schemas/agent.py
  • T087 [P] Frontend lint: cd frontend && npm run lint
  • T088 [P] Frontend build: cd frontend && npm run build
  • T089 Semantic audit — agent contracts: axiom_semantic_validation audit_contracts file_path="backend/src/agent/"
  • T090 UX reference validation — verify all states from contracts/ux/agent-chat-ux.md (15 FSM states) are reachable and rendered correctly via browser or vitest @UX_TEST scenarios.
  • T091 Rejected-path regression test — verify that /api/assistant REST endpoints still function (FR-020 backward compat). Existing backend/tests/test_assistant_api.py passes unmodified.
  • T092 Verify ADR guardrails: @REJECTED full Svelte replacement → SvelteKit routes intact; @REJECTED custom SSE/WebSocket → @gradio/client used exclusively; @REJECTED separate auth mechanism → JWT reused.

Task Summary

Phase Stories Tasks Parallel
P1 — Setup T001-T009 6
P2 — Foundational T010-T014 2
P3 — US1 (Streaming) P1 🎯 T015-T028
P4 — US2 (Tools) P1 🎯 T029-T038
P5 — US3 (Visibility) P2 T039-T044
P6 — US4 (Context) P2 T045-T051
P7 — US5 (Persistence) P3 T052-T064
P8 — US6 (Files) P2 T065-T072
P9 — Polish + Gaps T073-T092 8
Total 92 16

Story Independence

Story Independent Test
US1 Launch Gradio, connect from Svelte, send message → streaming tokens appear
US2 Send multi-tool query → agent selects correct tools autonomously
US3 Multi-tool query → tool-call cards visible inline, expandable
US4 Follow-up with pronoun → agent resolves from prior context
US5 3 conversations, restart app → all 3 appear, resume works
US6 Upload PDF → agent extracts and analyzes content

#endregion AgentChat.Tasks