Spec: F14 — Chat with Lumi
Status: shipped · Audited: 2026-07-14 · Contract: partial until browser proof
1. Problem & CEO Job
Decide fast. The Brief answers “what should I look at?”; chat answers the follow-up: “việc nào cần tôi quyết?”, “ai đang bị kẹt?”, “tóm tắt project X”. It is a query layer over the organization’s own data graph — customers, staff, tasks, commitments, risks, projects, and suppliers that no general assistant can see. It answers only from that data and says “không có” when none exists: a wrong-but-fluent answer is worse than a dumb-but-honest one. The model is table-stakes; the graph makes answers specific to this company.
2. User Stories & Acceptance Criteria
-
JTBD (CEO — history): “When I view my team’s pulse history, I want to see trends in concerns over time, so I can identify recurring themes — a customer complaint surfacing in three consecutive weeks — without reading every pulse.”
- Given the thread message list, the history is keyboard-navigable (Tab / arrow keys reach each message; Enter opens or expands). Screen-reader labels on message bubbles (
aria-labelwith sender and timestamp). (ChatPage.tsx: message divs haverole="article",tabIndex={0},aria-labelwith sender+time; container hasonKeyDownhandler for ArrowUp/ArrowDown) - Given recurring keywords across multiple pulses (e.g., “công nợ” appearing in five pulses over two weeks), the chat answer to “rủi ro gần đây?” reflects the pattern — not just the most recent pulse. (2026-07-02:
_recurring_domain_phrases+ augmented risk branch in_route_chat_intent;TestRecurringRiskPulseTrendintest_chat_service.py) - Given recurring commitment keywords across multiple pulses (e.g., “sẽ chuyển” appearing in five pulses), the chat answer to “cam kết gần đây?” reflects the pattern — not just the open Commitment records. (2026-07-05:
_COMMITMENT_PATTERNSimported; commitment branch augmented with_recurring_domain_phrases(org_id, _COMMITMENT_PATTERNS);commitment_trend_header+commitment_trend_linetemplate keys;TestRecurringCommitmentPulseTrend(4 cases) intest_chat_service.py) - Given the CEO has never submitted a pulse (empty org history), the message area shows an explicit empty state (“Chưa có dữ liệu pulse — hãy bắt đầu check-in đầu tiên”) rather than a blank screen. (ChatPage.tsx: fetches pulsesApi.list() on mount; hasPulses=false → shows t(‘no_pulse_empty_state’) in the empty panel)
- Given the thread message list, the history is keyboard-navigable (Tab / arrow keys reach each message; Enter opens or expands). Screen-reader labels on message bubbles (
-
As a CEO, I want to ask in natural Vietnamese and get structured answers from my org’s data.
- Given “việc nào cần quyết?”, then open DecisionRequests (top 5) as a list — not prose around them. — covered by
TestChatService(decision intent; §11 audit summary: not a listed gap) - Given “rủi ro” / “cam kết” / “task” / “brief” intents, then the corresponding org-scoped list with the fields that matter (severity, due date, owner). — covered by
TestChatService(§11 audit summary: not a listed gap) - Given “task nào quá hạn?” (overdue keywords), then only
due_date < todayopen/in_progress tasks — not all open tasks. — 2026-06-10 fix;TestChatServiceoverdue tests (§11 row 2 ✅) - Given “ai đang bị kẹt?” (blocked keywords), then
status="waiting"tasks grouped by owner with counts; empty → “không có ai đang bị kẹt”. — 2026-06-10 intent;TestChatServiceblocked test (§11 row 3 ✅) - Given an empty result, then an explicit “hiện không có…” — never fabricated items. — honesty bar enforced by mock templates +
test_chat_fallback_for_unknown; §11 rows 3 + 5 ✅; audit summary: not a listed gap - Given an unrecognized question, then Lumi states what it can answer (capability transparency, no fake comprehension). — covered by
test_chat_fallback_for_unknowninTestChatService
- Given “việc nào cần quyết?”, then open DecisionRequests (top 5) as a list — not prose around them. — covered by
-
As a CEO, I want threads, so that a line of questioning has context.
- Threads persist per member; messages ordered;
metadata_jsonavailable for provenance. — covered bytest_chat_messages,test_chat_messages_ordered_with_metadata,test_chat_message_metadata_json_on_sendinTestChat
- Threads persist per member; messages ordered;
-
Tenant absolute: every answer queries
organization_idfromrequest.tenant— a cross-org leak through chat is the worst possible bug in this feature. — covered bytest_chat_tenant_isolation,test_chat_message_tenant_isolationinTestChat, andtest_chat_service_tenant_isolationinTestChatService
3. Scope Boundaries
- In: thread CRUD, message send/receive, retrieval over the org’s data graph + LLM synthesis (configured provider per F37) of a grounded answer in the member’s language, assistant action proposals with confirm-before-commit, voice/file capture, progressive web delivery, capability fallback, and recurring-keyword trend aggregation.
- Out: open-world general-knowledge answers, irreversible/external actions without explicit confirmation, and native/KMP progressive-event parity in this backend/web slice.
§3a — Retrieval → grounded synthesis (the real-mode answer path)
The keyword intents below are no longer the answer — they are the retrieval router. The answer is synthesized by the LLM from the retrieved rows, in the member’s language, and may never exceed what those rows say:
- Retrieve (org-scoped, always): from
msg_lower, select the relevant slices — open decisions, high/critical risks, overdue tasks, blocked (waiting) tasks grouped by owner, overdue/open commitments, today’s brief items, and a recurring-keyword roll-up across recent pulses (closes the §2 trend AC). Every query filtersorganization_idfromrequest.tenant. Cap rows per slice (≤20) and serialize to a compact DATA block with stable item references — never raw UUIDs surfaced to the CEO. - Synthesize: call the provider through
language_check.check_and_retry(...)withbuild_system_prompt(member, "chat")+build_chat_prompt(question, data_context). The system prompt’s GROUNDING rules force: answer only from DATA; empty DATA → an honest “nothing on that yet” in the member’s language; never invent. Persist the contributing item references inChatMessage.metadata_json(provenance). - Mock mode (default in dev/CI): no LLM — the existing deterministic per-intent answers are retained but emitted via member-language templates so vi/en/th round-trip and the honesty bar (“không có” / “nothing yet”) is preserved. This keeps tests deterministic and coverage ≥99%.
- Failure isolation:
LLMProviderError→ fall back to the deterministic mock answer for the matched intent (an honest grounded answer beats a 500), logged. Emptycontent→ 400. - Tenant isolation is absolute — unchanged and re-asserted: every retrieval query is org-scoped; a cross-org leak through chat is the worst possible bug in this feature.
Why the answer is defensible — the data graph
Chat is a query layer over the org’s own operating graph, not a generic assistant bolted onto a product. A Vietnamese fit-out SME does not run a workspace tool whose chat we must out-answer — its real-time state lives in Zalo threads, an Excel file, and the owner’s head, and none of that is queryable. The defensibility is not the AI (any model can read text — that is table-stakes); it is the accumulated multiplayer graph Lumi builds from pulses, tasks, commitments, risks, customers, and suppliers. Ask “việc nào đang kẹt?” and chat answers from this company’s status="waiting" tasks grouped by owner, with evidence — an answer no open-world chatbot can produce because it cannot see the graph. Every day the org uses Lumi, the graph deepens and the answers get sharper; that compounding is the moat.
Success Metrics
| Metric | Target | Why |
|---|---|---|
| Weekly chat usage rate | >40% of active members | Chat must be a daily habit, not a curiosity. Measures whether Lumi’s answers are useful enough to return to. |
| Intent recognition accuracy | >85% of questions correctly routed to the right intent | Wrong-intent answers destroy trust faster than a blank response. Measured by F15 feedback on chat messages. |
| CEO answer satisfaction | >70% “useful” on chat-message feedback | The CEO is the primary persona. If chat doesn’t help them decide fast, it fails its job. |
| Felt latency | First useful visible feedback <2s; first proposal <5s; full turn <8s p95 on the approved live suite | CEOs need evidence that work is landing, not empty typing theatre. Mock locks deterministic UI timing; live-provider p95 requires credential-bound evidence. |
4. Data Model
chat.ChatThread (member-owned), chat.ChatMessage (role, author_kind, status, metadata_json, agent_trace_json). The normal path enters through apps/chat/agent_loop.py, executes in task_runtime_execution.py, and uses AgentToolbelt for tenant/role-filtered reads and proposals. The synchronous grounded service remains the broker-down fallback.
5. API Contract
| Method | Path | Min role | Notes |
|---|---|---|---|
| GET | /api/chat/threads/ | member | My threads |
| POST | /api/chat/threads/create/ | member | New thread |
| GET | /api/chat/threads/<id>/messages/ | member | Cursor history ordered by immutable UUIDv7 id, 100/page; compatible {count,count_is_capped,next,previous,results} envelope. count is exact through the documented cap and cap+1 thereafter, so history reads never scan an unbounded thread. |
| POST | /api/chat/threads/<id>/messages/send/ | member | Send → 201 with a complete reply on the synchronous fallback path, or 202 with a tenant/member-scoped pending assistant message when the agent loop is enabled. The async path emits assistant_progress, grounded assistant_proposals, validated assistant_token, then terminal message_updated events; polling remains the fallback if SSE fails. |
6. UI / UX
/intel/chat(ChatPage.tsx;/hub/chatpermanently redirects). The dock and full page share the same proposal/action derivation and SSE consumer.- First-run empty state teaches by example: 3–4 tappable sample questions (“Việc nào cần tôi quyết?”) — this is the training, there is no other.
- States: the optimistic pending reply immediately gives a topic-specific acknowledgment; tool progress, grounded proposal/action batches, and validated answer chunks replace that acknowledgment as they arrive. The composer is disabled during send. On failure, a toast appears and the typed text is restored so the CEO never retypes it.
- Message-load failure is an authored inline state, never a false empty. When
chatApi.messages()fails, the message panel shows anEmptyState(TriangleAlerticon,error_load_title+error_load_messagescopy,error_load_retryaction) that re-fetches on click — it must not fall through to the positive first-run prompt panel, which would falsely tell the CEO the thread is empty. - i18n:
chat.
7. Test Plan
| # | Case | Type | Where |
|---|---|---|---|
| 1 | Thread/message endpoints, tenant scoping | backend | tests/test_api.py::TestChat |
| 2 | Each intent: data answer + empty answer + fallback | backend | tests/test_api.py::TestChatService |
| 3 | Send + render flow | e2e | describe("Chat with Lumi") |
| 4 | Page states: message-load error + retry recovery, send appends user/assistant bubbles, send failure preserves composer text | unit | frontend/src/test/ChatPage.test.tsx |
| 5 | Composer attachment state: a send failure preserves the attached file chip + publicUrl so the CEO retries without re-picking; a send success clears the chip. The pair locks the preserve-on-failure / clear-on-success branch behavior in handleSend. | unit | frontend/src/test/ChatPage.test.tsx (preserves the attached file on send failure… + clears the attached file on send success) |
| 6 | Dock ↔ full-page proposal/action parity (W-AIQ-03): dock renders a ProposalCard, confirm calls the real confirm mutation and shows the executed state, dock renders an ActionCard too | unit | frontend/src/test/dock/ChatLumiPanelProposals.test.tsx |
| 7 | Chat cache race (W-AIQ-02): a send-written proposal survives a stale, deferred-MSW initial-GET response that resolves AFTER the send’s cache write | unit | frontend/src/test/hooks/useChatSendRace.test.tsx |
| 8 | Markdown XSS-safe loading fallback (W-AIQ-04): raw source renders as inert plain text before the marked/dompurify chunk resolves (no live <script> element ever), then swaps to sanitized HTML | unit | frontend/src/test/markdown/Markdown.test.tsx |
| 9 | Progressive async turn: first visible feedback is under 2s on mock, each grounded proposal batch renders before final completion, multiple batches accumulate, and both full-page and dock surfaces consume the same member-scoped SSE contract | backend + unit + e2e | backend/tests/assistant/test_f91_p1a_latency_narration.py; frontend/src/test/intel/ChatPage.progress.test.tsx; frontend/src/test/dock/ChatLumiPanelProposals.test.tsx; frontend/tests/e2e/f91-progressive-chat.spec.ts |
8. Current Behavior
Shipped. Ask-Lumi chat has real thread, message, and answer flows in the web product and remains distinct from F29 internal human chat.
9. Error States
Backend
| Error | HTTP | Trigger | Handling |
|---|---|---|---|
| Empty content | 400 | content missing or empty string in send request | {"detail": "Content required"} |
| LLM provider failure | 200/201 | LLMProviderError from Gemini/OpenRouter (network, timeout, bad payload) | Caught inside answer_chat_message, which degrades to a grounded mock answer — the reply is still useful, not an error. |
| Unexpected AI failure | 503 | Any non-LLMProviderError exception while generating the reply in-request | Caught in send_message: user message persisted, a failed assistant message returned, exception reported to Sentry. Frontend shows t('error_send') toast. Replaces the old async-placeholder path that left empty/pending bubbles (LUMI-BE-7). |
| Thread not found | 404 | Invalid UUID or thread belongs to different org | get_object_or_404 in thread_messages and send_message (fixed 2026-06-13) |
| Language detection silent failure | 200 | record_message_language errors are not caught — a failure here would crash the send. Current implementation is pure Python (regex + ORM save), so failure is unlikely. | Would surface as 500 on send |
Frontend
| Error | Trigger | UX |
|---|---|---|
error_load_threads | useChatThreads query errors with no cached list | Inline EmptyState (error_load_threads_title + error_load_threads) with an error_load_retry button that calls refetch. Replaces the old toast-only path that left the positive first-run coaching panel on screen — a thread-load failure no longer reads as “you have no conversations”. (2026-06-21: thread-load parity with the message-load path.) |
error_load_messages | useChatMessages query errors with no cached messages | Inline EmptyState (error_load_title + error_load_messages) with an error_load_retry button that calls refetch. No false empty-prompt panel; toast suppressed in favor of the in-panel state. Gated on empty cache so a transient background-refetch failure never wipes a loaded conversation. |
error_create_thread | chatApi.createThread() fails | Toast error, no thread added to list |
error_send | chatApi.sendMessage() fails | Toast error, sending resets to false, the typed text is restored to the composer, and the attached file is preserved so the CEO can retry without retyping the question or re-picking the file. Closes both halves of the Known Gap below. |
error_upload_failed | useUpload hook error | Toast error, attached file cleared |
Composer State Preservation on Send Failure (formerly: Known Gap — closed 2026-06-22)
handleSend is now optimistic only for the typed text: setInput('') runs before the
API call so the composer “feels sent”, but setAttachedFile(null) + resetUpload() were
moved inside the success branch so a send failure preserves the attachment intact.
The error_send catch additionally restores the typed text. Combined, a network blip
on send keeps both the CEO’s words and their attached file ready for one-tap retry —
the highest-traffic CEO daily surface (the AI Assistant chat) stays calm and never
forces re-pick after a transient failure.
The invariant is locked by two paired regression tests in frontend/src/test/ChatPage.test.tsx:
preserves the attached file on send failure so the CEO can retry without re-pickingclears the attached file on send success
Together they make the “preserve on failure / clear on success” branch behavior uncommittable to regress.
History / Pulse-Trend Error States
| ID | Scenario | Expected behavior |
|---|---|---|
| ES01 | Pulse history empty — CEO has never submitted a pulse | Chat answer to history-type queries returns an explicit “Hiện không có dữ liệu pulse” message — never fabricates items. Frontend history list shows the chat.empty_state i18n key rather than a blank area. |
| ES02 | Pulse history present but AI extraction failed on one or more entries | Chat answers draw only from successfully extracted items. Messages referencing a pulse where extraction errored show the raw pulse text as context (if available) rather than silence. No fabricated extraction. |
| ES03 | Pagination edge: last page of message history has fewer items than page_size | GET /api/chat/threads/<id>/messages/ on the final page returns "next": null. Frontend stops paginating on next: null — no extra request, no “empty page” flash. |
10. Diagrams & Docs Touched
DIAGRAMS.md · docs/AGENTS.md → Chat Agent (the intent table there is the product contract).
11. Implementation Audit (2026-06-13)
| # | Claim | Status | Evidence |
|---|---|---|---|
| 1 | Thread CRUD; messages ordered by created_at; metadata_json present on user + assistant messages | ✅ match | test_chat_messages_ordered_with_metadata; test_chat_message_metadata_json_on_send |
| 2 | Overdue intent: due_date < today filter applied (not all open tasks) | ✅ match | 2026-06-10 fix; TestChatService overdue tests |
| 3 | Blocked intent: status="waiting" tasks grouped by owner; empty → “Không có ai đang bị kẹt” | ✅ match | 2026-06-10 intent; TestChatService blocked test |
| 4 | Tenant isolation: cross-tenant 404 on thread read/send via get_object_or_404 | ✅ match | test_chat_message_tenant_isolation; test_chat_service_tenant_isolation (2026-06-13 fix from .get() to get_object_or_404) |
| 5 | Unrecognized question → capability transparency (states what Lumi can answer) | ✅ match | test_chat_fallback_for_unknown (strengthened 2026-06-13) |
| 6 | Empty-org history → explicit “Chưa có dữ liệu pulse” empty state | ✅ match | ChatPage.tsx empty-history check; hasPulses conditional render |
| 7 | Keyboard navigation + screen-reader labels on message bubbles | ✅ match | role="article", tabIndex={0}, aria-label, onKeyDown handler in ChatPage.tsx |
| 8 | Recurring keyword aggregation across pulses for trend answers | ✅ match | 2026-07-02: _recurring_domain_phrases(org_id, _RISK_PATTERNS) counts distinct pulse hits per risk phrase; augmented risk branch in _route_chat_intent appends trend section when ≥2 pulses match; TestRecurringRiskPulseTrend (4 cases: repeat surfaces, threshold, tenant isolation, combined) in tests/api/test_chat_service.py |
| 9 | Thread read/send scoped by org only, not member — UUID-unguessable but not member-isolated | ⚠️ gap | Edge case “Thread Access Control”; soft isolation via UUID; post-MVP hardening recommended |
| 10 | Optimistic input clear: user’s typed message + attached file lost on send failure | ✅ match | 2026-06-21: handleSend restores the typed text in the error_send catch (ChatPage.test.tsx send-failure restore test). 2026-06-22: attachment-preservation locked behind a regression test pair (preserves the attached file on send failure… + clears the attached file on send success) — §9 Known Gap closed, see §13. |
| 11 | Visual QC: first-run and empty-thread chat states teach by example instead of showing a blank conversation panel | ✅ match | 2026-06-18 visual QC: ChatPage.tsx now shows coached sample prompts for no active thread and empty selected threads; screenshot flow captures fresh-thread, typed, and sent states. |
| 12 | Visual QC: the conversation rail and active-thread header help users scan context before asking follow-ups | ✅ match | 2026-06-18 visual QC: left rail now shows title, preview, and recency metadata per thread; active thread header states whether the thread already has answers or is ready for its first question. |
| 13 | Thread-load failure shows an inline error + retry (not a misleading positive empty state) | ✅ match | 2026-06-21: threadsQuery.isError && threads.length === 0 → inline EmptyState (error_load_threads_title) + retry; ChatPage.test.tsx::shows an inline error with retry when the thread list fails to load |
| 14 | Reads use TanStack Query (useChat.ts hooks), not useEffect+manual fetch; error panels gated on empty cache so a background-refetch failure never wipes loaded data | ✅ match | 2026-06-21: ChatPage consumes useChatThreads/useChatMessages; the previously-dead useChat.ts is now live; ChatPage.test.tsx::keeps the loaded conversation visible if a background refetch fails |
Summary: 14/14 match; 1 gap (member-level thread isolation — row 9, post-MVP). Commitment-domain pulse trend extended 2026-07-05 (§2 AC). Recurring-keyword risk trend closed 2026-07-02 (row 8). The optimistic-input-loss, toast-only-load-error, and convention-drift gaps were closed 2026-06-21 (rows 10, 13, 14).
Edge Cases Catalog (formerly §7, 2026-06-12): Key items integrated above. Remaining: input edge cases (empty→400, whitespace bypasses server, long message unlimited, attachment URL treated as plain text); thread behaviors (empty list → empty state, ordering by -updated_at, title fallback); query result limits (5 items per intent, no “N more” indicator; 20 for blocked tasks); language detection runs on every send (passive state update, not answer language). Full catalog in git history (2026-06-12 changelog entry).
12. Open Questions
- Real-mode LLM chat (RAG over org data) — needs a grounding policy (“answer only from retrieved rows, cite evidence”) before enabling; the mock’s honesty is the bar to beat, not just its fluency.
13. Bug / Change Log
2026-07-16 — Empty-thread startup ownership
Type: Performance behavior preservation
The empty Chat start surface keeps its prompt, desktop thread rail, mobile trust entry, and first-thread actions without loading the active message, markdown, proposal, upload, voice, and composer stack. Conditional language/onboarding banners and confirmation/trust modules now load only when their state or user intent requires them. Existing Chat behavior suites remain green; F40 owns the source-custody and route-budget proof.
2026-07-14 — Contract Graph ownership boundary
The web /intel/chat surface is now represented by contracts/features/F14.cue. F91 remains the
canonical owner of chat and assistant APIs, agent-loop grounding, confirmation, memory, autonomy,
and native assistant claims; F14 intentionally owns only the route, ChatPage behavior, and web
state/accessibility proof. Browser runtime remains an explicit generated gap until the registered
journey executes.
2026-07-13 — Immutable UUID cursor and bounded compatibility count
F14 history now uses the unique immutable UUIDv7 id as its cursor, adds the matching
org/thread/id index, and co-partitions reactions by denormalizing message_created_at.
The required iOS count key remains but is bounded and accompanied by
count_is_capped, eliminating an unbounded COUNT(*) on long threads. ADR-043 owns the
cross-ledger invariant and conversion path.
2026-07-10 — Dock ↔ full-page proposal parity, chat cache race, XSS-safe Markdown fallback (W-AIQ-02/03/04)
Type: Bug Fix (structural gap + race condition + honesty gap) + Test coverage
Summary — three findings from the W-AIQ audit walks (docs/tests/evidence/web/W-AIQ-02/03/04/walk.md), fixed together since all three sit on the same chat rendering/cache path:
- W-AIQ-03 (structural FAIL) — dock never rendered proposal/action cards.
ChatLumiPanel.tsxmapped every message to a plain text/Markdown bubble and never importedProposalCard/ActionCard, so a confirm-before-commit proposal the full page showed at/intel/chatwas completely invisible in the dock for the identical thread. Fix: extracted the proposal/action derivation ChatPage owned inline (stable identity, dismissal, inline-cache-before-refetch) into a shared hook,frontend/src/hooks/useChatProposals.ts, and now bothChatPage.tsxandChatLumiPanel.tsxcall it — the dock renders the SAMEProposalCard/ActionCardcomponents with the SAME confirm/reject mutations, not a fork. The dock’s ownhandleSendalso now capturesproposals/actionsfrom the send response into the shared inline cache, exactly likeChatPage’screateHandleSend. - W-AIQ-02 (race, ~1-in-4-6 runs) — stale initial GET could clobber a just-sent proposal.
useChatMessages(threadId)’s initial fetch (fired the instant a thread is selected/created) could resolve AFTER a send’s cache write, silently overwriting a just-rendered proposal with the thread’s pre-send (empty) snapshot — no console error, no crash, just a vanished card. Fix:useSendChatMessage(infrontend/src/hooks/queries/useChat.ts) now cancels any in-flight fetch for that exact thread’s messages query inonMutate, before the send’s own network call fires — a cancelled query’s late resolution is discarded by TanStack Query regardless of arrival order. One fix in the shared mutation hook covers every caller (ChatPage AND the dock). - W-AIQ-04 (blank-bubble transient) — first Markdown render could paint blank text.
Markdown.tsxlazy-importsmarked+dompurify(code-split, ~30-130ms cold-load) but started atuseState('')and rendereddangerouslySetInnerHTML={{__html: ''}}the whole time, so the FIRST markdown bubble on a page load showed a “complete” (non- pending) assistant bubble with blank text. Fix:htmlnow starts atnull; whilenull, the component renders the raw source as a plain REACT TEXT NODE (neverdangerouslySetInnerHTML) so the real reply text is visible immediately and a hostile<script>/onerror=payload is always inert characters, never parsed markup — then swaps to the sanitized HTML once the chunk resolves, same as before. - Tests —
frontend/src/test/dock/ChatLumiPanelProposals.test.tsx(3: dock renders a proposal card, confirm calls the real mutation and shows the executed state, dock renders an ActionCard too),frontend/src/test/hooks/useChatSendRace.test.tsx(1: deferred-MSW race — the send-written proposal survives a stale GET that resolves after it; verified this test fails without theonMutatecancel and passes with it),frontend/src/test/markdown/Markdown.test.tsx(2: plain-text-then-sanitized-HTML swap, asserting no live<script>element and no unsanitized<script>bytes at any point). Two pre-existingChatPage.test.tsxassertions were hardened fromexpect(await screen.findByText(...)).toBeInTheDocument()toawait waitFor(() => expect(screen.getByText(...)).toBeInTheDocument())— the former can hold a stale element handle across the (now real, always-present) plain-text → sanitized-HTML swap, which legitimately replaces the matched DOM node.
2026-07-09 — Auto-title chat threads from the first user message (P0 design-review defect)
Type: Bug Fix (UX defect — assistant-first doctrine)
Summary:
- Defect. Threads were created with
title=""and nothing ever named them, so the mobile/web assistant home rendered walls of “Untitled thread” — an assistant that can’t name its own conversations reads as broken (F91 doctrine). No LLM/async titling hook existed anywhere; this heuristic is the first and primary titling path. - Fix. New pure helper
derive_thread_title(text)inapps/chat/services.py: strip markdown (fences, inline code, links/images, headings, blockquotes, list markers, emphasis) → strip emoji → collapse to a single line → truncate to 60 chars on a word boundary → drop trailing punctuation; blank/emoji-only input →""(caller skips).maybe_autotitle_thread(thread, content)sets the title only when currently blank (user renames and the messaging-bridge sentinel title are never overwritten). - Wiring. Called in the message-creation service paths —
apps/chat/views.py_send_message_agent_loop(both the normal path and the daily-ceiling path; the inline broker-down fallback reuses the same already-created user message) andapps/telegram/agent_bridge.py_enqueue_agent_loop— so web, mobile, and Telegram all benefit. Tenant isolation untouched (thread is resolved org-scoped before the call). - Tests.
tests/api/test_chat_autotitle.py— 21 cases: helper units (markdown, emoji, Vietnamese text, 60-char word boundary, blank), guard units (blank-only set, never-overwrite), integration (first message titles, second doesn’t overwrite, pre-titled untouched, cross-tenant 404 leaves title blank, Telegram bridge titles).
2026-07-05 — Recurring commitment-keyword pulse trend (§2 JTBD commitment AC)
Type: Feature (AI service enhancement) + Test coverage
Summary:
- What was missing. When a CEO asked “cam kết gần đây?” the chat returned only open
Commitment objects — identical gap to what the risk branch had before 2026-07-02.
The
_COMMITMENT_PATTERNSlist and_recurring_domain_phrasesfunction already existed; the commitment branch just didn’t use them. Phrases like “sẽ chuyển” appearing in five consecutive pulses signal recurring payment-promise patterns even when no formal Commitment record was created. - Commitment intent branch augmented in
_route_chat_intent: after the open-commitment list, calls_recurring_domain_phrases(organization_id, _COMMITMENT_PATTERNS)and appends a “Từ khóa cam kết lặp lại trong check-in gần đây:” section when recurring patterns are found. Both Commitment objects and trend data shown when both exist; empty state fires only when neither is present. - Two new template keys in
apps/ai/_templates.py:commitment_trend_header(fixed, vi/en/th) andcommitment_trend_line(formatted, vi/en/th,{term}+{count}). - 4 new tests in
tests/api/test_chat_service.py::TestRecurringCommitmentPulseTrend:test_commitment_query_includes_pulse_trend_when_keyword_repeats— ≥2 distinct pulses → phrase surfacestest_commitment_query_trend_respects_count_threshold— 1 pulse → phrase absenttest_commitment_query_trend_is_tenant_scoped— cross-tenant pulses not visibletest_commitment_query_with_objects_and_trend_shows_both— Commitment record + trend shown together
- Verification: 6564 passed, 97.15% coverage (≥97% gate). §2 commitment AC checked.
2026-07-02 — Recurring risk-keyword pulse trend (§11 row 8 closed)
Type: Feature (AI service enhancement) + Test coverage
Summary:
- What was missing. When a CEO asked “rủi ro gần đây?” the chat returned only the list of open Risk objects from the database. The §2 AC required the answer to also reflect recurring patterns across pulse check-ins — e.g. “công nợ” appearing in 5 consecutive pulses signals a systemic cash-flow issue even when no explicit Risk item was created.
- New function
_recurring_domain_phrases(organization_id, patterns, *, recent=50, limit=3)inapps/ai/services.py: scans the most recent 50 pulse entries, performs phrase-level substring matching against a domain pattern list (preserving multi-word Vietnamese phrases like “công nợ” that word-tokenisation would lose), counts distinct pulses per phrase, returns top 3 phrases with count ≥ 2. Tenant-scoped viaPulseEntry.filter(organization_id=...). - Risk intent branch augmented in
_route_chat_intent: after the open-risk list, calls_recurring_domain_phrases(organization_id, _RISK_PATTERNS)and appends a “Từ khóa rủi ro lặp lại trong check-in gần đây:” section when recurring patterns are found. Both risk objects and trend data are shown when both exist; the empty state fires only when neither is present. - Two new template keys in
apps/ai/_templates.py:risk_trend_header(fixed, vi/en/th) andrisk_trend_line(formatted, vi/en/th,{term}+{count}placeholders). - 4 new tests in
tests/api/test_chat_service.py::TestRecurringRiskPulseTrend:test_risk_query_includes_pulse_trend_when_keyword_repeats— ≥2 distinct pulses → phrase surfacestest_risk_query_trend_respects_count_threshold— 1 pulse → phrase absenttest_risk_query_trend_is_tenant_scoped— org_b pulses not visible to org_a querytest_risk_query_with_risks_and_trend_shows_both— Risk object + trend both appear
Verification: 4878 passed, 97.12% coverage (≥97% gate). §11 row 8 flipped ⚠️ → ✅. §2 AC checked.
2026-07-02 — Spec drift correction: check 5 ACs confirmed by §11 audit
Type: Documentation (spec drift fix)
Summary:
- Corrected §2 AC checkboxes to reflect the actual implementation state per the §11 audit.
- Checked 5 previously-unchecked ACs that were confirmed done by §11: decision intent (audit summary: not a listed gap), rủi ro/cam kết/task/brief intents (audit summary: not a listed gap), overdue-tasks filter (row 2 ✅), blocked-tasks grouped by owner with empty case (row 3 ✅), and the empty-result honesty guarantee (rows 3 + 5 ✅; audit summary: not a listed gap).
- Left 1 AC as
[ ]: recurring-keyword aggregation across pulses (§11 row 8 ⚠️ gap — still unimplemented inChatService). - No code, test, API, or status change.
2026-06-22 — Close the §9 Known Gap: attachment preserved on send failure (regression test pair)
Type: Test (lock the invariant) + Spec correctness (close stale Known Gap)
Summary:
- State of the code before this PR. PR #298 (
feat(chat): thread-load inline error + TanStack convergence, 2026-06-21) restructuredhandleSendto clearsetAttachedFile(null) + resetUpload()inside the success branch rather than before the API call — so the attachment-preservation half of the §9 Known Gap was already implemented in production. The spec, however, still documented the gap as open (“a restored file attachment is not re-attached on failure”). No regression test locked the new branch behavior, so a future refactor that moved the clear back outside the try block — or into afinally— would re-open the gap without anyone noticing until a CEO retried a chat with an attachment over a flaky network. - The fix. Two paired regression tests in
frontend/src/test/ChatPage.test.tsxlock the full branch behavior:preserves the attached file on send failure so the CEO can retry without re-picking— mocksuploadsApi.presign+uploadsApi.complete+ a successful S3fetchto make the chip render, mockschatApi.sendMessageto reject, asserts the send did fire with the attachment URL in the body (...\n\nhttps://.../y.pdf), the chip text (q4-plan.pdf) is still on screen after the failure, and the typed composer text is restored too.clears the attached file on send success— same setup, send resolves; the chip disappears. Without this symmetric assertion, a regression that preserved the chip on success too (e.g. moving the clear into afinallyblock) would slip past the failure-path test.
- The spec §9 was updated: the “Known Gap: Optimistic Input Clear” section is now “Composer State Preservation on Send Failure (closed 2026-06-22)” — documenting the actual code shape (input cleared optimistically, attachment cleared only on success, both restored on failure) and pointing at the two regression tests as the invariant lock. §7 Test Plan gains row 5 naming the two tests.
Why it ships now: the AI Assistant chat is the highest-traffic CEO daily surface in Lumi (the “calm reliability” that earns the daily habit). A send-failure that drops either the words or the file is exactly the kind of paper cut that pushes a CEO off the daily-habit channel. PR #298 closed the code half a week ago; this PR locks it behind a test and updates the spec so the next engineer doesn’t accidentally re-open the gap. No new dependencies, no new copy strings, no migration, no API change, no auth/tenancy/AI-evidence touch.
Tests:
frontend/src/test/ChatPage.test.tsx: 10 → 12 (two new cases, both green).- Full frontend suite: 9968 passed / 16 skipped (134 files).
tsc --noEmitclean.node scripts/check-design-tokens.mjsclean.
POLISH:
- F14 QA was 9/10; the regression pair raises confidence that the calm-reliability invariant holds. No spec-level POLISH bump claimed without the audit doc.
2026-06-21 — IOS-9: messages envelope missing count (iOS decode failure)
Type: Fix (API contract / mobile compatibility) + Regression test
Summary:
- Root cause.
thread_messages(GET /api/chat/threads/<id>/messages/) paginates withMessageCursorPagination(a DRFCursorPaginationsubclass), whose envelope is{next, previous, results}— nocount. Shipped iOS clients decode a generic paginated envelope that requirescount(every other paginated DRF endpoint in the app is count-bearing), so the chat-history decode failed withkeyNotFound "count" at (root)(Sentry lumi-ios, 6 events). - Fix (backward-compatible superset).
MessageCursorPaginationnow overridespaginate_querysetto stash the full (tenant + thread scoped) queryset andget_paginated_responseto emit{count, next, previous, results}—next/previous/resultsunchanged from cursor pagination. This first fix used an exact.count(); ADR-043 later bounded that compatibility field at 10,001 and addedcount_is_capped, while preserving exact values below the cap. - Tests (
tests/test_api.py::TestChatMessagePagination):countpresent and equal to thread total below the cap;countis the full total across pages (not page length);countis tenant+thread scoped (other-org read still 404/403, sibling thread doesn’t inflate); existing next/previous/results cursor behavior preserved.
2026-06-21 — Thread-load inline error + TanStack Query convergence (follow-on)
Type: Fix (UX) + Convention convergence + Test coverage
Summary:
- Closed the remaining false-empty path. The earlier same-day fix handled the
message-load failure but left the thread-list load failure toast-only — so a
failed thread load still rendered the positive first-run coaching panel, falsely
telling the CEO they have no conversations.
ChatPagenow shows an inlineEmptyState(error_load_threads_title+error_load_threads) with anerror_load_retrybutton on thread-load failure, matching the message-load path. - TanStack Query convergence (promoted Next-candidate ④ from the prior entry).
Thread + message reads migrated from
useEffect+.then()to the previously-deaduseChat.tshooks (useChatThreads/useChatMessages), which supplyisError/refetch. The manualmessagesErrorboolean +loadMessagescallback are retired. Send/create cache updates kept in the page viasetQueryDataso the optimistic append is unchanged. Both error panels are gated on an empty cache, so a transient background refetch failure (prod runsrefetchOnWindowFocus) never wipes a loaded conversation. - i18n: added
error_load_threads_title(en + vi, propagated); reuses the existingerror_load_title/error_load_retrykeys. - Tests:
ChatPage.test.tsxgains thread-load error + retry and a background-refetch-does-not-wipe-cache regression; the send-append assertion is scoped to the conversation log (the assistant reply also legitimately previews in the thread rail). Full suite green (9878 passed). - No backend, API, route, or data-model change; tenant isolation unchanged. POLISH F14 already at UX 9 / QA 9; this closes the convention-drift and thread-load gaps that held the audit short of full convergence.
2026-06-21 — Inline message-load error + composer text preservation (POLISH convergence)
Type: Fix (UX/QA)
Summary:
- Killed the false-empty on load failure. When
chatApi.messages()failed,ChatPagefired a toast and leftmessagesempty, so the positive first-run prompt panel rendered — falsely telling the CEO the thread was empty. It now renders an authored inlineEmptyState(TriangleAlert,error_load_title+error_load_messages) with a workingerror_load_retrybutton that re-fetches the thread’s messages. Mirrors the F23 Deadline Horizon error pattern. - Composer text preserved on send failure.
handleSendnow captures the typed text and restores it in theerror_sendcatch, so the CEO never retypes their question. Closes the text half of §9’s Known Gap; file-attachment restoration remains a tracked Next candidate. - QA coverage raised.
frontend/src/test/ChatPage.test.tsxgains tests for the message-load error + retry recovery, the send/append flow, and input preservation on send failure (§7 row 4). - i18n: added
error_load_title,error_load_retrytochat(en + vi, propagated to all locales). - No backend, API, route, or data-model change; tenant isolation unchanged. POLISH F14 UX 8→9, QA 7→9 (audit doc updated).
2026-06-21 — Floating Chat Lumi pod (Command Dock)
Type: Feature (UI/UX)
Summary:
- Chat Lumi now lives in a floating, bottom-end Command Dock (
frontend/src/components/dock/) present on every authenticated screen, alongside the Messages pod — so the daily AI habit follows the CEO instead of living behind a sidebar click. ChatLumiPanelis a compact, fully interactive thread (continues the latest thread, opens one on first send) reusingchatApi; an arrow-out control deep-links to the full/hub/chatsurface for deep work.- The pod carries the ambient “needs you” unread signal (notifications count) via the shared
useUnreadCountsaggregator; reduced-motion respected; Escape closes; single panel open at a time. - Tests:
frontend/src/test/CommandDock.test.tsx(pod render, toggle, single-open, Escape, unread aria).
2026-06-18 — Visual QC: chat first-run empty state coaching
Type: UX Fix
Summary:
- The screenshot flow captured
/hub/chatas a mostly blank right pane with one passive empty-state sentence. - This fails §6’s requirement that first-run chat teaches by example with tappable sample questions.
- Added a coached empty state for both no active thread and empty selected threads, with sample prompts about decisions, overdue work, risks, and blocked owners.
- The screenshot helper now clears stale per-flow PNGs before capture, and the chat flow creates a fresh thread so the gallery includes the coached state plus typed/sent states.
Verification:
frontend/screenshots/12-chat/02-chat-thread-ready.pngshows the empty-thread coaching.frontend/tests/e2e/screenshot-flow.spec.tscreates a fresh thread and captures typed/sent states.
2026-06-18 — Visual QC: conversation rail metadata and active-thread context
Type: UX Fix
Summary:
- The chat screen still looked generic in screenshot review because the thread rail was a stack of repeated titles with little context.
- Added a clearer rail header, per-thread preview snippets, and recency metadata so repeated brief-style threads are easier to distinguish at a glance.
- Added an active-thread header that explains whether the thread already has context or is waiting for its first question.
- Tightened the Vietnamese sample copy from mixed English/Vietnamese (“Task”) to a fully localized phrase.
Verification:
frontend/src/test/ChatPage.test.tsxcovers rail metadata and active-thread context.frontend/screenshots/12-chat/02-chat-thread-ready.pngand04-chat-after-send.pngshow the new hierarchy.
2026-06-16 — Fix: chat replies generated synchronously in-request (LUMI-BE-7)
Type: Bug Fix (production outage)
Summary: /hub/chat returned empty “Lumi” bubbles in production — every reply was blank. Root cause: the P10 async refactor offloaded reply generation to a Celery task (process_chat_async.delay), but no Celery worker was ever deployed (only backend/frontend ECS task definitions exist) and the Redis broker was unreachable (OperationalError: [Errno 111] Connection refused, Sentry LUMI-BE-7, 12 events / 2 users). The empty placeholder assistant message was therefore never filled.
Fix: send_message now generates the reply synchronously in the request via answer_chat_message and returns 201 with a complete, non-empty assistant message — no worker, no broker dependency (true scale-to-zero: $0 at idle, no cold-start latency on an interactive surface). answer_chat_message already degrades to a grounded mock on LLMProviderError; any other exception yields a 503 with a failed assistant message (user message still persisted) instead of a silent empty bubble. The process_chat_async Celery task is retained for genuine future background work but is no longer on the chat send path.
Tests: test_api.py::TestChat::test_send_message_is_synchronous_no_pending_placeholder, ::test_send_message_ai_failure_degrades_gracefully; test_performance.py::test_send_message_returns_201_with_complete_reply; test_ai_language.py::test_send_message_uses_build_system_prompt (patch target updated). View coverage 100%.
2026-06-16 — RAG-over-graph + LLM synthesis specified (answers in member’s language)
Type: Spec Update (design for implementation)
Summary:
- Added §3a: keyword intents become the retrieval router, not the answer. Retrieved
org-scoped rows (decisions, risks, overdue/blocked tasks, commitments, brief items,
recurring-keyword pulse trend) are synthesized by Gemini/DeepSeek (F37) into a grounded
answer in the member’s language, with provenance in
metadata_json. - Mock mode keeps deterministic per-intent answers but emits them via member-language templates (vi/en/th); provider failure falls back to the mock answer (no 500).
- Closes the long-standing gaps: §2 recurring-keyword trend AC (row 8),
system_promptnow actually consumed (was ignored by the keyword matcher), and the F37 “answer in user language” contract now reaches chat output.
Reason:
- §11 row 8 (no trend aggregation) and §12 (real-mode RAG needs a grounding policy) were
open; the keyword matcher ignored
build_system_promptand always answered in Vietnamese. This specifies the grounded, language-correct answer path while preserving the honesty bar.
Impact:
- Real-mode behavior + answer language change; thread/message API and tenant isolation unchanged. Status flips when implemented + tested. Mock determinism preserved for CI.
Tests: retrieval tenant-scoping, mock template language (vi/en/th), empty-data honest answer, real-mode synthesis with provider mocked + metadata provenance, provider-failure fallback, recurring-keyword trend answer.
2026-06-15 — Frontend route namespaced
Frontend route namespaced: /chat -> /hub/chat (app-wide IA restructure; API paths unchanged).
2026-06-14 — Strategy v2 realignment (positioning)
Type: Documentation Change
Summary:
- §1 intro: reframed chat as a query layer over the org’s own data graph (the moat), with AI explicitly called table-stakes.
- §3 out-of-scope: “general-knowledge chat (Lumi is not ChatGPT)” → “general-knowledge chat (chat answers only from the org’s own data graph, never the open world)”.
- §3: deleted the “Competitive Positioning” table (Notion AI / Jira AI / ClickUp AI) and replaced it with a short “Why the answer is defensible — the data graph” paragraph framing the moat as the accumulated multiplayer graph, answering “việc nào đang kẹt?” from the org’s own
status="waiting"tasks with evidence.
Reason:
- Realignment to competitive-strategy-v2.md and the registry’s Positioning doctrine (v2): do not position vs US SaaS (Notion/Jira/ClickUp); AI is table-stakes and the data graph is the moat; no “Lumi is not ChatGPT”-style competitive framing.
Impact:
- Positioning only. No product behavior, API, data model, or test changed.
Tests:
- None (documentation).
Notes:
- The intent contract (§2 ACs) and the honesty bar (“không có”, never invent) are unchanged — only the competitive framing around them.
2026-06-14 — Restructure to TEMPLATE.md section order; expand §11 audit (SP fleet)
Type: Documentation Change
Summary:
- §7 Edge Cases absorbed into §11 note; Test Plan moved from §9 back to §7; “Current Behavior” numbered as §8; §8 Error States → §9; §9 Test Plan → §7; §10 Diagrams stays §10; §11 Implementation Audit stays §11 (10-row table); §12/§13 unchanged.
- §11 audit: 10 rows, 7 ✅, 3 gaps (recurring-keyword trend, member-level thread isolation, optimistic input loss).
2026-06-14 — Perf fix: cursor pagination + ChatThread index + Subquery last_message (BE-D r02)
Type: Performance Fix
Summary:
- G1 fixed:
thread_messagesusesMessageCursorPagination(100/page, cursor-based). - G2 fixed:
ChatThread.Metacomposite index(organization, user, -updated_at). - G3 fixed:
list_threadsuses Subquery annotations instead of unbounded Prefetch. - Score 7→8.
docs/POLISH.mdF14 Perf[7]→[8].
2026-06-13 — AC verification: capability transparency, thread persistence, tenant isolation tests (fleet agent T1-B, round 28)
Type: Test Coverage / Spec Update
Summary:
- AC1 (capability transparency): strengthened
test_chat_fallback_for_unknownto assert the answer includes Lumi’s capability statement. - AC2 (thread persistence): added
test_chat_messages_ordered_with_metadataandtest_chat_message_metadata_json_on_send. - AC3 (tenant isolation): added
test_chat_message_tenant_isolationandtest_chat_service_tenant_isolation. Fixedchat/views.py: changedthread_messagesandsend_messagefrom.get()toget_object_or_404so cross-tenant access returns 404 instead of 500.
2026-06-13 — JTBD story (pulse history trends), accessibility note, ES01–ES03 (fleet agent G, round 25)
Type: Spec Sharpening
Summary:
- Added JTBD story to §2 with three ACs covering keyboard navigation, recurring-keyword surfacing, and empty-history empty state.
- Added ES01–ES03 to error states: pulse history empty → “no data” message; AI extraction failure → raw text fallback; pagination last page →
next: null.
2026-06-12 — Chat spec hardening: edge cases and error states audit
Type: Spec Update
Summary:
- Added Edge Cases section: message input validation, thread behaviors, thread access control gap, query result limits per intent, language detection integration.
- Added Error States section: backend errors (empty content 400, LLM provider 500, thread 404, language detection silent failure) and frontend errors (toast with i18n keys). Documented optimistic input clear gap.
2026-06-12 — Competitive positioning and success metrics (fleet agent G, round 21)
Type: Spec Sharpening
Summary:
- Added Competitive Positioning table (Notion AI, Jira AI, ClickUp AI) and Success Metrics table to §3.
2026-06-10 — Chat intent fixes: overdue filter, blocked intent, drop raw confidence
Type: Bug Fix
Summary:
- “quá hạn”/“overdue” keywords now filter
due_date < todayinstead of returning all open tasks. - New “blocked” intent (
kẹt|bị chặn|blocked) answers withstatus="waiting"tasks grouped by owner. - Decision-request answers no longer print raw
confidence: 0.6substrings.
2026-07-14 — Progressive grounded proposals on every web chat surface
Type: Performance / transport contract (#1552)
Summary:
- The agent loop publishes each proposal/action batch immediately after the tool result has passed the existing collection and grounding seam, before final-answer synthesis finishes.
/intel/chatand the dock now shareuseChatEventStream; both accumulate proposal batches, render the same cards during the pending turn, and keep polling as the SSE fallback.- A useful topic-specific acknowledgment is optimistic and immediate. The deterministic Chromium proof holds the HTTP send open and observes that feedback in under two seconds.
- Final-answer faithfulness is unchanged: only validated answer content is chunked into
assistant_tokenevents, andmessage_updatedremains the terminal persisted truth.
Evidence:
backend/tests/assistant/test_f91_p1a_latency_narration.pyfrontend/src/test/intel/ChatPage.progress.test.tsxfrontend/src/test/dock/ChatLumiPanelProposals.test.tsxfrontend/tests/e2e/f91-progressive-chat.spec.ts
Boundary: native/KMP consumption is deliberately deferred to the F30 audit; no mobile runtime claim is made. Live-provider p95 remains credential-gated and is tracked by the existing F91 live-proof gap rather than inferred from mock timing.