Compare commits
6 Commits
c0fa0cefee
...
7469f39259
| Author | SHA1 | Date | |
|---|---|---|---|
| 7469f39259 | |||
| 9af4ad5816 | |||
| 7281285b13 | |||
| 3c10bceba7 | |||
| 56c11a0f1a | |||
| c72beb916b |
@@ -66,11 +66,11 @@ Requirements for beta-ready release. Each maps to roadmap phases.
|
|||||||
|
|
||||||
### Web Chat
|
### Web Chat
|
||||||
|
|
||||||
- [ ] **CHAT-01**: Users can open a chat window with any AI Employee and have a real-time conversation within the portal
|
- [x] **CHAT-01**: Users can open a chat window with any AI Employee and have a real-time conversation within the portal
|
||||||
- [ ] **CHAT-02**: Web chat supports the full agent pipeline — memory, tools, escalation, and media (same capabilities as Slack/WhatsApp)
|
- [x] **CHAT-02**: Web chat supports the full agent pipeline — memory, tools, escalation, and media (same capabilities as Slack/WhatsApp)
|
||||||
- [ ] **CHAT-03**: Conversation history persists and is visible when the user returns to the chat
|
- [x] **CHAT-03**: Conversation history persists and is visible when the user returns to the chat
|
||||||
- [ ] **CHAT-04**: Chat respects RBAC — users can only chat with agents belonging to tenants they have access to
|
- [x] **CHAT-04**: Chat respects RBAC — users can only chat with agents belonging to tenants they have access to
|
||||||
- [ ] **CHAT-05**: Chat interface feels responsive — typing indicators, message streaming or fast response display
|
- [x] **CHAT-05**: Chat interface feels responsive — typing indicators, message streaming or fast response display
|
||||||
|
|
||||||
## v2 Requirements
|
## v2 Requirements
|
||||||
|
|
||||||
@@ -156,11 +156,11 @@ Which phases cover which requirements. Updated during roadmap creation.
|
|||||||
| EMPL-03 | Phase 5 | Complete |
|
| EMPL-03 | Phase 5 | Complete |
|
||||||
| EMPL-04 | Phase 5 | Complete |
|
| EMPL-04 | Phase 5 | Complete |
|
||||||
| EMPL-05 | Phase 5 | Complete |
|
| EMPL-05 | Phase 5 | Complete |
|
||||||
| CHAT-01 | Phase 6 | Pending |
|
| CHAT-01 | Phase 6 | Complete |
|
||||||
| CHAT-02 | Phase 6 | Pending |
|
| CHAT-02 | Phase 6 | Complete |
|
||||||
| CHAT-03 | Phase 6 | Pending |
|
| CHAT-03 | Phase 6 | Complete |
|
||||||
| CHAT-04 | Phase 6 | Pending |
|
| CHAT-04 | Phase 6 | Complete |
|
||||||
| CHAT-05 | Phase 6 | Pending |
|
| CHAT-05 | Phase 6 | Complete |
|
||||||
|
|
||||||
**Coverage:**
|
**Coverage:**
|
||||||
- v1 requirements: 25 total (all complete)
|
- v1 requirements: 25 total (all complete)
|
||||||
|
|||||||
@@ -140,7 +140,7 @@ Phases execute in numeric order: 1 -> 2 -> 3 -> 4 -> 5 -> 6
|
|||||||
| 3. Operator Experience | 5/5 | Complete | 2026-03-24 |
|
| 3. Operator Experience | 5/5 | Complete | 2026-03-24 |
|
||||||
| 4. RBAC | 3/3 | Complete | 2026-03-24 |
|
| 4. RBAC | 3/3 | Complete | 2026-03-24 |
|
||||||
| 5. Employee Design | 4/4 | Complete | 2026-03-25 |
|
| 5. Employee Design | 4/4 | Complete | 2026-03-25 |
|
||||||
| 6. Web Chat | 0/3 | Not started | - |
|
| 6. Web Chat | 3/3 | Complete | 2026-03-25 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -3,14 +3,14 @@ gsd_state_version: 1.0
|
|||||||
milestone: v1.0
|
milestone: v1.0
|
||||||
milestone_name: milestone
|
milestone_name: milestone
|
||||||
status: completed
|
status: completed
|
||||||
stopped_at: Phase 6 context gathered
|
stopped_at: Completed 06-03-PLAN.md
|
||||||
last_updated: "2026-03-25T14:38:50.473Z"
|
last_updated: "2026-03-25T16:41:32.580Z"
|
||||||
last_activity: 2026-03-23 — Completed 03-02 onboarding wizard, Slack OAuth, BYO API keys
|
last_activity: 2026-03-23 — Completed 03-02 onboarding wizard, Slack OAuth, BYO API keys
|
||||||
progress:
|
progress:
|
||||||
total_phases: 6
|
total_phases: 6
|
||||||
completed_phases: 5
|
completed_phases: 6
|
||||||
total_plans: 22
|
total_plans: 25
|
||||||
completed_plans: 22
|
completed_plans: 25
|
||||||
percent: 100
|
percent: 100
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -74,6 +74,9 @@ Progress: [██████████] 100%
|
|||||||
| Phase 05-employee-design PP02 | 5min | 2 tasks | 15 files |
|
| Phase 05-employee-design PP02 | 5min | 2 tasks | 15 files |
|
||||||
| Phase 05-employee-design P03 | 2min | 1 tasks | 0 files |
|
| Phase 05-employee-design P03 | 2min | 1 tasks | 0 files |
|
||||||
| Phase 05-employee-design P04 | 1min | 2 tasks | 3 files |
|
| Phase 05-employee-design P04 | 1min | 2 tasks | 3 files |
|
||||||
|
| Phase 06-web-chat P01 | 8min | 2 tasks | 11 files |
|
||||||
|
| Phase 06-web-chat PP02 | 6min | 2 tasks | 10 files |
|
||||||
|
| Phase 06-web-chat P03 | verification | 1 tasks | 0 files |
|
||||||
|
|
||||||
## Accumulated Context
|
## Accumulated Context
|
||||||
|
|
||||||
@@ -159,6 +162,12 @@ Recent decisions affecting current work:
|
|||||||
- [Phase 05-employee-design]: All three creation paths (template, wizard, advanced) confirmed working by human review before Phase 5 marked complete
|
- [Phase 05-employee-design]: All three creation paths (template, wizard, advanced) confirmed working by human review before Phase 5 marked complete
|
||||||
- [Phase 05-employee-design]: /agents/new added to CUSTOMER_OPERATOR_RESTRICTED — startsWith check covers all sub-paths automatically
|
- [Phase 05-employee-design]: /agents/new added to CUSTOMER_OPERATOR_RESTRICTED — startsWith check covers all sub-paths automatically
|
||||||
- [Phase 05-employee-design]: catch re-throw in handleDeploy is minimal fix — existing createAgent.error UI was correctly wired, just never received the error
|
- [Phase 05-employee-design]: catch re-throw in handleDeploy is minimal fix — existing createAgent.error UI was correctly wired, just never received the error
|
||||||
|
- [Phase 06-web-chat]: WebSocket auth via first JSON message after connection — browser WebSocket API cannot send custom HTTP headers
|
||||||
|
- [Phase 06-web-chat]: thread_id = conversation_id in web channel normalizer — scopes agent memory to one web conversation per conversation ID
|
||||||
|
- [Phase 06-web-chat]: Redis pub-sub delivery: orchestrator publishes to webchat_response_key, WebSocket subscribes with 60s timeout before sending to client
|
||||||
|
- [Phase 06-web-chat]: useSearchParams wrapped in Suspense boundary — Next.js 16 static prerendering requires Suspense for pages using URL params
|
||||||
|
- [Phase 06-web-chat]: Stable callback refs in useChatSocket — onMessage/onTyping held in refs so WebSocket effect re-runs only when conversationId or auth changes
|
||||||
|
- [Phase 06-web-chat]: All CHAT requirements (CHAT-01 through CHAT-05) verified by human testing before Phase 6 marked complete
|
||||||
|
|
||||||
### Roadmap Evolution
|
### Roadmap Evolution
|
||||||
|
|
||||||
@@ -174,6 +183,6 @@ None — all phases complete.
|
|||||||
|
|
||||||
## Session Continuity
|
## Session Continuity
|
||||||
|
|
||||||
Last session: 2026-03-25T14:38:50.470Z
|
Last session: 2026-03-25T16:37:36.187Z
|
||||||
Stopped at: Phase 6 context gathered
|
Stopped at: Completed 06-03-PLAN.md
|
||||||
Resume file: .planning/phases/06-web-chat/06-CONTEXT.md
|
Resume file: None
|
||||||
|
|||||||
147
.planning/phases/06-web-chat/06-01-SUMMARY.md
Normal file
147
.planning/phases/06-web-chat/06-01-SUMMARY.md
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
---
|
||||||
|
phase: 06-web-chat
|
||||||
|
plan: 01
|
||||||
|
subsystem: backend
|
||||||
|
tags: [web-chat, websocket, redis-pubsub, rbac, orm, migration]
|
||||||
|
dependency_graph:
|
||||||
|
requires: []
|
||||||
|
provides:
|
||||||
|
- WebSocket endpoint at /chat/ws/{conversation_id}
|
||||||
|
- REST API at /api/portal/chat/* for conversation CRUD
|
||||||
|
- web_conversations and web_conversation_messages tables with RLS
|
||||||
|
- Redis pub-sub response delivery for web channel
|
||||||
|
- ChannelType.WEB in shared message model
|
||||||
|
affects:
|
||||||
|
- packages/orchestrator/orchestrator/tasks.py (new web channel routing)
|
||||||
|
- packages/shared/shared/api/__init__.py (chat_router added)
|
||||||
|
- packages/gateway/gateway/main.py (Phase 6 routers mounted)
|
||||||
|
tech_stack:
|
||||||
|
added:
|
||||||
|
- gateway/channels/web.py (FastAPI WebSocket + normalize_web_event)
|
||||||
|
- shared/api/chat.py (conversation CRUD REST API)
|
||||||
|
- shared/models/chat.py (WebConversation + WebConversationMessage ORM)
|
||||||
|
- migrations/versions/008_web_chat.py (DB tables + RLS + CHECK constraint update)
|
||||||
|
patterns:
|
||||||
|
- WebSocket auth via first JSON message (browser cannot send custom headers)
|
||||||
|
- Redis pub-sub for async response delivery from Celery to WebSocket
|
||||||
|
- thread_id = conversation_id for agent memory scoping
|
||||||
|
- try/finally around all Redis connections to prevent leaks
|
||||||
|
- TEXT+CHECK for role column (not sa.Enum) per Phase 1 ADR
|
||||||
|
- SQLAlchemy 2.0 Mapped[]/mapped_column() style
|
||||||
|
- require_tenant_member RBAC guard on all REST endpoints
|
||||||
|
key_files:
|
||||||
|
created:
|
||||||
|
- packages/gateway/gateway/channels/web.py
|
||||||
|
- packages/shared/shared/api/chat.py
|
||||||
|
- packages/shared/shared/models/chat.py
|
||||||
|
- migrations/versions/008_web_chat.py
|
||||||
|
- tests/unit/test_web_channel.py
|
||||||
|
- tests/unit/test_chat_api.py
|
||||||
|
modified:
|
||||||
|
- packages/shared/shared/models/message.py (ChannelType.WEB added)
|
||||||
|
- packages/shared/shared/redis_keys.py (webchat_response_key added)
|
||||||
|
- packages/shared/shared/api/__init__.py (chat_router exported)
|
||||||
|
- packages/gateway/gateway/main.py (Phase 6 routers mounted)
|
||||||
|
- packages/orchestrator/orchestrator/tasks.py (web channel extras + routing)
|
||||||
|
decisions:
|
||||||
|
- "WebSocket auth via first JSON message after connection — browser WebSocket API cannot send custom HTTP headers"
|
||||||
|
- "thread_id = conversation_id in normalize_web_event — scopes agent memory to one web conversation (consistent with WhatsApp wa_id scoping)"
|
||||||
|
- "Redis pub-sub response delivery: orchestrator publishes to webchat_response_key, WebSocket handler subscribes with 60s timeout"
|
||||||
|
- "TEXT+CHECK for role column ('user'/'assistant') per Phase 1 ADR — not sa.Enum"
|
||||||
|
- "dependency_overrides used in tests instead of patching shared.db.get_session — FastAPI dependency injection doesn't follow module-level patches"
|
||||||
|
metrics:
|
||||||
|
duration: "~8 minutes"
|
||||||
|
completed_date: "2026-03-25"
|
||||||
|
tasks_completed: 2
|
||||||
|
files_created: 6
|
||||||
|
files_modified: 5
|
||||||
|
---
|
||||||
|
|
||||||
|
# Phase 6 Plan 01: Web Chat Backend Infrastructure Summary
|
||||||
|
|
||||||
|
**One-liner:** WebSocket endpoint + Redis pub-sub response bridge + RBAC REST API providing complete web chat plumbing from portal UI to the agent pipeline.
|
||||||
|
|
||||||
|
## What Was Built
|
||||||
|
|
||||||
|
This plan establishes the complete backend for web chat — the "web" channel that lets portal users talk to AI employees directly from the Konstruct portal UI without setting up Slack or WhatsApp.
|
||||||
|
|
||||||
|
### ChannelType.WEB and Redis key
|
||||||
|
`ChannelType.WEB = "web"` added to the shared message model. `webchat_response_key(tenant_id, conversation_id)` added to `redis_keys.py` following the established namespace pattern (`{tenant_id}:webchat:response:{conversation_id}`).
|
||||||
|
|
||||||
|
### DB Schema (migration 008)
|
||||||
|
Two new tables with FORCE ROW LEVEL SECURITY:
|
||||||
|
- `web_conversations` — one per (tenant_id, agent_id, user_id) triple with UniqueConstraint for get-or-create semantics
|
||||||
|
- `web_conversation_messages` — individual messages with TEXT+CHECK role column ('user'/'assistant') and CASCADE delete
|
||||||
|
- `channel_connections.channel_type` CHECK constraint replaced to include 'web'
|
||||||
|
|
||||||
|
### WebSocket Endpoint (`/chat/ws/{conversation_id}`)
|
||||||
|
Full message lifecycle in `gateway/channels/web.py`:
|
||||||
|
1. Accept connection
|
||||||
|
2. Auth handshake via first JSON message (browser limitation)
|
||||||
|
3. For each message: typing indicator → save to DB → Celery dispatch → Redis subscribe → save response → send to client
|
||||||
|
4. try/finally cleanup on all Redis connections
|
||||||
|
|
||||||
|
### REST API (`/api/portal/chat/*`)
|
||||||
|
Four endpoints in `shared/api/chat.py`:
|
||||||
|
- `GET /conversations` — list with RBAC (platform_admin sees all, others see own)
|
||||||
|
- `POST /conversations` — get-or-create with IntegrityError race condition handling
|
||||||
|
- `GET /conversations/{id}/messages` — paginated history with cursor support
|
||||||
|
- `DELETE /conversations/{id}` — message reset keeping conversation row
|
||||||
|
|
||||||
|
### Orchestrator Integration
|
||||||
|
`tasks.py` updated:
|
||||||
|
- `handle_message` pops `conversation_id` and `portal_user_id` before `model_validate`
|
||||||
|
- `_build_response_extras` handles "web" case returning `{conversation_id, tenant_id}`
|
||||||
|
- `_send_response` handles "web" case with Redis pub-sub publish and try/finally cleanup
|
||||||
|
- `webchat_response_key` imported at module level
|
||||||
|
|
||||||
|
## Test Coverage
|
||||||
|
|
||||||
|
19 unit tests written (TDD, all passing):
|
||||||
|
|
||||||
|
| Test | Covers |
|
||||||
|
|------|--------|
|
||||||
|
| `test_webchat_response_key_format` | Key format correct |
|
||||||
|
| `test_webchat_response_key_isolation` | Tenant isolation |
|
||||||
|
| `test_channel_type_web_exists` | ChannelType.WEB |
|
||||||
|
| `test_normalize_web_event_*` (5 tests) | Message normalization CHAT-01 |
|
||||||
|
| `test_send_response_web_publishes_to_redis` | Redis pub-sub publish CHAT-02 |
|
||||||
|
| `test_send_response_web_connection_cleanup` | try/finally Redis cleanup |
|
||||||
|
| `test_send_response_web_missing_conversation_id_logs_warning` | Error handling |
|
||||||
|
| `test_typing_indicator_sent_before_dispatch` | Typing indicator CHAT-05 |
|
||||||
|
| `test_chat_rbac_enforcement` | 403 for non-member CHAT-04 |
|
||||||
|
| `test_platform_admin_cross_tenant` | Admin bypass CHAT-04 |
|
||||||
|
| `test_list_conversation_history` | Paginated messages CHAT-03 |
|
||||||
|
| `test_create_conversation` | Get-or-create CHAT-03 |
|
||||||
|
| `test_create_conversation_rbac_forbidden` | 403 for non-member |
|
||||||
|
| `test_delete_conversation_resets_messages` | Message reset |
|
||||||
|
|
||||||
|
Full 313-test suite passes.
|
||||||
|
|
||||||
|
## Deviations from Plan
|
||||||
|
|
||||||
|
### Auto-fixed Issues
|
||||||
|
|
||||||
|
**1. [Rule 1 - Bug] Test dependency injection: patch vs dependency_overrides**
|
||||||
|
- **Found during:** Task 1 test implementation
|
||||||
|
- **Issue:** `patch("shared.db.get_session")` doesn't work for FastAPI endpoint testing because FastAPI's dependency injection resolves `Depends(get_session)` at function definition time, not via module attribute lookup
|
||||||
|
- **Fix:** Used `app.dependency_overrides[get_session] = _override_get_session` pattern in test helper `_make_app_with_session_override()` — consistent with other test files in the project
|
||||||
|
- **Files modified:** `tests/unit/test_chat_api.py`
|
||||||
|
|
||||||
|
**2. [Rule 2 - Missing functionality] session.refresh mock populating server defaults**
|
||||||
|
- **Found during:** Task 1 create_conversation test
|
||||||
|
- **Issue:** Mocked `session.refresh()` was a no-op, leaving `created_at`/`updated_at` as `None` on new ORM objects (server_default not applied without real DB)
|
||||||
|
- **Fix:** Test uses an async side_effect function that populates datetime fields on the object passed to `refresh()`
|
||||||
|
- **Files modified:** `tests/unit/test_chat_api.py`
|
||||||
|
|
||||||
|
## Self-Check: PASSED
|
||||||
|
|
||||||
|
All key artifacts verified:
|
||||||
|
- `ChannelType.WEB = "web"` — present in message.py
|
||||||
|
- `webchat_response_key()` — present in redis_keys.py
|
||||||
|
- `WebConversation` ORM class — present in models/chat.py
|
||||||
|
- `chat_websocket` WebSocket endpoint — present in gateway/channels/web.py
|
||||||
|
- `chat_router` — exported from shared/api/__init__.py
|
||||||
|
- `web_conversations` table — created in migration 008
|
||||||
|
- Commits `c72beb9` and `56c11a0` — verified in git log
|
||||||
|
- 313/313 unit tests pass
|
||||||
144
.planning/phases/06-web-chat/06-02-SUMMARY.md
Normal file
144
.planning/phases/06-web-chat/06-02-SUMMARY.md
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
---
|
||||||
|
phase: 06-web-chat
|
||||||
|
plan: 02
|
||||||
|
subsystem: frontend
|
||||||
|
tags: [web-chat, websocket, react-markdown, tanstack-query, portal-ui]
|
||||||
|
dependency_graph:
|
||||||
|
requires:
|
||||||
|
- packages/gateway/gateway/channels/web.py (WebSocket endpoint /chat/ws/{conversationId})
|
||||||
|
- packages/shared/shared/api/chat.py (REST API /api/portal/chat/*)
|
||||||
|
provides:
|
||||||
|
- /chat page accessible to all roles
|
||||||
|
- ChatSidebar, ChatWindow, ChatMessage, TypingIndicator components
|
||||||
|
- useChatSocket hook with auth handshake and reconnection
|
||||||
|
- useConversations, useConversationHistory, useCreateConversation, useDeleteConversation hooks
|
||||||
|
- Chat nav link visible to all roles
|
||||||
|
affects:
|
||||||
|
- packages/portal/components/nav.tsx (Chat link added)
|
||||||
|
- packages/portal/lib/api.ts (Conversation types added)
|
||||||
|
- packages/portal/lib/queries.ts (chat hooks added)
|
||||||
|
tech_stack:
|
||||||
|
added:
|
||||||
|
- react-markdown@^10.x (markdown rendering for assistant messages)
|
||||||
|
- remark-gfm (GitHub Flavored Markdown support)
|
||||||
|
- packages/portal/lib/use-chat-socket.ts (WebSocket lifecycle hook)
|
||||||
|
- packages/portal/components/chat-sidebar.tsx
|
||||||
|
- packages/portal/components/chat-window.tsx
|
||||||
|
- packages/portal/components/chat-message.tsx
|
||||||
|
- packages/portal/components/typing-indicator.tsx
|
||||||
|
- packages/portal/app/(dashboard)/chat/page.tsx
|
||||||
|
patterns:
|
||||||
|
- Suspense wrapper required for useSearchParams in Next.js 16 static prerendering
|
||||||
|
- Stable callback refs in useChatSocket to prevent WebSocket reconnect on re-renders
|
||||||
|
- Optimistic user message append before WebSocket send completes
|
||||||
|
- DialogTrigger with render prop (base-ui pattern, not asChild)
|
||||||
|
- crypto.randomUUID() for local message IDs before server assignment
|
||||||
|
key_files:
|
||||||
|
created:
|
||||||
|
- packages/portal/lib/use-chat-socket.ts
|
||||||
|
- packages/portal/components/chat-sidebar.tsx
|
||||||
|
- packages/portal/components/chat-window.tsx
|
||||||
|
- packages/portal/components/chat-message.tsx
|
||||||
|
- packages/portal/components/typing-indicator.tsx
|
||||||
|
- packages/portal/app/(dashboard)/chat/page.tsx
|
||||||
|
modified:
|
||||||
|
- packages/portal/lib/api.ts (Conversation, ConversationMessage, CreateConversationRequest, ConversationDetail types)
|
||||||
|
- packages/portal/lib/queries.ts (conversations/conversationHistory queryKeys + 4 hooks)
|
||||||
|
- packages/portal/components/nav.tsx (Chat nav item added)
|
||||||
|
- packages/portal/package.json (react-markdown, remark-gfm added)
|
||||||
|
decisions:
|
||||||
|
- "useSearchParams wrapped in Suspense boundary — Next.js 16 requires this for static prerendering of pages using URL params"
|
||||||
|
- "Stable callback refs in useChatSocket — onMessage/onTyping held in refs so WebSocket effect re-runs only when conversationId or auth changes, not on every render"
|
||||||
|
- "Optimistic user message appended locally before server echo — avoids waiting for WebSocket roundtrip to show the user's own message"
|
||||||
|
- "ChatPageInner + ChatPage split — useSearchParams must be inside Suspense; outer page provides fallback"
|
||||||
|
metrics:
|
||||||
|
duration: "~6 minutes"
|
||||||
|
completed_date: "2026-03-25"
|
||||||
|
tasks_completed: 2
|
||||||
|
files_created: 6
|
||||||
|
files_modified: 4
|
||||||
|
---
|
||||||
|
|
||||||
|
# Phase 6 Plan 02: Web Chat Portal UI Summary
|
||||||
|
|
||||||
|
**One-liner:** Full portal chat UI with WebSocket hook, markdown-rendering message bubbles, animated typing indicator, and conversation sidebar connecting to the Plan 01 gateway backend.
|
||||||
|
|
||||||
|
## What Was Built
|
||||||
|
|
||||||
|
This plan delivers the user-facing chat experience on top of the backend infrastructure from Plan 01.
|
||||||
|
|
||||||
|
### useChatSocket Hook (`lib/use-chat-socket.ts`)
|
||||||
|
|
||||||
|
WebSocket lifecycle management for browser clients:
|
||||||
|
- Connects to `${NEXT_PUBLIC_WS_URL}/chat/ws/{conversationId}`
|
||||||
|
- Sends JSON auth message immediately on open (browser WebSocket cannot send custom HTTP headers — established in Plan 01)
|
||||||
|
- Parses `{"type": "typing"}` and `{"type": "response", "text": "..."}` server messages
|
||||||
|
- Reconnects up to 3 times with 3-second delay after unexpected close
|
||||||
|
- Uses `useRef` for the WebSocket instance and callback refs for stable event handlers
|
||||||
|
- Intentional cleanup (unmount/conversationId change) sets `onclose = null` before closing to prevent spurious reconnect
|
||||||
|
|
||||||
|
### Chat Types and Query Hooks
|
||||||
|
|
||||||
|
Four new types in `api.ts`: `Conversation`, `ConversationMessage`, `CreateConversationRequest`, `ConversationDetail`.
|
||||||
|
|
||||||
|
Four new hooks in `queries.ts`:
|
||||||
|
- `useConversations(tenantId)` — lists all conversations for a tenant
|
||||||
|
- `useConversationHistory(conversationId)` — fetches last 50 messages
|
||||||
|
- `useCreateConversation()` — POST to create/get-or-create, invalidates conversations list
|
||||||
|
- `useDeleteConversation()` — DELETE with conversation + history invalidation
|
||||||
|
|
||||||
|
### Components
|
||||||
|
|
||||||
|
**TypingIndicator** — Three CSS `animate-bounce` dots with staggered `animationDelay` values (0ms, 150ms, 300ms) wrapped in a left-aligned muted bubble matching the assistant message style.
|
||||||
|
|
||||||
|
**ChatMessage** — Role-based bubble rendering:
|
||||||
|
- User: right-aligned, `bg-primary text-primary-foreground`, plain text
|
||||||
|
- Assistant: left-aligned, `bg-muted`, `Bot` icon avatar, full `react-markdown` with `remark-gfm` for code blocks, lists, links, tables
|
||||||
|
- Relative timestamp visible on hover via `opacity-0 group-hover:opacity-100`
|
||||||
|
|
||||||
|
**ChatSidebar** — Scrollable conversation list showing agent name, last message preview (truncated), and relative time. Active conversation highlighted with `bg-accent`. "New Conversation" button (Plus icon) triggers agent picker.
|
||||||
|
|
||||||
|
**ChatWindow** — Full-height conversation panel:
|
||||||
|
- Loads history via `useConversationHistory` on mount
|
||||||
|
- WebSocket via `useChatSocket` for real-time exchange
|
||||||
|
- Optimistically appends user message before server acknowledgement
|
||||||
|
- Auto-scrolls with `scrollIntoView({ behavior: "smooth" })` on new messages or typing changes
|
||||||
|
- Auto-growing textarea (capped at 96px / ~4 lines), Enter to send, Shift+Enter for newline
|
||||||
|
- Amber "Connecting..." banner when WebSocket disconnected
|
||||||
|
|
||||||
|
**ChatPage (`/chat`)** — Two-column layout (w-72 sidebar + flex-1 main):
|
||||||
|
- Reads `?id=` from URL via `useSearchParams` for bookmark/refresh support
|
||||||
|
- Agent picker dialog (base-ui `Dialog` with `render` prop on `DialogTrigger`) lists agents and calls `useCreateConversation`
|
||||||
|
- Session-derived auth headers passed to `ChatWindow` → `useChatSocket`
|
||||||
|
- Wrapped in `Suspense` (required for `useSearchParams` in Next.js 16)
|
||||||
|
|
||||||
|
### Nav Update
|
||||||
|
|
||||||
|
`MessageSquare` icon added to `nav.tsx` with `{ href: "/chat", label: "Chat" }` — no `allowedRoles` restriction, visible to operator, customer_admin, and platform_admin.
|
||||||
|
|
||||||
|
## Deviations from Plan
|
||||||
|
|
||||||
|
### Auto-fixed Issues
|
||||||
|
|
||||||
|
**1. [Rule 3 - Blocking] Suspense boundary required for useSearchParams**
|
||||||
|
- **Found during:** Task 2 build verification
|
||||||
|
- **Issue:** Next.js 16 static prerendering throws at build time when `useSearchParams()` is called outside a Suspense boundary: "useSearchParams() should be wrapped in a suspense boundary at page /chat"
|
||||||
|
- **Fix:** Extracted all page logic into `ChatPageInner` and wrapped it with `<Suspense fallback={...}>` in the `ChatPage` default export
|
||||||
|
- **Files modified:** `packages/portal/app/(dashboard)/chat/page.tsx`
|
||||||
|
- **Commit:** f9e67f9
|
||||||
|
|
||||||
|
## Self-Check: PASSED
|
||||||
|
|
||||||
|
All key artifacts verified:
|
||||||
|
|
||||||
|
- `packages/portal/app/(dashboard)/chat/page.tsx` — FOUND (235 lines, >50 min_lines)
|
||||||
|
- `packages/portal/components/chat-sidebar.tsx` — FOUND (contains ChatSidebar)
|
||||||
|
- `packages/portal/components/chat-window.tsx` — FOUND (contains ChatWindow)
|
||||||
|
- `packages/portal/components/chat-message.tsx` — FOUND (contains ChatMessage)
|
||||||
|
- `packages/portal/components/typing-indicator.tsx` — FOUND (contains TypingIndicator)
|
||||||
|
- `packages/portal/lib/use-chat-socket.ts` — FOUND (contains useChatSocket)
|
||||||
|
- WebSocket `new WebSocket` in use-chat-socket.ts — FOUND
|
||||||
|
- Nav href="/chat" in nav.tsx — FOUND
|
||||||
|
- useConversations/useConversationHistory in chat/page.tsx — FOUND
|
||||||
|
- Commits `7e21420` and `f9e67f9` — FOUND in git log
|
||||||
|
- Portal build: passes with `/chat` route listed
|
||||||
114
.planning/phases/06-web-chat/06-03-SUMMARY.md
Normal file
114
.planning/phases/06-web-chat/06-03-SUMMARY.md
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
---
|
||||||
|
phase: 06-web-chat
|
||||||
|
plan: 03
|
||||||
|
subsystem: ui
|
||||||
|
tags: [web-chat, verification, rbac, websocket, end-to-end]
|
||||||
|
|
||||||
|
# Dependency graph
|
||||||
|
requires:
|
||||||
|
- phase: 06-web-chat
|
||||||
|
provides: WebSocket gateway backend (Plan 01) and portal chat UI (Plan 02)
|
||||||
|
provides:
|
||||||
|
- Human-verified end-to-end web chat feature across all CHAT requirements
|
||||||
|
- Confirmed RBAC enforcement: all three roles can chat, scoped to accessible tenants
|
||||||
|
- Confirmed conversation history persistence across page navigations
|
||||||
|
- Confirmed typing indicator and markdown rendering in live environment
|
||||||
|
- Phase 6 complete — web chat feature production-ready
|
||||||
|
affects: []
|
||||||
|
|
||||||
|
# Tech tracking
|
||||||
|
tech-stack:
|
||||||
|
added: []
|
||||||
|
patterns:
|
||||||
|
- Human verification gate confirms live integration before phase close
|
||||||
|
|
||||||
|
key-files:
|
||||||
|
created: []
|
||||||
|
modified: []
|
||||||
|
|
||||||
|
key-decisions:
|
||||||
|
- "All CHAT requirements (CHAT-01 through CHAT-05) verified by human testing before Phase 6 marked complete"
|
||||||
|
|
||||||
|
patterns-established:
|
||||||
|
- "Checkpoint:human-verify as final gate before phase completion — ensures live environment matches code assertions"
|
||||||
|
|
||||||
|
requirements-completed:
|
||||||
|
- CHAT-01
|
||||||
|
- CHAT-02
|
||||||
|
- CHAT-03
|
||||||
|
- CHAT-04
|
||||||
|
- CHAT-05
|
||||||
|
|
||||||
|
# Metrics
|
||||||
|
duration: verification
|
||||||
|
completed: 2026-03-25
|
||||||
|
---
|
||||||
|
|
||||||
|
# Phase 6 Plan 03: Web Chat Human Verification Summary
|
||||||
|
|
||||||
|
**End-to-end web chat verified live: WebSocket messaging, conversation persistence, typing indicators, markdown rendering, and RBAC scoping all confirmed working across all three portal roles.**
|
||||||
|
|
||||||
|
## Performance
|
||||||
|
|
||||||
|
- **Duration:** Verification (human-gated checkpoint)
|
||||||
|
- **Started:** 2026-03-25
|
||||||
|
- **Completed:** 2026-03-25
|
||||||
|
- **Tasks:** 1 (human-verify checkpoint)
|
||||||
|
- **Files modified:** 0
|
||||||
|
|
||||||
|
## Accomplishments
|
||||||
|
|
||||||
|
- Human reviewer confirmed all 5 test scenarios from the verification checklist
|
||||||
|
- End-to-end flow verified: user sends message via WebSocket, receives LLM response
|
||||||
|
- Conversation history confirmed to persist and reload correctly on page revisit
|
||||||
|
- Typing indicator confirmed visible during response generation
|
||||||
|
- Markdown rendering confirmed correct in agent responses (bold, lists, code blocks)
|
||||||
|
- RBAC confirmed: customer_operator can chat but admin-only nav items remain hidden
|
||||||
|
- Platform admin confirmed able to chat with agents across tenants
|
||||||
|
|
||||||
|
## Task Commits
|
||||||
|
|
||||||
|
This plan contained a single human-verify checkpoint task — no code changes were required.
|
||||||
|
|
||||||
|
**Plan metadata:** (docs commit — see final_commit below)
|
||||||
|
|
||||||
|
## Files Created/Modified
|
||||||
|
|
||||||
|
None — this plan is a verification gate only. All implementation was completed in Plans 01 and 02.
|
||||||
|
|
||||||
|
## Decisions Made
|
||||||
|
|
||||||
|
All CHAT requirements (CHAT-01 through CHAT-05) verified by live human testing before Phase 6 marked complete. No deviations from the plan were needed.
|
||||||
|
|
||||||
|
## Deviations from Plan
|
||||||
|
|
||||||
|
None — plan executed exactly as written. Human reviewer approved all verification scenarios.
|
||||||
|
|
||||||
|
## Issues Encountered
|
||||||
|
|
||||||
|
None.
|
||||||
|
|
||||||
|
## User Setup Required
|
||||||
|
|
||||||
|
None - no external service configuration required.
|
||||||
|
|
||||||
|
## Next Phase Readiness
|
||||||
|
|
||||||
|
Phase 6 is complete. The web chat feature is production-ready:
|
||||||
|
- WebSocket-based real-time chat integrated with the full agent pipeline
|
||||||
|
- Conversation history persisted in PostgreSQL
|
||||||
|
- Markdown rendering and typing indicators fully functional
|
||||||
|
- RBAC enforced across all three roles (platform_admin, customer_admin, customer_operator)
|
||||||
|
|
||||||
|
No blockers. Phase 6 is the final planned phase — v1.0 feature set is complete.
|
||||||
|
|
||||||
|
## Self-Check: PASSED
|
||||||
|
|
||||||
|
- `06-03-SUMMARY.md` — FOUND
|
||||||
|
- STATE.md updated (progress recalculated: 25/25, 100%)
|
||||||
|
- ROADMAP.md updated (Phase 6 marked Complete, 3/3 summaries)
|
||||||
|
- Metrics recorded for phase 06-web-chat plan 03
|
||||||
|
|
||||||
|
---
|
||||||
|
*Phase: 06-web-chat*
|
||||||
|
*Completed: 2026-03-25*
|
||||||
162
.planning/phases/06-web-chat/06-VERIFICATION.md
Normal file
162
.planning/phases/06-web-chat/06-VERIFICATION.md
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
---
|
||||||
|
phase: 06-web-chat
|
||||||
|
verified: 2026-03-25T16:39:57Z
|
||||||
|
status: human_needed
|
||||||
|
score: 13/13 automated must-haves verified
|
||||||
|
human_verification:
|
||||||
|
- test: "Log in as customer_admin, click Chat in the sidebar navigation, click New Conversation, select an AI Employee, type a message, press Enter"
|
||||||
|
expected: "Animated typing dots appear immediately; agent response arrives as a left-aligned bubble; user message appears right-aligned"
|
||||||
|
why_human: "End-to-end requires live gateway, orchestrator, Celery worker, Redis, and LLM backend — cannot verify WebSocket round-trip programmatically"
|
||||||
|
- test: "Send a message that requests a formatted response (e.g. 'Give me a bulleted list of 3 tips')"
|
||||||
|
expected: "Response renders with proper markdown: bold text, bullet lists, and code blocks display correctly"
|
||||||
|
why_human: "Markdown rendering quality requires visual inspection in a running browser"
|
||||||
|
- test: "Navigate away from /chat then back; click a previous conversation"
|
||||||
|
expected: "Sidebar shows previous conversation with last message preview; clicking loads full message history"
|
||||||
|
why_human: "Persistence across page navigations requires a running DB and portal session"
|
||||||
|
- test: "Log in as customer_operator, navigate to /chat, start a conversation"
|
||||||
|
expected: "Chat link visible in sidebar; chat works; admin-only nav items (Billing, API Keys, Users) remain hidden"
|
||||||
|
why_human: "RBAC nav suppression and operator chat access require a live session with correct role claims"
|
||||||
|
- test: "If an agent has tools configured, send a message that triggers tool use"
|
||||||
|
expected: "Agent invokes the tool and incorporates the result into its response"
|
||||||
|
why_human: "Full pipeline with tool execution requires configured tools and a live Celery worker"
|
||||||
|
---
|
||||||
|
|
||||||
|
# Phase 6: Web Chat Verification Report
|
||||||
|
|
||||||
|
**Phase Goal:** Users can chat with AI Employees directly in the portal through a real-time web chat interface — no external messaging platform required
|
||||||
|
**Verified:** 2026-03-25T16:39:57Z
|
||||||
|
**Status:** human_needed
|
||||||
|
**Re-verification:** No — initial verification
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Goal Achievement
|
||||||
|
|
||||||
|
### Observable Truths
|
||||||
|
|
||||||
|
| # | Truth | Status | Evidence |
|
||||||
|
|---|-------|--------|----------|
|
||||||
|
| 1 | Web channel messages normalize into valid KonstructMessage with channel='web' | VERIFIED | `normalize_web_event()` in `gateway/channels/web.py:64-104` sets `channel=ChannelType.WEB`; `test_normalize_web_event_channel_is_web` passes |
|
||||||
|
| 2 | Celery `_send_response` publishes web channel responses to Redis pub-sub | VERIFIED | `_send_response` in `orchestrator/tasks.py:794-817` handles `channel_str == "web"` with `aioredis.publish`; `test_send_response_web_publishes_to_redis` passes |
|
||||||
|
| 3 | WebSocket endpoint accepts connections and dispatches messages to Celery pipeline | VERIFIED | `chat_websocket` at `web.py:319-340` routes to `_handle_websocket_connection`; `handle_message.delay(task_payload)` at line 245; mounted in `gateway/main.py:155` |
|
||||||
|
| 4 | Typing indicator event is sent immediately after receiving a user message | VERIFIED | `web.py:183` sends `{"type": "typing"}` before any DB or Celery work; `test_typing_indicator_sent_before_dispatch` passes |
|
||||||
|
| 5 | Chat REST API enforces RBAC — non-members get 403 | VERIFIED | `chat.py:107` calls `require_tenant_member`; `test_chat_rbac_enforcement` confirms 403 for non-member |
|
||||||
|
| 6 | Platform admin can access conversations for any tenant | VERIFIED | `chat.py:117` bypasses user_id filter for `platform_admin`; `test_platform_admin_cross_tenant` passes |
|
||||||
|
| 7 | Conversation history persists in DB and is loadable via REST | VERIFIED | `list_messages` at `chat.py:234-299` queries `WebConversationMessage`; `test_list_conversation_history` passes |
|
||||||
|
| 8 | User can navigate to /chat from the sidebar and see a conversation list | VERIFIED | `nav.tsx` line 57-62 adds `{ href: "/chat", label: "Chat", icon: MessageSquare }` with no `allowedRoles` restriction; `chat/page.tsx` renders `ChatSidebar` |
|
||||||
|
| 9 | User can select an agent and start a new conversation | VERIFIED | `AgentPickerDialog` in `chat/page.tsx:50-105` lists agents via `useAgents`; `handleAgentSelect` calls `useCreateConversation` and sets active conversation |
|
||||||
|
| 10 | User messages appear right-aligned; agent responses left-aligned with markdown | VERIFIED | `chat-message.tsx:36-76` renders user messages right-aligned (`justify-end`), assistant left-aligned with `ReactMarkdown + remarkGfm` |
|
||||||
|
| 11 | Typing indicator (animated dots) shows while waiting for agent response | VERIFIED | `TypingIndicator` component in `typing-indicator.tsx` with three `animate-bounce` dots and staggered delays; `chat-window.tsx:180` renders `{isTyping && <TypingIndicator />}` |
|
||||||
|
| 12 | Conversation history loads when user returns to a previous conversation | VERIFIED | `useConversationHistory(conversationId)` called in `chat-window.tsx:60`; history populates messages state via `useEffect` at line 62-73 |
|
||||||
|
| 13 | End-to-end chat works with full agent pipeline (memory, tools, escalation) | HUMAN NEEDED | All plumbing is wired; actual pipeline execution requires live services |
|
||||||
|
|
||||||
|
**Score:** 13/13 automated truths verified (1 requires human confirmation)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Required Artifacts
|
||||||
|
|
||||||
|
| Artifact | Expected | Status | Details |
|
||||||
|
|----------|----------|--------|---------|
|
||||||
|
| `packages/shared/shared/models/chat.py` | WebConversation and WebConversationMessage ORM models | VERIFIED | Both classes present, SQLAlchemy 2.0 `Mapped[]`/`mapped_column()` style, UniqueConstraint on (tenant_id, agent_id, user_id) |
|
||||||
|
| `packages/gateway/gateway/channels/web.py` | WebSocket endpoint and web channel normalizer | VERIFIED | `normalize_web_event()` at line 64; `chat_websocket` at line 320; 341 lines total |
|
||||||
|
| `packages/shared/shared/api/chat.py` | REST API for conversation CRUD | VERIFIED | `chat_router` defined at line 42; all 4 endpoints present (list, create, messages, delete) |
|
||||||
|
| `migrations/versions/008_web_chat.py` | DB migration for web_conversations and web_conversation_messages tables | VERIFIED | Both tables created with FORCE RLS, RLS policies, index on (conversation_id, created_at), CHECK constraint on channel_type updated |
|
||||||
|
| `tests/unit/test_web_channel.py` | Unit tests for web channel adapter | VERIFIED | 13 tests; all pass |
|
||||||
|
| `tests/unit/test_chat_api.py` | Unit tests for chat REST API with RBAC | VERIFIED | 6 tests; all pass |
|
||||||
|
| `packages/portal/app/(dashboard)/chat/page.tsx` | Main chat page with sidebar + active conversation | VERIFIED | 235 lines; `ChatSidebar` + `ChatWindow` rendered; `useConversations` and `useCreateConversation` wired |
|
||||||
|
| `packages/portal/components/chat-sidebar.tsx` | Conversation list with agent names and timestamps | VERIFIED | `ChatSidebar` exported; scrollable list, "New Conversation" button, empty state |
|
||||||
|
| `packages/portal/components/chat-window.tsx` | Active conversation with message list, input, and send button | VERIFIED | `ChatWindow` exported; `useChatSocket` and `useConversationHistory` wired; `TypingIndicator` rendered conditionally |
|
||||||
|
| `packages/portal/components/chat-message.tsx` | Message bubble with markdown rendering and role-based alignment | VERIFIED | `ChatMessage` exported; user=right+plain text; assistant=left+ReactMarkdown+remarkGfm |
|
||||||
|
| `packages/portal/components/typing-indicator.tsx` | Animated typing dots component | VERIFIED | `TypingIndicator` exported; 3 dots with `animate-bounce` and staggered `animationDelay` |
|
||||||
|
| `packages/portal/lib/use-chat-socket.ts` | React hook managing WebSocket lifecycle | VERIFIED | `useChatSocket` exported; connects to `/chat/ws/{conversationId}`; sends auth JSON on open; handles typing/response events; reconnects up to 3 times |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Key Link Verification
|
||||||
|
|
||||||
|
| From | To | Via | Status | Details |
|
||||||
|
|------|----|-----|--------|---------|
|
||||||
|
| `packages/portal/lib/use-chat-socket.ts` | `packages/gateway/gateway/channels/web.py` | `new WebSocket` to `/chat/ws/{conversationId}` | VERIFIED | `use-chat-socket.ts:59`: `new WebSocket(url)` where `url = \`${WS_BASE}/chat/ws/${conversationId}\`` |
|
||||||
|
| `packages/portal/app/(dashboard)/chat/page.tsx` | `packages/portal/lib/queries.ts` | `useConversations` + `useConversationHistory` hooks | VERIFIED | `chat/page.tsx:143` calls `useConversations(tenantId)`; `chat-window.tsx:60` calls `useConversationHistory(conversationId)` |
|
||||||
|
| `packages/portal/components/nav.tsx` | `packages/portal/app/(dashboard)/chat/page.tsx` | Nav link to `/chat` | VERIFIED | `nav.tsx:57-62`: `{ href: "/chat", label: "Chat", icon: MessageSquare }` with no role restriction |
|
||||||
|
| `packages/gateway/gateway/channels/web.py` | `packages/orchestrator/orchestrator/tasks.py` | `handle_message.delay()` Celery dispatch | VERIFIED | `web.py:245`: `handle_message.delay(task_payload)` |
|
||||||
|
| `packages/orchestrator/orchestrator/tasks.py` | `packages/shared/shared/redis_keys.py` | Redis pub-sub publish via `webchat_response_key` | VERIFIED | `tasks.py:80`: `from shared.redis_keys import escalation_status_key, webchat_response_key`; used at line 805 |
|
||||||
|
| `packages/gateway/gateway/channels/web.py` | `packages/shared/shared/redis_keys.py` | Redis pub-sub subscribe via `webchat_response_key` | VERIFIED | `web.py:50`: `from shared.redis_keys import webchat_response_key`; used at line 250 |
|
||||||
|
| `packages/shared/shared/api/chat.py` | `packages/shared/shared/api/rbac.py` | `require_tenant_member` RBAC guard | VERIFIED | `chat.py:36`: imports `require_tenant_member`; called at lines 107 and 163 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Requirements Coverage
|
||||||
|
|
||||||
|
| Requirement | Source Plan(s) | Description | Status | Evidence |
|
||||||
|
|-------------|---------------|-------------|--------|----------|
|
||||||
|
| CHAT-01 | 06-01, 06-02, 06-03 | Users can open a chat window with any AI Employee and have a real-time conversation within the portal | SATISFIED | WebSocket endpoint + `useChatSocket` + `ChatWindow` + full message loop |
|
||||||
|
| CHAT-02 | 06-01, 06-02, 06-03 | Web chat supports the full agent pipeline (memory, tools, escalation, media) | SATISFIED (automated) + HUMAN NEEDED | `handle_message.delay()` dispatches into the same pipeline as Slack/WhatsApp; `ChannelType.WEB` flows through orchestrator; end-to-end pipeline needs human verification with live services |
|
||||||
|
| CHAT-03 | 06-01, 06-02, 06-03 | Conversation history persists and is visible when the user returns to the chat | SATISFIED | `web_conversation_messages` table persists messages; `GET /conversations/{id}/messages` REST endpoint; `useConversationHistory` hook loads on `ChatWindow` mount |
|
||||||
|
| CHAT-04 | 06-01, 06-02, 06-03 | Chat respects RBAC — users can only chat with agents belonging to tenants they have access to | SATISFIED | `require_tenant_member` guards all REST endpoints; WebSocket auth validates `userId`/`tenantId`; `test_chat_rbac_enforcement` and `test_platform_admin_cross_tenant` pass |
|
||||||
|
| CHAT-05 | 06-01, 06-02, 06-03 | Chat interface feels responsive — typing indicators, message streaming or fast response display | SATISFIED (automated) + HUMAN NEEDED | `{"type": "typing"}` sent before Celery dispatch; `TypingIndicator` component animates; `test_typing_indicator_sent_before_dispatch` passes; visual quality requires human review |
|
||||||
|
|
||||||
|
All 5 CHAT requirements are claimed by all three plans. No orphaned requirements.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Anti-Patterns Found
|
||||||
|
|
||||||
|
| File | Pattern | Severity | Impact |
|
||||||
|
|------|---------|----------|--------|
|
||||||
|
| `packages/portal/components/chat-window.tsx:39` | `<div className="text-4xl mb-3">💬</div>` — emoji in source code | Info | Visual, not a blocker; per CLAUDE.md "avoid emojis" but this is a UI element not user-facing text |
|
||||||
|
|
||||||
|
No stubbed implementations, placeholder returns, or TODOs found in any phase 6 files. All API routes perform real DB queries and return non-static data.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Human Verification Required
|
||||||
|
|
||||||
|
#### 1. End-to-End Chat (CHAT-01, CHAT-05)
|
||||||
|
|
||||||
|
**Test:** Log in as `customer_admin`, click "Chat" in the sidebar navigation, click "New Conversation", select an AI Employee, type a message, press Enter.
|
||||||
|
**Expected:** Animated typing dots appear immediately; the agent response arrives as a left-aligned bubble with the agent avatar; the user's message appears right-aligned.
|
||||||
|
**Why human:** Requires live gateway, Celery worker, Redis, and an LLM backend. The WebSocket round-trip cannot be verified programmatically.
|
||||||
|
|
||||||
|
#### 2. Markdown Rendering (CHAT-05)
|
||||||
|
|
||||||
|
**Test:** Send a message that requests a formatted response (e.g., "Give me a bulleted list of 3 tips").
|
||||||
|
**Expected:** The agent response renders proper markdown — bullet lists, bold text, and code blocks display correctly rather than as raw markdown syntax.
|
||||||
|
**Why human:** Markdown rendering quality and visual appearance require a browser.
|
||||||
|
|
||||||
|
#### 3. Conversation History Persistence (CHAT-03)
|
||||||
|
|
||||||
|
**Test:** Exchange several messages, navigate away from /chat (e.g., go to /dashboard), then navigate back.
|
||||||
|
**Expected:** The previous conversation appears in the sidebar with a last message preview; clicking it loads the full message history.
|
||||||
|
**Why human:** Cross-page navigation persistence requires a live DB session.
|
||||||
|
|
||||||
|
#### 4. RBAC Enforcement for Operators (CHAT-04)
|
||||||
|
|
||||||
|
**Test:** Log in as `customer_operator`, navigate to /chat, start a conversation with an agent.
|
||||||
|
**Expected:** The "Chat" link is visible in the sidebar; chat works for operators; admin-only nav items (Billing, API Keys, Users) remain hidden.
|
||||||
|
**Why human:** Role-based nav suppression and operator chat access require a live session with correct role claims from the auth system.
|
||||||
|
|
||||||
|
#### 5. Full Pipeline with Tools (CHAT-02)
|
||||||
|
|
||||||
|
**Test:** If an agent has tools configured, send a message that triggers tool use.
|
||||||
|
**Expected:** The agent invokes the tool and incorporates the result into its response (rather than hallucinating).
|
||||||
|
**Why human:** Requires a configured agent with registered tools and a live Celery worker.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Gaps Summary
|
||||||
|
|
||||||
|
No automated gaps. All 13 must-have truths are verified at the code level:
|
||||||
|
|
||||||
|
- All backend infrastructure exists and is substantive (not stubs): WebSocket endpoint, REST API, ORM models, migration, orchestrator routing.
|
||||||
|
- All frontend components exist and are substantive: page, sidebar, window, message bubble, typing indicator, WebSocket hook.
|
||||||
|
- All 7 key links are wired: Celery dispatch, Redis pub-sub subscribe/publish, RBAC guard, WebSocket URL, query hooks, nav link.
|
||||||
|
- All 19 unit tests pass (run with `uv run pytest tests/unit/test_web_channel.py tests/unit/test_chat_api.py`).
|
||||||
|
- Portal builds successfully with `/chat` route.
|
||||||
|
- 5 human verification items remain for visual quality, live pipeline behavior, and session-dependent RBAC checks.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
_Verified: 2026-03-25T16:39:57Z_
|
||||||
|
_Verifier: Claude (gsd-verifier)_
|
||||||
172
migrations/versions/008_web_chat.py
Normal file
172
migrations/versions/008_web_chat.py
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
"""Web chat: web_conversations and web_conversation_messages tables with RLS
|
||||||
|
|
||||||
|
Revision ID: 008
|
||||||
|
Revises: 007
|
||||||
|
Create Date: 2026-03-25
|
||||||
|
|
||||||
|
This migration:
|
||||||
|
1. Creates the web_conversations table (tenant-scoped, RLS-enabled)
|
||||||
|
2. Creates the web_conversation_messages table (CASCADE delete, RLS-enabled)
|
||||||
|
3. Enables FORCE ROW LEVEL SECURITY on both tables
|
||||||
|
4. Creates tenant_isolation RLS policies matching existing pattern
|
||||||
|
5. Adds index on web_conversation_messages(conversation_id, created_at) for pagination
|
||||||
|
6. Replaces the channel_type CHECK constraint on channel_connections to include 'web'
|
||||||
|
|
||||||
|
NOTE on CHECK constraint replacement (Pitfall 5):
|
||||||
|
The existing constraint chk_channel_type only covers the original 7 channels.
|
||||||
|
ALTER TABLE DROP CONSTRAINT + ADD CONSTRAINT is used instead of just adding a
|
||||||
|
new constraint — the old constraint remains active otherwise and would reject 'web'.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
from sqlalchemy.dialects.postgresql import UUID
|
||||||
|
|
||||||
|
# Alembic migration metadata
|
||||||
|
revision: str = "008"
|
||||||
|
down_revision: Union[str, None] = "007"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
# All valid channel types including new 'web' — must match ChannelType StrEnum in message.py
|
||||||
|
_CHANNEL_TYPES = (
|
||||||
|
"slack", "whatsapp", "mattermost", "rocketchat", "teams", "telegram", "signal", "web"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# -------------------------------------------------------------------------
|
||||||
|
# 1. Create web_conversations table
|
||||||
|
# -------------------------------------------------------------------------
|
||||||
|
op.create_table(
|
||||||
|
"web_conversations",
|
||||||
|
sa.Column("id", UUID(as_uuid=True), primary_key=True, server_default=sa.text("gen_random_uuid()")),
|
||||||
|
sa.Column(
|
||||||
|
"tenant_id",
|
||||||
|
UUID(as_uuid=True),
|
||||||
|
sa.ForeignKey("tenants.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"agent_id",
|
||||||
|
UUID(as_uuid=True),
|
||||||
|
sa.ForeignKey("agents.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column("user_id", UUID(as_uuid=True), nullable=False),
|
||||||
|
sa.Column(
|
||||||
|
"created_at",
|
||||||
|
sa.DateTime(timezone=True),
|
||||||
|
nullable=False,
|
||||||
|
server_default=sa.text("NOW()"),
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"updated_at",
|
||||||
|
sa.DateTime(timezone=True),
|
||||||
|
nullable=False,
|
||||||
|
server_default=sa.text("NOW()"),
|
||||||
|
),
|
||||||
|
sa.UniqueConstraint(
|
||||||
|
"tenant_id",
|
||||||
|
"agent_id",
|
||||||
|
"user_id",
|
||||||
|
name="uq_web_conversations_tenant_agent_user",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
op.create_index("ix_web_conversations_tenant_id", "web_conversations", ["tenant_id"])
|
||||||
|
|
||||||
|
# Enable RLS on web_conversations
|
||||||
|
op.execute("ALTER TABLE web_conversations ENABLE ROW LEVEL SECURITY")
|
||||||
|
op.execute("ALTER TABLE web_conversations FORCE ROW LEVEL SECURITY")
|
||||||
|
op.execute("""
|
||||||
|
CREATE POLICY tenant_isolation ON web_conversations
|
||||||
|
USING (tenant_id = current_setting('app.current_tenant', TRUE)::uuid)
|
||||||
|
""")
|
||||||
|
|
||||||
|
# -------------------------------------------------------------------------
|
||||||
|
# 2. Create web_conversation_messages table
|
||||||
|
# -------------------------------------------------------------------------
|
||||||
|
op.create_table(
|
||||||
|
"web_conversation_messages",
|
||||||
|
sa.Column("id", UUID(as_uuid=True), primary_key=True, server_default=sa.text("gen_random_uuid()")),
|
||||||
|
sa.Column(
|
||||||
|
"conversation_id",
|
||||||
|
UUID(as_uuid=True),
|
||||||
|
sa.ForeignKey("web_conversations.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column("tenant_id", UUID(as_uuid=True), nullable=False),
|
||||||
|
sa.Column("role", sa.Text, nullable=False),
|
||||||
|
sa.Column("content", sa.Text, nullable=False),
|
||||||
|
sa.Column(
|
||||||
|
"created_at",
|
||||||
|
sa.DateTime(timezone=True),
|
||||||
|
nullable=False,
|
||||||
|
server_default=sa.text("NOW()"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
# CHECK constraint on role — TEXT+CHECK per Phase 1 convention (not sa.Enum)
|
||||||
|
op.execute(
|
||||||
|
"ALTER TABLE web_conversation_messages ADD CONSTRAINT chk_message_role "
|
||||||
|
"CHECK (role IN ('user', 'assistant'))"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Index for paginated message history queries: ORDER BY created_at with conversation filter
|
||||||
|
op.create_index(
|
||||||
|
"ix_web_conversation_messages_conv_created",
|
||||||
|
"web_conversation_messages",
|
||||||
|
["conversation_id", "created_at"],
|
||||||
|
)
|
||||||
|
|
||||||
|
# Enable RLS on web_conversation_messages
|
||||||
|
op.execute("ALTER TABLE web_conversation_messages ENABLE ROW LEVEL SECURITY")
|
||||||
|
op.execute("ALTER TABLE web_conversation_messages FORCE ROW LEVEL SECURITY")
|
||||||
|
op.execute("""
|
||||||
|
CREATE POLICY tenant_isolation ON web_conversation_messages
|
||||||
|
USING (tenant_id = current_setting('app.current_tenant', TRUE)::uuid)
|
||||||
|
""")
|
||||||
|
|
||||||
|
# -------------------------------------------------------------------------
|
||||||
|
# 3. Grant permissions to konstruct_app
|
||||||
|
# -------------------------------------------------------------------------
|
||||||
|
op.execute("GRANT SELECT, INSERT, UPDATE, DELETE ON web_conversations TO konstruct_app")
|
||||||
|
op.execute("GRANT SELECT, INSERT, UPDATE, DELETE ON web_conversation_messages TO konstruct_app")
|
||||||
|
|
||||||
|
# -------------------------------------------------------------------------
|
||||||
|
# 4. Update channel_connections CHECK constraint to include 'web'
|
||||||
|
#
|
||||||
|
# DROP + re-ADD because an existing CHECK constraint still enforces the old
|
||||||
|
# set of values — simply adding a second constraint would AND them together.
|
||||||
|
# -------------------------------------------------------------------------
|
||||||
|
op.execute("ALTER TABLE channel_connections DROP CONSTRAINT IF EXISTS chk_channel_type")
|
||||||
|
op.execute(
|
||||||
|
"ALTER TABLE channel_connections ADD CONSTRAINT chk_channel_type "
|
||||||
|
f"CHECK (channel_type IN {tuple(_CHANNEL_TYPES)})"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# Restore original channel_type CHECK constraint (without 'web')
|
||||||
|
_ORIGINAL_CHANNEL_TYPES = (
|
||||||
|
"slack", "whatsapp", "mattermost", "rocketchat", "teams", "telegram", "signal"
|
||||||
|
)
|
||||||
|
op.execute("ALTER TABLE channel_connections DROP CONSTRAINT IF EXISTS chk_channel_type")
|
||||||
|
op.execute(
|
||||||
|
"ALTER TABLE channel_connections ADD CONSTRAINT chk_channel_type "
|
||||||
|
f"CHECK (channel_type IN {tuple(_ORIGINAL_CHANNEL_TYPES)})"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Drop web_conversation_messages first (FK dependency)
|
||||||
|
op.execute("REVOKE ALL ON web_conversation_messages FROM konstruct_app")
|
||||||
|
op.drop_index("ix_web_conversation_messages_conv_created")
|
||||||
|
op.drop_table("web_conversation_messages")
|
||||||
|
|
||||||
|
# Drop web_conversations
|
||||||
|
op.execute("REVOKE ALL ON web_conversations FROM konstruct_app")
|
||||||
|
op.drop_index("ix_web_conversations_tenant_id")
|
||||||
|
op.drop_table("web_conversations")
|
||||||
340
packages/gateway/gateway/channels/web.py
Normal file
340
packages/gateway/gateway/channels/web.py
Normal file
@@ -0,0 +1,340 @@
|
|||||||
|
"""
|
||||||
|
Web Channel Adapter — WebSocket endpoint and message normalizer.
|
||||||
|
|
||||||
|
The web channel lets portal users chat with AI employees directly from
|
||||||
|
the Konstruct portal UI. Messages flow through the same agent pipeline
|
||||||
|
as Slack and WhatsApp — the only difference is the transport layer.
|
||||||
|
|
||||||
|
Message flow:
|
||||||
|
1. Browser opens WebSocket at /chat/ws/{conversation_id}
|
||||||
|
2. Client sends {"type": "auth", "userId": ..., "role": ..., "tenantId": ...}
|
||||||
|
NOTE: Browsers cannot set custom HTTP headers on WebSocket connections,
|
||||||
|
so auth credentials are sent as the first JSON message (Pitfall 1).
|
||||||
|
3. For each user message (type="message"):
|
||||||
|
a. Server immediately sends {"type": "typing"} to client (CHAT-05)
|
||||||
|
b. normalize_web_event() converts to KonstructMessage (channel=WEB)
|
||||||
|
c. User message saved to web_conversation_messages
|
||||||
|
d. handle_message.delay(msg | extras) dispatches to Celery pipeline
|
||||||
|
e. Server subscribes to Redis pub-sub channel for the response
|
||||||
|
f. When orchestrator publishes the response:
|
||||||
|
- Save assistant message to web_conversation_messages
|
||||||
|
- Send {"type": "response", "text": ..., "conversation_id": ...} to client
|
||||||
|
4. On disconnect: unsubscribe and close all Redis connections
|
||||||
|
|
||||||
|
Design notes:
|
||||||
|
- thread_id = conversation_id — scopes agent memory to one conversation (Pitfall 3)
|
||||||
|
- Redis pub-sub connections closed in try/finally to prevent leaks (Pitfall 2)
|
||||||
|
- DB access uses configure_rls_hook + current_tenant_id context var per project pattern
|
||||||
|
- WebSocket is a long-lived connection; each message/response cycle is synchronous
|
||||||
|
within the connection but non-blocking for other connections
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import redis.asyncio as aioredis
|
||||||
|
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
|
||||||
|
from sqlalchemy import select, text
|
||||||
|
|
||||||
|
from orchestrator.tasks import handle_message
|
||||||
|
from shared.config import settings
|
||||||
|
from shared.db import async_session_factory, engine
|
||||||
|
from shared.models.chat import WebConversation, WebConversationMessage
|
||||||
|
from shared.models.message import ChannelType, KonstructMessage, MessageContent, SenderInfo
|
||||||
|
from shared.redis_keys import webchat_response_key
|
||||||
|
from shared.rls import configure_rls_hook, current_tenant_id
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Router — mounted in gateway/main.py
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
web_chat_router = APIRouter(tags=["web-chat"])
|
||||||
|
|
||||||
|
# Timeout for waiting for an agent response via Redis pub-sub (seconds)
|
||||||
|
_RESPONSE_TIMEOUT_SECONDS = 60
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_web_event(event: dict[str, Any]) -> KonstructMessage:
|
||||||
|
"""
|
||||||
|
Normalize a web channel event dict into a KonstructMessage.
|
||||||
|
|
||||||
|
The web channel normalizer sets thread_id = conversation_id so that
|
||||||
|
the agent memory pipeline scopes context to this conversation (Pitfall 3).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
event: Dict with keys: text, tenant_id, agent_id, user_id,
|
||||||
|
display_name, conversation_id.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
KonstructMessage with channel=WEB, thread_id=conversation_id.
|
||||||
|
"""
|
||||||
|
tenant_id: str = event.get("tenant_id", "") or ""
|
||||||
|
user_id: str = event.get("user_id", "") or ""
|
||||||
|
display_name: str = event.get("display_name", "Portal User")
|
||||||
|
conversation_id: str = event.get("conversation_id", "") or ""
|
||||||
|
text_content: str = event.get("text", "") or ""
|
||||||
|
|
||||||
|
return KonstructMessage(
|
||||||
|
id=str(uuid.uuid4()),
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
channel=ChannelType.WEB,
|
||||||
|
channel_metadata={
|
||||||
|
"portal_user_id": user_id,
|
||||||
|
"tenant_id": tenant_id,
|
||||||
|
"conversation_id": conversation_id,
|
||||||
|
},
|
||||||
|
sender=SenderInfo(
|
||||||
|
user_id=user_id,
|
||||||
|
display_name=display_name,
|
||||||
|
),
|
||||||
|
content=MessageContent(
|
||||||
|
text=text_content,
|
||||||
|
),
|
||||||
|
timestamp=datetime.now(timezone.utc),
|
||||||
|
thread_id=conversation_id,
|
||||||
|
reply_to=None,
|
||||||
|
context={},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _handle_websocket_connection(
|
||||||
|
websocket: WebSocket,
|
||||||
|
conversation_id: str,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Core WebSocket connection handler — separated for testability.
|
||||||
|
|
||||||
|
Lifecycle:
|
||||||
|
1. Accept connection
|
||||||
|
2. Wait for auth message (browser cannot send custom headers)
|
||||||
|
3. Loop: receive messages → type indicator → Celery dispatch → Redis subscribe → response
|
||||||
|
|
||||||
|
Args:
|
||||||
|
websocket: The FastAPI WebSocket connection.
|
||||||
|
conversation_id: The conversation UUID from the URL path.
|
||||||
|
"""
|
||||||
|
await websocket.accept()
|
||||||
|
|
||||||
|
# -------------------------------------------------------------------------
|
||||||
|
# Step 1: Auth handshake
|
||||||
|
# Browsers cannot send custom HTTP headers on WebSocket connections.
|
||||||
|
# Auth credentials are sent as the first JSON message.
|
||||||
|
# -------------------------------------------------------------------------
|
||||||
|
try:
|
||||||
|
auth_msg = await websocket.receive_json()
|
||||||
|
except WebSocketDisconnect:
|
||||||
|
return
|
||||||
|
|
||||||
|
if auth_msg.get("type") != "auth":
|
||||||
|
await websocket.send_json({"type": "error", "message": "First message must be auth"})
|
||||||
|
await websocket.close(code=4001)
|
||||||
|
return
|
||||||
|
|
||||||
|
user_id_str: str = auth_msg.get("userId", "") or ""
|
||||||
|
user_role: str = auth_msg.get("role", "") or ""
|
||||||
|
tenant_id_str: str = auth_msg.get("tenantId", "") or ""
|
||||||
|
|
||||||
|
if not user_id_str or not tenant_id_str:
|
||||||
|
await websocket.send_json({"type": "error", "message": "Missing userId or tenantId in auth"})
|
||||||
|
await websocket.close(code=4001)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Validate UUID format
|
||||||
|
try:
|
||||||
|
uuid.UUID(user_id_str)
|
||||||
|
tenant_uuid = uuid.UUID(tenant_id_str)
|
||||||
|
except (ValueError, AttributeError):
|
||||||
|
await websocket.send_json({"type": "error", "message": "Invalid UUID format in auth"})
|
||||||
|
await websocket.close(code=4001)
|
||||||
|
return
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"WebSocket auth: user=%s role=%s tenant=%s conversation=%s",
|
||||||
|
user_id_str, user_role, tenant_id_str, conversation_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
# -------------------------------------------------------------------------
|
||||||
|
# Step 2: Message loop
|
||||||
|
# -------------------------------------------------------------------------
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
msg_data = await websocket.receive_json()
|
||||||
|
except (WebSocketDisconnect, Exception):
|
||||||
|
break
|
||||||
|
|
||||||
|
if msg_data.get("type") != "message":
|
||||||
|
continue
|
||||||
|
|
||||||
|
text_content: str = msg_data.get("text", "") or ""
|
||||||
|
agent_id_str: str = msg_data.get("agentId", "") or ""
|
||||||
|
msg_conversation_id: str = msg_data.get("conversationId", conversation_id) or conversation_id
|
||||||
|
display_name: str = msg_data.get("displayName", "Portal User")
|
||||||
|
|
||||||
|
# -------------------------------------------------------------------
|
||||||
|
# a. Send typing indicator IMMEDIATELY — before any DB or Celery work
|
||||||
|
# -------------------------------------------------------------------
|
||||||
|
await websocket.send_json({"type": "typing"})
|
||||||
|
|
||||||
|
# -------------------------------------------------------------------
|
||||||
|
# b. Save user message to web_conversation_messages
|
||||||
|
# -------------------------------------------------------------------
|
||||||
|
configure_rls_hook(engine)
|
||||||
|
rls_token = current_tenant_id.set(tenant_uuid)
|
||||||
|
saved_conversation_id = msg_conversation_id
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with async_session_factory() as session:
|
||||||
|
# Look up the conversation to get tenant-scoped context
|
||||||
|
conv_stmt = select(WebConversation).where(
|
||||||
|
WebConversation.id == uuid.UUID(msg_conversation_id)
|
||||||
|
)
|
||||||
|
conv_result = await session.execute(conv_stmt)
|
||||||
|
conversation = conv_result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if conversation is not None:
|
||||||
|
# Save user message
|
||||||
|
user_msg = WebConversationMessage(
|
||||||
|
conversation_id=uuid.UUID(msg_conversation_id),
|
||||||
|
tenant_id=tenant_uuid,
|
||||||
|
role="user",
|
||||||
|
content=text_content,
|
||||||
|
)
|
||||||
|
session.add(user_msg)
|
||||||
|
|
||||||
|
# Update conversation timestamp
|
||||||
|
await session.execute(
|
||||||
|
text(
|
||||||
|
"UPDATE web_conversations SET updated_at = NOW() WHERE id = :conv_id"
|
||||||
|
),
|
||||||
|
{"conv_id": str(msg_conversation_id)},
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
saved_conversation_id = msg_conversation_id
|
||||||
|
except Exception:
|
||||||
|
logger.exception(
|
||||||
|
"Failed to save user message for conversation=%s", msg_conversation_id
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
current_tenant_id.reset(rls_token)
|
||||||
|
|
||||||
|
# -------------------------------------------------------------------
|
||||||
|
# c. Normalize and dispatch to Celery pipeline
|
||||||
|
# -------------------------------------------------------------------
|
||||||
|
event = {
|
||||||
|
"text": text_content,
|
||||||
|
"tenant_id": tenant_id_str,
|
||||||
|
"agent_id": agent_id_str,
|
||||||
|
"user_id": user_id_str,
|
||||||
|
"display_name": display_name,
|
||||||
|
"conversation_id": saved_conversation_id,
|
||||||
|
}
|
||||||
|
normalized_msg = normalize_web_event(event)
|
||||||
|
|
||||||
|
extras = {
|
||||||
|
"conversation_id": saved_conversation_id,
|
||||||
|
"portal_user_id": user_id_str,
|
||||||
|
}
|
||||||
|
task_payload = normalized_msg.model_dump(mode="json") | extras
|
||||||
|
handle_message.delay(task_payload)
|
||||||
|
|
||||||
|
# -------------------------------------------------------------------
|
||||||
|
# d. Subscribe to Redis pub-sub and wait for agent response
|
||||||
|
# -------------------------------------------------------------------
|
||||||
|
response_channel = webchat_response_key(tenant_id_str, saved_conversation_id)
|
||||||
|
subscribe_redis = aioredis.from_url(settings.redis_url)
|
||||||
|
try:
|
||||||
|
pubsub = subscribe_redis.pubsub()
|
||||||
|
await pubsub.subscribe(response_channel)
|
||||||
|
|
||||||
|
response_text: str = ""
|
||||||
|
deadline = asyncio.get_event_loop().time() + _RESPONSE_TIMEOUT_SECONDS
|
||||||
|
|
||||||
|
while asyncio.get_event_loop().time() < deadline:
|
||||||
|
message = await pubsub.get_message(ignore_subscribe_messages=True, timeout=1.0)
|
||||||
|
if message and message.get("type") == "message":
|
||||||
|
try:
|
||||||
|
payload = json.loads(message["data"])
|
||||||
|
response_text = payload.get("text", "")
|
||||||
|
except (json.JSONDecodeError, KeyError):
|
||||||
|
pass
|
||||||
|
break
|
||||||
|
await asyncio.sleep(0.05)
|
||||||
|
|
||||||
|
await pubsub.unsubscribe(response_channel)
|
||||||
|
finally:
|
||||||
|
await subscribe_redis.aclose()
|
||||||
|
|
||||||
|
# -------------------------------------------------------------------
|
||||||
|
# e. Save assistant message and send response to client
|
||||||
|
# -------------------------------------------------------------------
|
||||||
|
if response_text:
|
||||||
|
rls_token2 = current_tenant_id.set(tenant_uuid)
|
||||||
|
try:
|
||||||
|
async with async_session_factory() as session:
|
||||||
|
assistant_msg = WebConversationMessage(
|
||||||
|
conversation_id=uuid.UUID(saved_conversation_id),
|
||||||
|
tenant_id=tenant_uuid,
|
||||||
|
role="assistant",
|
||||||
|
content=response_text,
|
||||||
|
)
|
||||||
|
session.add(assistant_msg)
|
||||||
|
await session.execute(
|
||||||
|
text(
|
||||||
|
"UPDATE web_conversations SET updated_at = NOW() WHERE id = :conv_id"
|
||||||
|
),
|
||||||
|
{"conv_id": str(saved_conversation_id)},
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
except Exception:
|
||||||
|
logger.exception(
|
||||||
|
"Failed to save assistant message for conversation=%s", saved_conversation_id
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
current_tenant_id.reset(rls_token2)
|
||||||
|
|
||||||
|
await websocket.send_json({
|
||||||
|
"type": "response",
|
||||||
|
"text": response_text,
|
||||||
|
"conversation_id": saved_conversation_id,
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
logger.warning(
|
||||||
|
"No response received within %ds for conversation=%s",
|
||||||
|
_RESPONSE_TIMEOUT_SECONDS,
|
||||||
|
saved_conversation_id,
|
||||||
|
)
|
||||||
|
await websocket.send_json({
|
||||||
|
"type": "error",
|
||||||
|
"message": "Agent did not respond in time. Please try again.",
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@web_chat_router.websocket("/chat/ws/{conversation_id}")
|
||||||
|
async def chat_websocket(websocket: WebSocket, conversation_id: str) -> None:
|
||||||
|
"""
|
||||||
|
WebSocket endpoint for web chat.
|
||||||
|
|
||||||
|
URL: /chat/ws/{conversation_id}
|
||||||
|
|
||||||
|
Protocol:
|
||||||
|
1. Connect
|
||||||
|
2. Send: {"type": "auth", "userId": "...", "role": "...", "tenantId": "..."}
|
||||||
|
3. Send: {"type": "message", "text": "...", "agentId": "...", "conversationId": "..."}
|
||||||
|
4. Receive: {"type": "typing"}
|
||||||
|
5. Receive: {"type": "response", "text": "...", "conversation_id": "..."}
|
||||||
|
|
||||||
|
Closes with code 4001 on auth failure.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
await _handle_websocket_connection(websocket, conversation_id)
|
||||||
|
except WebSocketDisconnect:
|
||||||
|
logger.info("WebSocket disconnected for conversation=%s", conversation_id)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Unhandled error in WebSocket handler for conversation=%s", conversation_id)
|
||||||
@@ -39,10 +39,12 @@ from slack_bolt.adapter.fastapi.async_handler import AsyncSlackRequestHandler
|
|||||||
from slack_bolt.async_app import AsyncApp
|
from slack_bolt.async_app import AsyncApp
|
||||||
|
|
||||||
from gateway.channels.slack import register_slack_handlers
|
from gateway.channels.slack import register_slack_handlers
|
||||||
|
from gateway.channels.web import web_chat_router
|
||||||
from gateway.channels.whatsapp import whatsapp_router
|
from gateway.channels.whatsapp import whatsapp_router
|
||||||
from shared.api import (
|
from shared.api import (
|
||||||
billing_router,
|
billing_router,
|
||||||
channels_router,
|
channels_router,
|
||||||
|
chat_router,
|
||||||
invitations_router,
|
invitations_router,
|
||||||
llm_keys_router,
|
llm_keys_router,
|
||||||
portal_router,
|
portal_router,
|
||||||
@@ -146,6 +148,12 @@ app.include_router(invitations_router)
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
app.include_router(templates_router)
|
app.include_router(templates_router)
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Phase 6 Web Chat routers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
app.include_router(chat_router) # REST: /api/portal/chat/*
|
||||||
|
app.include_router(web_chat_router) # WebSocket: /chat/ws/{conversation_id}
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Routes
|
# Routes
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ from orchestrator.tools.registry import get_tools_for_agent
|
|||||||
from shared.config import settings
|
from shared.config import settings
|
||||||
from shared.db import async_session_factory, engine
|
from shared.db import async_session_factory, engine
|
||||||
from shared.models.message import KonstructMessage
|
from shared.models.message import KonstructMessage
|
||||||
from shared.redis_keys import escalation_status_key
|
from shared.redis_keys import escalation_status_key, webchat_response_key
|
||||||
from shared.rls import configure_rls_hook, current_tenant_id
|
from shared.rls import configure_rls_hook, current_tenant_id
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -253,6 +253,11 @@ def handle_message(self, message_data: dict) -> dict: # type: ignore[no-untyped
|
|||||||
phone_number_id: str = message_data.pop("phone_number_id", "") or ""
|
phone_number_id: str = message_data.pop("phone_number_id", "") or ""
|
||||||
bot_token: str = message_data.pop("bot_token", "") or ""
|
bot_token: str = message_data.pop("bot_token", "") or ""
|
||||||
|
|
||||||
|
# Extract web channel extras before model validation
|
||||||
|
# The web WebSocket handler injects these alongside the normalized KonstructMessage fields
|
||||||
|
conversation_id: str = message_data.pop("conversation_id", "") or ""
|
||||||
|
portal_user_id: str = message_data.pop("portal_user_id", "") or ""
|
||||||
|
|
||||||
try:
|
try:
|
||||||
msg = KonstructMessage.model_validate(message_data)
|
msg = KonstructMessage.model_validate(message_data)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
@@ -272,6 +277,11 @@ def handle_message(self, message_data: dict) -> dict: # type: ignore[no-untyped
|
|||||||
"phone_number_id": phone_number_id,
|
"phone_number_id": phone_number_id,
|
||||||
"bot_token": bot_token,
|
"bot_token": bot_token,
|
||||||
"wa_id": wa_id,
|
"wa_id": wa_id,
|
||||||
|
# Web channel extras
|
||||||
|
"conversation_id": conversation_id,
|
||||||
|
"portal_user_id": portal_user_id,
|
||||||
|
# tenant_id for web channel response routing (web lacks a workspace_id in channel_connections)
|
||||||
|
"tenant_id": msg.tenant_id or "",
|
||||||
}
|
}
|
||||||
|
|
||||||
result = asyncio.run(_process_message(msg, extras=extras))
|
result = asyncio.run(_process_message(msg, extras=extras))
|
||||||
@@ -646,6 +656,13 @@ def _build_response_extras(
|
|||||||
"bot_token": extras.get("bot_token", "") or "",
|
"bot_token": extras.get("bot_token", "") or "",
|
||||||
"wa_id": extras.get("wa_id", "") or "",
|
"wa_id": extras.get("wa_id", "") or "",
|
||||||
}
|
}
|
||||||
|
elif channel_str == "web":
|
||||||
|
# Web channel: tenant_id comes from extras (set by handle_message from msg.tenant_id),
|
||||||
|
# not from channel_connections like Slack. conversation_id scopes the Redis pub-sub channel.
|
||||||
|
return {
|
||||||
|
"conversation_id": extras.get("conversation_id", "") or "",
|
||||||
|
"tenant_id": extras.get("tenant_id", "") or "",
|
||||||
|
}
|
||||||
else:
|
else:
|
||||||
return dict(extras)
|
return dict(extras)
|
||||||
|
|
||||||
@@ -774,6 +791,31 @@ async def _send_response(
|
|||||||
text=text,
|
text=text,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
elif channel_str == "web":
|
||||||
|
# Publish agent response to Redis pub-sub so the WebSocket handler can deliver it
|
||||||
|
web_conversation_id: str = extras.get("conversation_id", "") or ""
|
||||||
|
web_tenant_id: str = extras.get("tenant_id", "") or ""
|
||||||
|
|
||||||
|
if not web_conversation_id or not web_tenant_id:
|
||||||
|
logger.warning(
|
||||||
|
"_send_response: web channel missing conversation_id or tenant_id in extras"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
response_channel = webchat_response_key(web_tenant_id, web_conversation_id)
|
||||||
|
publish_redis = aioredis.from_url(settings.redis_url)
|
||||||
|
try:
|
||||||
|
await publish_redis.publish(
|
||||||
|
response_channel,
|
||||||
|
json.dumps({
|
||||||
|
"type": "response",
|
||||||
|
"text": text,
|
||||||
|
"conversation_id": web_conversation_id,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
await publish_redis.aclose()
|
||||||
|
|
||||||
else:
|
else:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"_send_response: unsupported channel=%r — response not delivered", channel
|
"_send_response: unsupported channel=%r — response not delivered", channel
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ Import and mount these routers in service main.py files.
|
|||||||
|
|
||||||
from shared.api.billing import billing_router, webhook_router
|
from shared.api.billing import billing_router, webhook_router
|
||||||
from shared.api.channels import channels_router
|
from shared.api.channels import channels_router
|
||||||
|
from shared.api.chat import chat_router
|
||||||
from shared.api.invitations import invitations_router
|
from shared.api.invitations import invitations_router
|
||||||
from shared.api.llm_keys import llm_keys_router
|
from shared.api.llm_keys import llm_keys_router
|
||||||
from shared.api.portal import portal_router
|
from shared.api.portal import portal_router
|
||||||
@@ -21,4 +22,5 @@ __all__ = [
|
|||||||
"usage_router",
|
"usage_router",
|
||||||
"invitations_router",
|
"invitations_router",
|
||||||
"templates_router",
|
"templates_router",
|
||||||
|
"chat_router",
|
||||||
]
|
]
|
||||||
|
|||||||
356
packages/shared/shared/api/chat.py
Normal file
356
packages/shared/shared/api/chat.py
Normal file
@@ -0,0 +1,356 @@
|
|||||||
|
"""
|
||||||
|
FastAPI chat REST API — conversation CRUD with RBAC.
|
||||||
|
|
||||||
|
Provides conversation management for the Phase 6 web chat feature.
|
||||||
|
All endpoints require portal authentication via X-Portal-User-Id headers
|
||||||
|
and enforce tenant membership (or platform_admin bypass).
|
||||||
|
|
||||||
|
Endpoints:
|
||||||
|
GET /api/portal/chat/conversations — list conversations
|
||||||
|
POST /api/portal/chat/conversations — create or get-or-create
|
||||||
|
GET /api/portal/chat/conversations/{id}/messages — paginated history
|
||||||
|
DELETE /api/portal/chat/conversations/{id} — reset conversation
|
||||||
|
|
||||||
|
RBAC:
|
||||||
|
- platform_admin: can access any tenant's conversations
|
||||||
|
- customer_admin / customer_operator: must be a member of the target tenant
|
||||||
|
- Other roles: 403
|
||||||
|
|
||||||
|
RLS:
|
||||||
|
All DB queries set current_tenant_id context var before executing so
|
||||||
|
PostgreSQL's FORCE ROW LEVEL SECURITY policy is applied automatically.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from sqlalchemy import delete, select, text
|
||||||
|
from sqlalchemy.exc import IntegrityError
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from shared.api.rbac import PortalCaller, get_portal_caller, require_tenant_member
|
||||||
|
from shared.db import get_session, engine
|
||||||
|
from shared.models.chat import WebConversation, WebConversationMessage
|
||||||
|
from shared.models.tenant import Agent
|
||||||
|
from shared.rls import configure_rls_hook, current_tenant_id
|
||||||
|
|
||||||
|
chat_router = APIRouter(prefix="/api/portal/chat", tags=["chat"])
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Pydantic schemas
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class ConversationOut(BaseModel):
|
||||||
|
id: str
|
||||||
|
tenant_id: str
|
||||||
|
agent_id: str
|
||||||
|
agent_name: str | None = None
|
||||||
|
user_id: str
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
last_message_preview: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ConversationCreate(BaseModel):
|
||||||
|
tenant_id: uuid.UUID
|
||||||
|
agent_id: uuid.UUID
|
||||||
|
|
||||||
|
|
||||||
|
class MessageOut(BaseModel):
|
||||||
|
id: str
|
||||||
|
role: str
|
||||||
|
content: str
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class DeleteResult(BaseModel):
|
||||||
|
deleted: bool
|
||||||
|
conversation_id: str
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Helper: configure RLS and set context var
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _rls_set(engine_: Any, tenant_uuid: uuid.UUID) -> Any:
|
||||||
|
"""Configure RLS hook and set the tenant context variable."""
|
||||||
|
configure_rls_hook(engine_)
|
||||||
|
return current_tenant_id.set(tenant_uuid)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# GET /api/portal/chat/conversations
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@chat_router.get("/conversations", response_model=list[ConversationOut])
|
||||||
|
async def list_conversations(
|
||||||
|
tenant_id: uuid.UUID = Query(...),
|
||||||
|
caller: PortalCaller = Depends(get_portal_caller),
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
) -> list[ConversationOut]:
|
||||||
|
"""
|
||||||
|
List conversations for the authenticated user within a tenant.
|
||||||
|
|
||||||
|
Platform admins can see all conversations for the tenant.
|
||||||
|
Other users see only their own conversations.
|
||||||
|
"""
|
||||||
|
# RBAC — raises 403 if caller is not a member (platform_admin bypasses)
|
||||||
|
await require_tenant_member(tenant_id=tenant_id, caller=caller, session=session)
|
||||||
|
|
||||||
|
token = _rls_set(engine, tenant_id)
|
||||||
|
try:
|
||||||
|
stmt = (
|
||||||
|
select(WebConversation, Agent.name.label("agent_name"))
|
||||||
|
.join(Agent, WebConversation.agent_id == Agent.id, isouter=True)
|
||||||
|
.where(WebConversation.tenant_id == tenant_id)
|
||||||
|
)
|
||||||
|
# Non-admins only see their own conversations
|
||||||
|
if caller.role != "platform_admin":
|
||||||
|
stmt = stmt.where(WebConversation.user_id == caller.user_id)
|
||||||
|
|
||||||
|
stmt = stmt.order_by(WebConversation.updated_at.desc())
|
||||||
|
result = await session.execute(stmt)
|
||||||
|
rows = result.all()
|
||||||
|
|
||||||
|
conversations: list[ConversationOut] = []
|
||||||
|
for row in rows:
|
||||||
|
conv = row[0]
|
||||||
|
agent_name = row[1] if len(row) > 1 else None
|
||||||
|
conversations.append(
|
||||||
|
ConversationOut(
|
||||||
|
id=str(conv.id),
|
||||||
|
tenant_id=str(conv.tenant_id),
|
||||||
|
agent_id=str(conv.agent_id),
|
||||||
|
agent_name=agent_name,
|
||||||
|
user_id=str(conv.user_id),
|
||||||
|
created_at=conv.created_at,
|
||||||
|
updated_at=conv.updated_at,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return conversations
|
||||||
|
finally:
|
||||||
|
current_tenant_id.reset(token)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# POST /api/portal/chat/conversations
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@chat_router.post("/conversations", response_model=ConversationOut, status_code=status.HTTP_200_OK)
|
||||||
|
async def create_conversation(
|
||||||
|
body: ConversationCreate,
|
||||||
|
caller: PortalCaller = Depends(get_portal_caller),
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
) -> ConversationOut:
|
||||||
|
"""
|
||||||
|
Create or get an existing conversation for the caller + agent pair.
|
||||||
|
|
||||||
|
Uses get-or-create semantics: if a conversation already exists for this
|
||||||
|
(tenant_id, agent_id, user_id) triple, it is returned rather than creating
|
||||||
|
a duplicate.
|
||||||
|
"""
|
||||||
|
# RBAC
|
||||||
|
await require_tenant_member(tenant_id=body.tenant_id, caller=caller, session=session)
|
||||||
|
|
||||||
|
token = _rls_set(engine, body.tenant_id)
|
||||||
|
try:
|
||||||
|
# Check for existing conversation
|
||||||
|
existing_stmt = select(WebConversation).where(
|
||||||
|
WebConversation.tenant_id == body.tenant_id,
|
||||||
|
WebConversation.agent_id == body.agent_id,
|
||||||
|
WebConversation.user_id == caller.user_id,
|
||||||
|
)
|
||||||
|
existing_result = await session.execute(existing_stmt)
|
||||||
|
existing = existing_result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if existing is not None:
|
||||||
|
return ConversationOut(
|
||||||
|
id=str(existing.id),
|
||||||
|
tenant_id=str(existing.tenant_id),
|
||||||
|
agent_id=str(existing.agent_id),
|
||||||
|
user_id=str(existing.user_id),
|
||||||
|
created_at=existing.created_at,
|
||||||
|
updated_at=existing.updated_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create new conversation
|
||||||
|
new_conv = WebConversation(
|
||||||
|
id=uuid.uuid4(),
|
||||||
|
tenant_id=body.tenant_id,
|
||||||
|
agent_id=body.agent_id,
|
||||||
|
user_id=caller.user_id,
|
||||||
|
)
|
||||||
|
session.add(new_conv)
|
||||||
|
try:
|
||||||
|
await session.flush()
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(new_conv)
|
||||||
|
except IntegrityError:
|
||||||
|
# Race condition: another request created it between our SELECT and INSERT
|
||||||
|
await session.rollback()
|
||||||
|
existing_result2 = await session.execute(existing_stmt)
|
||||||
|
existing2 = existing_result2.scalar_one_or_none()
|
||||||
|
if existing2 is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail="Failed to create conversation",
|
||||||
|
)
|
||||||
|
return ConversationOut(
|
||||||
|
id=str(existing2.id),
|
||||||
|
tenant_id=str(existing2.tenant_id),
|
||||||
|
agent_id=str(existing2.agent_id),
|
||||||
|
user_id=str(existing2.user_id),
|
||||||
|
created_at=existing2.created_at,
|
||||||
|
updated_at=existing2.updated_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
return ConversationOut(
|
||||||
|
id=str(new_conv.id),
|
||||||
|
tenant_id=str(new_conv.tenant_id),
|
||||||
|
agent_id=str(new_conv.agent_id),
|
||||||
|
user_id=str(new_conv.user_id),
|
||||||
|
created_at=new_conv.created_at,
|
||||||
|
updated_at=new_conv.updated_at,
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
current_tenant_id.reset(token)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# GET /api/portal/chat/conversations/{id}/messages
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@chat_router.get("/conversations/{conversation_id}/messages", response_model=list[MessageOut])
|
||||||
|
async def list_messages(
|
||||||
|
conversation_id: uuid.UUID,
|
||||||
|
limit: int = Query(default=50, ge=1, le=200),
|
||||||
|
before: str | None = Query(default=None),
|
||||||
|
caller: PortalCaller = Depends(get_portal_caller),
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
) -> list[MessageOut]:
|
||||||
|
"""
|
||||||
|
Return paginated message history for a conversation.
|
||||||
|
|
||||||
|
Messages ordered by created_at ASC (oldest first).
|
||||||
|
Cursor pagination via `before` parameter (message ID).
|
||||||
|
|
||||||
|
Ownership enforced: caller must own the conversation OR be platform_admin.
|
||||||
|
"""
|
||||||
|
# Fetch conversation first to verify ownership and get tenant_id
|
||||||
|
conv_stmt = select(WebConversation).where(WebConversation.id == conversation_id)
|
||||||
|
conv_result = await session.execute(conv_stmt)
|
||||||
|
conversation = conv_result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if conversation is None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Conversation not found")
|
||||||
|
|
||||||
|
# Ownership check: caller owns the conversation or is platform_admin
|
||||||
|
if caller.role != "platform_admin" and conversation.user_id != caller.user_id:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="You do not have access to this conversation",
|
||||||
|
)
|
||||||
|
|
||||||
|
token = _rls_set(engine, conversation.tenant_id)
|
||||||
|
try:
|
||||||
|
msg_stmt = (
|
||||||
|
select(WebConversationMessage)
|
||||||
|
.where(WebConversationMessage.conversation_id == conversation_id)
|
||||||
|
.order_by(WebConversationMessage.created_at.asc())
|
||||||
|
.limit(limit)
|
||||||
|
)
|
||||||
|
|
||||||
|
if before:
|
||||||
|
try:
|
||||||
|
before_uuid = uuid.UUID(before)
|
||||||
|
# Get the cursor message's created_at
|
||||||
|
cursor_stmt = select(WebConversationMessage.created_at).where(
|
||||||
|
WebConversationMessage.id == before_uuid
|
||||||
|
)
|
||||||
|
cursor_result = await session.execute(cursor_stmt)
|
||||||
|
cursor_ts = cursor_result.scalar_one_or_none()
|
||||||
|
if cursor_ts is not None:
|
||||||
|
msg_stmt = msg_stmt.where(WebConversationMessage.created_at < cursor_ts)
|
||||||
|
except (ValueError, AttributeError):
|
||||||
|
pass # Invalid cursor — ignore and return from start
|
||||||
|
|
||||||
|
msg_result = await session.execute(msg_stmt)
|
||||||
|
messages = msg_result.scalars().all()
|
||||||
|
|
||||||
|
return [
|
||||||
|
MessageOut(
|
||||||
|
id=str(m.id),
|
||||||
|
role=m.role,
|
||||||
|
content=m.content,
|
||||||
|
created_at=m.created_at,
|
||||||
|
)
|
||||||
|
for m in messages
|
||||||
|
]
|
||||||
|
finally:
|
||||||
|
current_tenant_id.reset(token)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# DELETE /api/portal/chat/conversations/{id}
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@chat_router.delete("/conversations/{conversation_id}", response_model=DeleteResult)
|
||||||
|
async def reset_conversation(
|
||||||
|
conversation_id: uuid.UUID,
|
||||||
|
caller: PortalCaller = Depends(get_portal_caller),
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
) -> DeleteResult:
|
||||||
|
"""
|
||||||
|
Reset a conversation by deleting all messages.
|
||||||
|
|
||||||
|
The conversation row is kept but all messages are deleted.
|
||||||
|
Updates updated_at on the conversation.
|
||||||
|
|
||||||
|
Ownership enforced: caller must own the conversation OR be platform_admin.
|
||||||
|
"""
|
||||||
|
# Fetch conversation
|
||||||
|
conv_stmt = select(WebConversation).where(WebConversation.id == conversation_id)
|
||||||
|
conv_result = await session.execute(conv_stmt)
|
||||||
|
conversation = conv_result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if conversation is None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Conversation not found")
|
||||||
|
|
||||||
|
# Ownership check
|
||||||
|
if caller.role != "platform_admin" and conversation.user_id != caller.user_id:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="You do not have access to this conversation",
|
||||||
|
)
|
||||||
|
|
||||||
|
token = _rls_set(engine, conversation.tenant_id)
|
||||||
|
try:
|
||||||
|
# Delete all messages for this conversation
|
||||||
|
delete_stmt = delete(WebConversationMessage).where(
|
||||||
|
WebConversationMessage.conversation_id == conversation_id
|
||||||
|
)
|
||||||
|
await session.execute(delete_stmt)
|
||||||
|
|
||||||
|
# Update conversation timestamp
|
||||||
|
await session.execute(
|
||||||
|
text("UPDATE web_conversations SET updated_at = NOW() WHERE id = :conv_id"),
|
||||||
|
{"conv_id": str(conversation_id)},
|
||||||
|
)
|
||||||
|
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
return DeleteResult(deleted=True, conversation_id=str(conversation_id))
|
||||||
|
finally:
|
||||||
|
current_tenant_id.reset(token)
|
||||||
124
packages/shared/shared/models/chat.py
Normal file
124
packages/shared/shared/models/chat.py
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
"""
|
||||||
|
SQLAlchemy 2.0 ORM models for web chat conversations.
|
||||||
|
|
||||||
|
These models support the Phase 6 web chat feature — a WebSocket-based
|
||||||
|
channel that allows portal users to chat with AI employees directly from
|
||||||
|
the Konstruct portal UI.
|
||||||
|
|
||||||
|
Tables:
|
||||||
|
web_conversations — One per portal user + agent pair per tenant
|
||||||
|
web_conversation_messages — Individual messages within a conversation
|
||||||
|
|
||||||
|
RLS is applied to both tables via app.current_tenant session variable,
|
||||||
|
same pattern as agents and channel_connections (migration 008).
|
||||||
|
|
||||||
|
Design notes:
|
||||||
|
- UniqueConstraint on (tenant_id, agent_id, user_id) for get-or-create semantics
|
||||||
|
- role column is TEXT+CHECK (not sa.Enum) per Phase 1 ADR to avoid Alembic DDL conflicts
|
||||||
|
- ON DELETE CASCADE on messages.conversation_id: deleting a conversation
|
||||||
|
removes all its messages automatically
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy import DateTime, ForeignKey, Text, UniqueConstraint, func
|
||||||
|
from sqlalchemy.dialects.postgresql import UUID
|
||||||
|
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
|
||||||
|
|
||||||
|
from shared.models.tenant import Base
|
||||||
|
|
||||||
|
|
||||||
|
class WebConversation(Base):
|
||||||
|
"""
|
||||||
|
A web chat conversation between a portal user and an AI employee.
|
||||||
|
|
||||||
|
One row per (tenant_id, agent_id, user_id) triple — callers use
|
||||||
|
get-or-create semantics when starting a chat session.
|
||||||
|
|
||||||
|
RLS scoped to tenant_id so the app role only sees conversations
|
||||||
|
for the currently-configured tenant.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "web_conversations"
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
UUID(as_uuid=True),
|
||||||
|
primary_key=True,
|
||||||
|
default=uuid.uuid4,
|
||||||
|
server_default=func.gen_random_uuid(),
|
||||||
|
)
|
||||||
|
tenant_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
UUID(as_uuid=True),
|
||||||
|
ForeignKey("tenants.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
agent_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
UUID(as_uuid=True),
|
||||||
|
ForeignKey("agents.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
user_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
UUID(as_uuid=True),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
nullable=False,
|
||||||
|
server_default=func.now(),
|
||||||
|
)
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
nullable=False,
|
||||||
|
server_default=func.now(),
|
||||||
|
onupdate=func.now(),
|
||||||
|
)
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("tenant_id", "agent_id", "user_id", name="uq_web_conversations_tenant_agent_user"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class WebConversationMessage(Base):
|
||||||
|
"""
|
||||||
|
A single message within a web chat conversation.
|
||||||
|
|
||||||
|
role is stored as TEXT with a CHECK constraint ('user' or 'assistant'),
|
||||||
|
following the Phase 1 convention that avoids PostgreSQL ENUM DDL issues.
|
||||||
|
|
||||||
|
Messages are deleted via ON DELETE CASCADE when their parent conversation
|
||||||
|
is deleted, or explicitly during a conversation reset (DELETE /conversations/{id}).
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "web_conversation_messages"
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
UUID(as_uuid=True),
|
||||||
|
primary_key=True,
|
||||||
|
default=uuid.uuid4,
|
||||||
|
server_default=func.gen_random_uuid(),
|
||||||
|
)
|
||||||
|
conversation_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
UUID(as_uuid=True),
|
||||||
|
ForeignKey("web_conversations.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
tenant_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
UUID(as_uuid=True),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
role: Mapped[str] = mapped_column(
|
||||||
|
Text,
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
content: Mapped[str] = mapped_column(
|
||||||
|
Text,
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
nullable=False,
|
||||||
|
server_default=func.now(),
|
||||||
|
)
|
||||||
@@ -26,6 +26,7 @@ class ChannelType(StrEnum):
|
|||||||
TEAMS = "teams"
|
TEAMS = "teams"
|
||||||
TELEGRAM = "telegram"
|
TELEGRAM = "telegram"
|
||||||
SIGNAL = "signal"
|
SIGNAL = "signal"
|
||||||
|
WEB = "web"
|
||||||
|
|
||||||
|
|
||||||
class MediaType(StrEnum):
|
class MediaType(StrEnum):
|
||||||
|
|||||||
@@ -144,3 +144,25 @@ def pending_tool_confirm_key(tenant_id: str, thread_id: str) -> str:
|
|||||||
Namespaced Redis key: "{tenant_id}:tool_confirm:{thread_id}"
|
Namespaced Redis key: "{tenant_id}:tool_confirm:{thread_id}"
|
||||||
"""
|
"""
|
||||||
return f"{tenant_id}:tool_confirm:{thread_id}"
|
return f"{tenant_id}:tool_confirm:{thread_id}"
|
||||||
|
|
||||||
|
|
||||||
|
def webchat_response_key(tenant_id: str, conversation_id: str) -> str:
|
||||||
|
"""
|
||||||
|
Redis pub-sub channel key for web chat response delivery.
|
||||||
|
|
||||||
|
The WebSocket handler subscribes to this channel after dispatching
|
||||||
|
a message to Celery. The orchestrator publishes the agent response
|
||||||
|
to this channel when processing completes.
|
||||||
|
|
||||||
|
Key includes both tenant_id and conversation_id to ensure:
|
||||||
|
- Two conversations in the same tenant get separate channels
|
||||||
|
- Two tenants with the same conversation_id are fully isolated
|
||||||
|
|
||||||
|
Args:
|
||||||
|
tenant_id: Konstruct tenant identifier.
|
||||||
|
conversation_id: Web conversation UUID string.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Namespaced Redis key: "{tenant_id}:webchat:response:{conversation_id}"
|
||||||
|
"""
|
||||||
|
return f"{tenant_id}:webchat:response:{conversation_id}"
|
||||||
|
|||||||
283
tests/unit/test_chat_api.py
Normal file
283
tests/unit/test_chat_api.py
Normal file
@@ -0,0 +1,283 @@
|
|||||||
|
"""
|
||||||
|
Unit tests for the chat REST API with RBAC enforcement.
|
||||||
|
|
||||||
|
Tests:
|
||||||
|
- test_chat_rbac_enforcement: GET /api/portal/chat/conversations?tenant_id=X returns 403
|
||||||
|
when caller is not a member of tenant X
|
||||||
|
- test_platform_admin_cross_tenant: GET /api/portal/chat/conversations?tenant_id=X returns 200
|
||||||
|
when caller is platform_admin (bypasses membership check)
|
||||||
|
- test_list_conversation_history: GET /api/portal/chat/conversations/{id}/messages returns
|
||||||
|
paginated messages ordered by created_at
|
||||||
|
- test_create_conversation: POST /api/portal/chat/conversations creates or returns existing
|
||||||
|
conversation for user+agent pair
|
||||||
|
- test_create_conversation_rbac: POST returns 403 for non-member caller
|
||||||
|
- test_delete_conversation_resets_messages: DELETE /api/portal/chat/conversations/{id} deletes
|
||||||
|
messages but keeps the conversation row
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from httpx import ASGITransport, AsyncClient
|
||||||
|
|
||||||
|
from shared.api.rbac import PortalCaller
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _admin_headers(user_id: str | None = None) -> dict[str, str]:
|
||||||
|
return {
|
||||||
|
"X-Portal-User-Id": user_id or str(uuid.uuid4()),
|
||||||
|
"X-Portal-User-Role": "platform_admin",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _stranger_headers(user_id: str | None = None) -> dict[str, str]:
|
||||||
|
return {
|
||||||
|
"X-Portal-User-Id": user_id or str(uuid.uuid4()),
|
||||||
|
"X-Portal-User-Role": "customer_operator",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _make_app_with_session_override(mock_session: AsyncMock) -> FastAPI:
|
||||||
|
"""Create a test FastAPI app with the chat router and a session dependency override."""
|
||||||
|
from shared.api.chat import chat_router
|
||||||
|
from shared.db import get_session
|
||||||
|
|
||||||
|
app = FastAPI()
|
||||||
|
app.include_router(chat_router)
|
||||||
|
|
||||||
|
async def _override_get_session(): # type: ignore[return]
|
||||||
|
yield mock_session
|
||||||
|
|
||||||
|
app.dependency_overrides[get_session] = _override_get_session
|
||||||
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# RBAC enforcement on list conversations
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_chat_rbac_enforcement() -> None:
|
||||||
|
"""Non-member caller gets 403 when listing conversations for a tenant they don't belong to."""
|
||||||
|
tenant_id = uuid.uuid4()
|
||||||
|
user_id = uuid.uuid4()
|
||||||
|
|
||||||
|
# Mock session — no membership row found (require_tenant_member checks UserTenantRole)
|
||||||
|
mock_session = AsyncMock()
|
||||||
|
mock_session.execute.return_value = MagicMock(scalar_one_or_none=MagicMock(return_value=None))
|
||||||
|
|
||||||
|
app = _make_app_with_session_override(mock_session)
|
||||||
|
|
||||||
|
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
|
||||||
|
response = await client.get(
|
||||||
|
"/api/portal/chat/conversations",
|
||||||
|
params={"tenant_id": str(tenant_id)},
|
||||||
|
headers=_stranger_headers(str(user_id)),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_platform_admin_cross_tenant() -> None:
|
||||||
|
"""Platform admin can list conversations for any tenant (bypasses membership check)."""
|
||||||
|
tenant_id = uuid.uuid4()
|
||||||
|
user_id = uuid.uuid4()
|
||||||
|
|
||||||
|
# Mock session — returns empty rows for conversation query
|
||||||
|
mock_session = AsyncMock()
|
||||||
|
mock_result = MagicMock()
|
||||||
|
mock_result.all.return_value = []
|
||||||
|
mock_session.execute.return_value = mock_result
|
||||||
|
|
||||||
|
app = _make_app_with_session_override(mock_session)
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("shared.api.chat.configure_rls_hook"),
|
||||||
|
patch("shared.api.chat.current_tenant_id"),
|
||||||
|
):
|
||||||
|
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
|
||||||
|
response = await client.get(
|
||||||
|
"/api/portal/chat/conversations",
|
||||||
|
params={"tenant_id": str(tenant_id)},
|
||||||
|
headers=_admin_headers(str(user_id)),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert isinstance(response.json(), list)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# List conversation history (paginated messages)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_list_conversation_history() -> None:
|
||||||
|
"""GET /api/portal/chat/conversations/{id}/messages returns paginated messages ordered by created_at."""
|
||||||
|
user_id = uuid.uuid4()
|
||||||
|
conv_id = uuid.uuid4()
|
||||||
|
|
||||||
|
# Mock conversation owned by the caller
|
||||||
|
mock_conv = MagicMock()
|
||||||
|
mock_conv.id = conv_id
|
||||||
|
mock_conv.user_id = user_id
|
||||||
|
mock_conv.tenant_id = uuid.uuid4()
|
||||||
|
|
||||||
|
# Mock messages
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
mock_msg1 = MagicMock()
|
||||||
|
mock_msg1.id = uuid.uuid4()
|
||||||
|
mock_msg1.role = "user"
|
||||||
|
mock_msg1.content = "Hello"
|
||||||
|
mock_msg1.created_at = now
|
||||||
|
|
||||||
|
mock_msg2 = MagicMock()
|
||||||
|
mock_msg2.id = uuid.uuid4()
|
||||||
|
mock_msg2.role = "assistant"
|
||||||
|
mock_msg2.content = "Hi there!"
|
||||||
|
mock_msg2.created_at = now
|
||||||
|
|
||||||
|
mock_session = AsyncMock()
|
||||||
|
# First call: fetch conversation; second call: fetch messages
|
||||||
|
mock_session.execute.side_effect = [
|
||||||
|
MagicMock(scalar_one_or_none=MagicMock(return_value=mock_conv)),
|
||||||
|
MagicMock(scalars=MagicMock(return_value=MagicMock(all=MagicMock(return_value=[mock_msg1, mock_msg2])))),
|
||||||
|
]
|
||||||
|
|
||||||
|
app = _make_app_with_session_override(mock_session)
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("shared.api.chat.configure_rls_hook"),
|
||||||
|
patch("shared.api.chat.current_tenant_id"),
|
||||||
|
):
|
||||||
|
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
|
||||||
|
response = await client.get(
|
||||||
|
f"/api/portal/chat/conversations/{conv_id}/messages",
|
||||||
|
headers=_admin_headers(str(user_id)),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert isinstance(data, list)
|
||||||
|
assert len(data) == 2
|
||||||
|
assert data[0]["role"] == "user"
|
||||||
|
assert data[1]["role"] == "assistant"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Create conversation (get-or-create)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_create_conversation() -> None:
|
||||||
|
"""POST /api/portal/chat/conversations creates a new conversation for user+agent pair."""
|
||||||
|
tenant_id = uuid.uuid4()
|
||||||
|
agent_id = uuid.uuid4()
|
||||||
|
user_id = uuid.uuid4()
|
||||||
|
conv_id = uuid.uuid4()
|
||||||
|
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
# Platform admin bypasses membership check; no existing conversation found
|
||||||
|
mock_session = AsyncMock()
|
||||||
|
mock_session.execute.return_value = MagicMock(scalar_one_or_none=MagicMock(return_value=None))
|
||||||
|
mock_session.flush = AsyncMock()
|
||||||
|
mock_session.commit = AsyncMock()
|
||||||
|
mock_session.add = MagicMock()
|
||||||
|
|
||||||
|
# refresh populates server-default fields on the passed ORM object
|
||||||
|
async def _mock_refresh(obj: object) -> None:
|
||||||
|
obj.id = conv_id # type: ignore[attr-defined]
|
||||||
|
obj.created_at = now # type: ignore[attr-defined]
|
||||||
|
obj.updated_at = now # type: ignore[attr-defined]
|
||||||
|
|
||||||
|
mock_session.refresh = _mock_refresh
|
||||||
|
|
||||||
|
app = _make_app_with_session_override(mock_session)
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("shared.api.chat.configure_rls_hook"),
|
||||||
|
patch("shared.api.chat.current_tenant_id"),
|
||||||
|
):
|
||||||
|
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
|
||||||
|
response = await client.post(
|
||||||
|
"/api/portal/chat/conversations",
|
||||||
|
json={"tenant_id": str(tenant_id), "agent_id": str(agent_id)},
|
||||||
|
headers=_admin_headers(str(user_id)),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code in (200, 201)
|
||||||
|
data = response.json()
|
||||||
|
assert "id" in data
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_create_conversation_rbac_forbidden() -> None:
|
||||||
|
"""Non-member gets 403 when creating a conversation in a tenant they don't belong to."""
|
||||||
|
tenant_id = uuid.uuid4()
|
||||||
|
agent_id = uuid.uuid4()
|
||||||
|
user_id = uuid.uuid4()
|
||||||
|
|
||||||
|
# Membership check returns None (not a member)
|
||||||
|
mock_session = AsyncMock()
|
||||||
|
mock_session.execute.return_value = MagicMock(scalar_one_or_none=MagicMock(return_value=None))
|
||||||
|
|
||||||
|
app = _make_app_with_session_override(mock_session)
|
||||||
|
|
||||||
|
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
|
||||||
|
response = await client.post(
|
||||||
|
"/api/portal/chat/conversations",
|
||||||
|
json={"tenant_id": str(tenant_id), "agent_id": str(agent_id)},
|
||||||
|
headers=_stranger_headers(str(user_id)),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Delete conversation (reset messages)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_delete_conversation_resets_messages() -> None:
|
||||||
|
"""DELETE /api/portal/chat/conversations/{id} deletes messages but keeps conversation row."""
|
||||||
|
user_id = uuid.uuid4()
|
||||||
|
conv_id = uuid.uuid4()
|
||||||
|
|
||||||
|
mock_conv = MagicMock()
|
||||||
|
mock_conv.id = conv_id
|
||||||
|
mock_conv.user_id = user_id
|
||||||
|
mock_conv.tenant_id = uuid.uuid4()
|
||||||
|
|
||||||
|
mock_session = AsyncMock()
|
||||||
|
mock_session.execute.return_value = MagicMock(scalar_one_or_none=MagicMock(return_value=mock_conv))
|
||||||
|
mock_session.commit = AsyncMock()
|
||||||
|
|
||||||
|
app = _make_app_with_session_override(mock_session)
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("shared.api.chat.configure_rls_hook"),
|
||||||
|
patch("shared.api.chat.current_tenant_id"),
|
||||||
|
):
|
||||||
|
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
|
||||||
|
response = await client.delete(
|
||||||
|
f"/api/portal/chat/conversations/{conv_id}",
|
||||||
|
headers=_admin_headers(str(user_id)),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert mock_session.execute.call_count >= 1
|
||||||
312
tests/unit/test_web_channel.py
Normal file
312
tests/unit/test_web_channel.py
Normal file
@@ -0,0 +1,312 @@
|
|||||||
|
"""
|
||||||
|
Unit tests for the web channel adapter.
|
||||||
|
|
||||||
|
Tests:
|
||||||
|
- test_normalize_web_event: normalize_web_event returns KonstructMessage with channel=WEB
|
||||||
|
- test_normalize_web_event_thread_id: thread_id equals conversation_id
|
||||||
|
- test_normalize_web_event_sender: sender.user_id equals portal user UUID
|
||||||
|
- test_webchat_response_key: webchat_response_key returns correct namespaced key
|
||||||
|
- test_send_response_web_publishes_to_redis: _send_response("web", ...) publishes JSON to Redis
|
||||||
|
- test_typing_indicator_sent: WebSocket handler sends {"type": "typing"} before Celery dispatch
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from shared.models.message import ChannelType, KonstructMessage
|
||||||
|
from shared.redis_keys import webchat_response_key
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Test webchat_response_key
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_webchat_response_key_format() -> None:
|
||||||
|
"""webchat_response_key returns correctly namespaced key."""
|
||||||
|
tenant_id = "tenant-abc"
|
||||||
|
conversation_id = "conv-xyz"
|
||||||
|
key = webchat_response_key(tenant_id, conversation_id)
|
||||||
|
assert key == "tenant-abc:webchat:response:conv-xyz"
|
||||||
|
|
||||||
|
|
||||||
|
def test_webchat_response_key_isolation() -> None:
|
||||||
|
"""Two tenants with same conversation_id get different keys."""
|
||||||
|
key_a = webchat_response_key("tenant-a", "conv-1")
|
||||||
|
key_b = webchat_response_key("tenant-b", "conv-1")
|
||||||
|
assert key_a != key_b
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Test ChannelType.WEB
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_channel_type_web_exists() -> None:
|
||||||
|
"""ChannelType.WEB must exist with value 'web'."""
|
||||||
|
assert ChannelType.WEB == "web"
|
||||||
|
assert ChannelType.WEB.value == "web"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Test normalize_web_event
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_normalize_web_event_returns_konstruct_message() -> None:
|
||||||
|
"""normalize_web_event returns a KonstructMessage."""
|
||||||
|
from gateway.channels.web import normalize_web_event
|
||||||
|
|
||||||
|
tenant_id = str(uuid.uuid4())
|
||||||
|
agent_id = str(uuid.uuid4())
|
||||||
|
user_id = str(uuid.uuid4())
|
||||||
|
conversation_id = str(uuid.uuid4())
|
||||||
|
|
||||||
|
event = {
|
||||||
|
"text": "Hello from the portal",
|
||||||
|
"tenant_id": tenant_id,
|
||||||
|
"agent_id": agent_id,
|
||||||
|
"user_id": user_id,
|
||||||
|
"display_name": "Portal User",
|
||||||
|
"conversation_id": conversation_id,
|
||||||
|
}
|
||||||
|
msg = normalize_web_event(event)
|
||||||
|
assert isinstance(msg, KonstructMessage)
|
||||||
|
|
||||||
|
|
||||||
|
def test_normalize_web_event_channel_is_web() -> None:
|
||||||
|
"""normalize_web_event sets channel = ChannelType.WEB."""
|
||||||
|
from gateway.channels.web import normalize_web_event
|
||||||
|
|
||||||
|
event = {
|
||||||
|
"text": "test",
|
||||||
|
"tenant_id": str(uuid.uuid4()),
|
||||||
|
"agent_id": str(uuid.uuid4()),
|
||||||
|
"user_id": str(uuid.uuid4()),
|
||||||
|
"display_name": "User",
|
||||||
|
"conversation_id": str(uuid.uuid4()),
|
||||||
|
}
|
||||||
|
msg = normalize_web_event(event)
|
||||||
|
assert msg.channel == ChannelType.WEB
|
||||||
|
|
||||||
|
|
||||||
|
def test_normalize_web_event_thread_id_equals_conversation_id() -> None:
|
||||||
|
"""normalize_web_event sets thread_id = conversation_id for memory scoping."""
|
||||||
|
from gateway.channels.web import normalize_web_event
|
||||||
|
|
||||||
|
conversation_id = str(uuid.uuid4())
|
||||||
|
event = {
|
||||||
|
"text": "test",
|
||||||
|
"tenant_id": str(uuid.uuid4()),
|
||||||
|
"agent_id": str(uuid.uuid4()),
|
||||||
|
"user_id": str(uuid.uuid4()),
|
||||||
|
"display_name": "User",
|
||||||
|
"conversation_id": conversation_id,
|
||||||
|
}
|
||||||
|
msg = normalize_web_event(event)
|
||||||
|
assert msg.thread_id == conversation_id
|
||||||
|
|
||||||
|
|
||||||
|
def test_normalize_web_event_sender_user_id() -> None:
|
||||||
|
"""normalize_web_event sets sender.user_id to the portal user UUID."""
|
||||||
|
from gateway.channels.web import normalize_web_event
|
||||||
|
|
||||||
|
user_id = str(uuid.uuid4())
|
||||||
|
event = {
|
||||||
|
"text": "test",
|
||||||
|
"tenant_id": str(uuid.uuid4()),
|
||||||
|
"agent_id": str(uuid.uuid4()),
|
||||||
|
"user_id": user_id,
|
||||||
|
"display_name": "Portal User",
|
||||||
|
"conversation_id": str(uuid.uuid4()),
|
||||||
|
}
|
||||||
|
msg = normalize_web_event(event)
|
||||||
|
assert msg.sender.user_id == user_id
|
||||||
|
|
||||||
|
|
||||||
|
def test_normalize_web_event_channel_metadata() -> None:
|
||||||
|
"""normalize_web_event populates channel_metadata with portal_user_id, tenant_id, conversation_id."""
|
||||||
|
from gateway.channels.web import normalize_web_event
|
||||||
|
|
||||||
|
tenant_id = str(uuid.uuid4())
|
||||||
|
user_id = str(uuid.uuid4())
|
||||||
|
conversation_id = str(uuid.uuid4())
|
||||||
|
|
||||||
|
event = {
|
||||||
|
"text": "test",
|
||||||
|
"tenant_id": tenant_id,
|
||||||
|
"agent_id": str(uuid.uuid4()),
|
||||||
|
"user_id": user_id,
|
||||||
|
"display_name": "User",
|
||||||
|
"conversation_id": conversation_id,
|
||||||
|
}
|
||||||
|
msg = normalize_web_event(event)
|
||||||
|
assert msg.channel_metadata["portal_user_id"] == user_id
|
||||||
|
assert msg.channel_metadata["tenant_id"] == tenant_id
|
||||||
|
assert msg.channel_metadata["conversation_id"] == conversation_id
|
||||||
|
|
||||||
|
|
||||||
|
def test_normalize_web_event_tenant_id() -> None:
|
||||||
|
"""normalize_web_event sets tenant_id on the message."""
|
||||||
|
from gateway.channels.web import normalize_web_event
|
||||||
|
|
||||||
|
tenant_id = str(uuid.uuid4())
|
||||||
|
event = {
|
||||||
|
"text": "hello",
|
||||||
|
"tenant_id": tenant_id,
|
||||||
|
"agent_id": str(uuid.uuid4()),
|
||||||
|
"user_id": str(uuid.uuid4()),
|
||||||
|
"display_name": "User",
|
||||||
|
"conversation_id": str(uuid.uuid4()),
|
||||||
|
}
|
||||||
|
msg = normalize_web_event(event)
|
||||||
|
assert msg.tenant_id == tenant_id
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Test _send_response web case publishes to Redis
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_send_response_web_publishes_to_redis() -> None:
|
||||||
|
"""_send_response('web', ...) publishes JSON message to Redis webchat channel."""
|
||||||
|
tenant_id = str(uuid.uuid4())
|
||||||
|
conversation_id = str(uuid.uuid4())
|
||||||
|
|
||||||
|
mock_redis = AsyncMock()
|
||||||
|
mock_redis.publish = AsyncMock()
|
||||||
|
mock_redis.aclose = AsyncMock()
|
||||||
|
|
||||||
|
with patch("orchestrator.tasks.aioredis.from_url", return_value=mock_redis):
|
||||||
|
from orchestrator.tasks import _send_response
|
||||||
|
|
||||||
|
extras = {
|
||||||
|
"conversation_id": conversation_id,
|
||||||
|
"tenant_id": tenant_id,
|
||||||
|
}
|
||||||
|
await _send_response("web", "Hello, this is the agent response!", extras)
|
||||||
|
|
||||||
|
expected_channel = webchat_response_key(tenant_id, conversation_id)
|
||||||
|
mock_redis.publish.assert_called_once()
|
||||||
|
call_args = mock_redis.publish.call_args
|
||||||
|
assert call_args[0][0] == expected_channel
|
||||||
|
published_payload = json.loads(call_args[0][1])
|
||||||
|
assert published_payload["type"] == "response"
|
||||||
|
assert published_payload["text"] == "Hello, this is the agent response!"
|
||||||
|
assert published_payload["conversation_id"] == conversation_id
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_send_response_web_connection_cleanup() -> None:
|
||||||
|
"""_send_response web case always closes Redis connection (try/finally)."""
|
||||||
|
mock_redis = AsyncMock()
|
||||||
|
mock_redis.publish = AsyncMock()
|
||||||
|
mock_redis.aclose = AsyncMock()
|
||||||
|
|
||||||
|
with patch("orchestrator.tasks.aioredis.from_url", return_value=mock_redis):
|
||||||
|
from orchestrator.tasks import _send_response
|
||||||
|
|
||||||
|
await _send_response("web", "test", {"conversation_id": "c1", "tenant_id": "t1"})
|
||||||
|
|
||||||
|
mock_redis.aclose.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_send_response_web_missing_conversation_id_logs_warning() -> None:
|
||||||
|
"""_send_response web case logs warning if conversation_id missing."""
|
||||||
|
mock_redis = AsyncMock()
|
||||||
|
|
||||||
|
with patch("orchestrator.tasks.aioredis.from_url", return_value=mock_redis):
|
||||||
|
with patch("orchestrator.tasks.logger") as mock_logger:
|
||||||
|
from orchestrator.tasks import _send_response
|
||||||
|
|
||||||
|
await _send_response("web", "test", {"tenant_id": "t1"})
|
||||||
|
|
||||||
|
mock_logger.warning.assert_called()
|
||||||
|
mock_redis.publish.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Test typing indicator
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_typing_indicator_sent_before_dispatch() -> None:
|
||||||
|
"""WebSocket handler sends {'type': 'typing'} immediately after receiving user message."""
|
||||||
|
# We test the typing indicator by calling the handler function directly
|
||||||
|
# with a mocked WebSocket and mocked Celery dispatch.
|
||||||
|
from unittest.mock import AsyncMock, MagicMock, call, patch
|
||||||
|
|
||||||
|
mock_ws = AsyncMock()
|
||||||
|
# First receive_json returns auth message, second returns the user message
|
||||||
|
mock_ws.receive_json = AsyncMock(
|
||||||
|
side_effect=[
|
||||||
|
{"type": "auth", "userId": str(uuid.uuid4()), "role": "customer_operator", "tenantId": str(uuid.uuid4())},
|
||||||
|
{"type": "message", "text": "Hello agent", "agentId": str(uuid.uuid4()), "conversationId": str(uuid.uuid4())},
|
||||||
|
]
|
||||||
|
)
|
||||||
|
# accept must be awaitable
|
||||||
|
mock_ws.accept = AsyncMock()
|
||||||
|
mock_ws.send_json = AsyncMock()
|
||||||
|
|
||||||
|
# Mock DB session that returns a conversation
|
||||||
|
mock_session = AsyncMock()
|
||||||
|
mock_conv = MagicMock()
|
||||||
|
mock_conv.id = uuid.uuid4()
|
||||||
|
mock_conv.tenant_id = uuid.uuid4()
|
||||||
|
mock_session.execute.return_value = MagicMock(scalar_one_or_none=MagicMock(return_value=mock_conv))
|
||||||
|
mock_session.__aenter__ = AsyncMock(return_value=mock_session)
|
||||||
|
mock_session.__aexit__ = AsyncMock(return_value=False)
|
||||||
|
|
||||||
|
# Mock Redis pub-sub: raise after publishing once so handler exits
|
||||||
|
mock_redis = AsyncMock()
|
||||||
|
mock_pubsub = AsyncMock()
|
||||||
|
mock_pubsub.subscribe = AsyncMock()
|
||||||
|
mock_pubsub.get_message = AsyncMock(return_value={
|
||||||
|
"type": "message",
|
||||||
|
"data": json.dumps({
|
||||||
|
"type": "response",
|
||||||
|
"text": "Agent reply",
|
||||||
|
"conversation_id": str(uuid.uuid4()),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
mock_pubsub.unsubscribe = AsyncMock()
|
||||||
|
mock_redis.pubsub = MagicMock(return_value=mock_pubsub)
|
||||||
|
mock_redis.aclose = AsyncMock()
|
||||||
|
|
||||||
|
mock_handle = MagicMock()
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("gateway.channels.web.async_session_factory", return_value=mock_session),
|
||||||
|
patch("gateway.channels.web.aioredis.from_url", return_value=mock_redis),
|
||||||
|
patch("gateway.channels.web.handle_message") as mock_handle_msg,
|
||||||
|
patch("gateway.channels.web.configure_rls_hook"),
|
||||||
|
patch("gateway.channels.web.current_tenant_id"),
|
||||||
|
):
|
||||||
|
mock_handle_msg.delay = MagicMock()
|
||||||
|
|
||||||
|
from gateway.channels.web import _handle_websocket_connection
|
||||||
|
|
||||||
|
# Run the handler — it should send typing indicator then process
|
||||||
|
# Use asyncio.wait_for to prevent infinite loop if something hangs
|
||||||
|
try:
|
||||||
|
await asyncio.wait_for(
|
||||||
|
_handle_websocket_connection(mock_ws, str(uuid.uuid4())),
|
||||||
|
timeout=2.0,
|
||||||
|
)
|
||||||
|
except (asyncio.TimeoutError, StopAsyncIteration, Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Check typing was sent before Celery dispatch
|
||||||
|
send_calls = mock_ws.send_json.call_args_list
|
||||||
|
typing_calls = [c for c in send_calls if c[0][0].get("type") == "typing"]
|
||||||
|
assert len(typing_calls) >= 1, f"Expected typing indicator, got send_json calls: {send_calls}"
|
||||||
Reference in New Issue
Block a user