feat(chat): chat history — index, admin UI, realtime (Phases 1–3)#8
Merged
Conversation
Add a `chat` slice: a Postgres INDEX over agents' S3 JSONL session transcripts, so chats are listable/filterable without fanning out to agent pods and remain available when a pod is down. Message content stays in S3 (source of truth); Postgres only indexes. - ChatSession + ChatFeedback models (FK cascade to Agent) + migration. - ChatSyncService reconciles the index from S3 (IFileGateway.list). Counts are monotonic lifetime totals — reconcile seeds/raises, never lowers (compaction shrinks the file; realtime will own the true total). - Endpoints (Owner/Admin): GET /chats, /chats/:id, /chats/:id/messages, POST /chats/sync. - Shared TranscriptReaderService (agent/file): surfaces compaction `summary` events as collapsed markers instead of a silent gap, and filters synthetic loop-control noise (continuation prompts + the duplicated pre-cutoff partial assistant chunk). The bridle transcript endpoint now delegates to it (byte-identical: user/assistant, no hygiene), removing ~90 lines of duplicated reader logic. - 11 unit specs (reader hygiene/summary/pagination, monotonic counts, reconcile); tsc clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Admin slice to browse the Phase 1 chat index: a filterable list, a chat
detail with a tail-first transcript, and a "Chats" tab on the agent page.
- slices/chat: Pinia store (ChatsService + {success,data} unwrap); chats
list page + ChatListProvider (search / channel / archived / internal
filters, server pagination, Sync button, agent-scopable via :agent-id);
chat detail + ChatDetailProvider (meta header, scroll-up "load older"
paging, "show tool events" debug toggle); read-only ChatMessageBubble
(assistant markdown via bridle's renderMarkdown, collapsible summary
marker, compact tool_call/tool_result blocks).
- Sidebar "Chats" nav item (+ MessageSquare in iconMap).
- Agent detail: "Chats" tab rendering <ChatListProvider :agent-id>.
- Regenerate admin + app OpenAPI clients from the new chat endpoints
(ChatsService: getChats/getChat/getChatMessages/syncChats) — additive only.
Verified: nuxt build green, store tsc clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The hub records every session_activity signal from the agent runtime into the chat index, so the admin list updates live (no reconcile wait). - IChatGateway.recordActivity: atomic increment via updateMany (race-free, no read-modify-write), dedup by eventId (NOT-filter), create fallback with a retry on the concurrent-create unique violation. Realtime owns the monotonic counts. - bridleAgentWs.handler subscribes to `session_activity` -> recordActivity, taking agentId from the authenticated socket (never the payload). - BridleModule imports ChatModule; ChatModule forwardRefs File/AgentModule to break the Bridle -> Chat -> File -> Bridle DI cycle. - recordActivity specs (create / increment / dedup / create-race). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
CI (`bun run lint` = `eslint --fix`) failed on no-redundant-type-constituents: `insights: unknown | null` → `unknown` (unknown already subsumes null). This is the only non-auto-fixable error; the prettier violations CI was silently fixing each run are now applied to the committed chat slice + touched files, so the source is lint-clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Generate a per-chat summary + structured insights (topics, sentiment, resolved, language) via the llm slice's active Anthropic credential. - ChatInsightService: on-demand summarize + a gated cron-batch (CHAT_INSIGHT_INTERVAL_SEC; setInterval like ChatSyncService). Eligibility gate never touches empty/unchanged chats: userMessageCount >= 3, settled (lastMessageAt older than 1h), new activity since last summary (summaryAt IS NULL OR lastMessageAt > summaryAt via a Prisma field ref), non-internal, per-run cap. Reads the transcript via TranscriptReaderService (tail-truncated), tolerant JSON parse, skips when no Anthropic credential. - IChatGateway.findEligibleForInsight + saveInsights (stamps summaryAt = now). - POST /chats/:id/summarize (Owner/Admin). - Admin: chat detail shows the summary + insight badges (topics / sentiment / resolved / language) and a Summarize / Re-summarize button. Regenerated the admin + app OpenAPI clients (summarizeChat). - Specs: parseInsights, summarize, runBatch (mocked LLM). tsc + eslint clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Rate individual assistant messages in a chat transcript. - Feedback API (uses the Phase 1 ChatFeedback model): POST /chats/:id/feedback upserts the current user's rating (authorId = JWT sub, source = admin); DELETE /chats/:id/feedback/:messageId toggles it off; GET /chats/:id/feedback lists the current user's ratings. Unique per (session, message, author). - IChatGateway.upsertFeedback / deleteFeedback / listFeedbackByAuthor. - Admin: 👍/👎 on assistant messages in the transcript, highlighting the current rating; clicking the same rating again clears it. Regenerated admin + app OpenAPI clients (createChatFeedback / deleteChatFeedback / getMyChatFeedback). - 4 gateway specs (upsert / re-rate in place / toggle-off / author-scoping). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Introduce a new environment variable, CHAT_INSIGHT_INTERVAL_SEC, to configure the interval for the chat insight cron-batch. This allows for LLM summarization of chats that meet specific criteria, enhancing the chat insight functionality.
Download a chat transcript in three formats. - GET /chats/:id/export?format=json|markdown|csv. @res() streams the raw file with download headers (bypasses the {success,data} envelope, same pattern as the file export). json = all message types, markdown/csv = user/assistant + summary. formatChatExport is a pure, tested function. - Admin: JSON / MD / CSV buttons on the chat detail; download via the raw axios client (blob response) so the Bearer header is attached. Regenerated clients. - 3 formatter specs (json parseable, markdown structure, csv escaping). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ected
An invalid/expired Anthropic key surfaced as a raw 500. Now:
- callClaude maps a 401/403 to a 400 with an actionable message ("update the
Anthropic credential in Settings → LLM credentials"); other provider errors
become a 502.
- runBatch aborts on the first rejected-credential error instead of retrying
every eligible session with the same bad key.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
hey-api doesn't throw on 4xx/5xx — a failed summarize (e.g. a rejected LLM credential) resolved to null and the button just reset with no feedback. The store now surfaces the API error message, and the chat detail renders it inline in the summary block (red text). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
JWT-scoped end-user chat history, separate from the admin panel. New API endpoints under /me/chats (MyChatController) list and replay only the caller's own bridle sessions: scoped server-side to the JWT sub, ownership-checked on every id (404 not 403 on mismatch), tool events never exposed. New app 'chat' slice — list + read-only transcript with the summary gist, nav link, and /chats route guard. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ode beta sk-ant-oat… tokens (Claude subscription OAuth — what the agents run on) are rejected on the x-api-key header (401 invalid x-api-key). Ranch sent every Anthropic request that way, so chat summaries, the credential health check, and scenario generation all failed on the exact token the bots use fine. Add anthropicAuthHeaders() (mirrors the runtime's ClaudeRepository): OAuth tokens go as Authorization: Bearer + anthropic-beta oauth,claude-code; standard API keys keep x-api-key. Applied to all three callers. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… persist POST /:agentId/message[/sync] minted a fresh sync-<uuid> clientId per request, so the agent runtime — which keys access-approval and session history on that id — saw a new, unapproved user every message, and the 'send the owner your code' prompt never cleared. Verify the Bearer JWT the app already sends and use its sub (or admin), like the WS client handler; fall back to a throwaway id only for anonymous callers. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…eader (admin) Move the three meta insight badges up next to 'Summary & insights' (apart from the topic tags, which keep their own row) and capitalize each. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The default layout rendered <slot/> (NuxtPage) in both branches of a v-if/v-else keyed on isFlush. isFlush flips only on entering/leaving /agents/:id, so the branch swap tore down and recreated the page subtree — remounting the page and re-firing every useAsyncData request (doubled agent/chats/me/agents calls). Use one wrapper element with a toggled class. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…me/chats/sync Owners/admins chat under the shared bridle 'admin' channel (that's how the agent recognizes them as admin — runtime adminIds includes the literal 'admin'), not under their JWT sub. So /me/chats scoped strictly to sub showed them nothing. Resolve the caller's OWN bridle identity the same way the bridle client/HTTP handlers do — isAdmin ? 'admin' : sub — for list, detail, messages, and sync. Add POST /me/chats/sync (syncMyChats): a JWT-scoped, non-admin reconcile that pulls only the caller's own sessions from S3 into the index — a manual fallback when realtime indexing hasn't caught up. App gets a Sync button on the history list. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Round out the app "my history" view with the end-user-appropriate slice of the admin panel: - Feedback 👍/👎 — new /me/chats/:id/feedback (POST/DELETE/GET), source='app', ownership-checked, authored by the caller's real sub (not the shared 'admin' channel). Thumbs in the message bubble. - Export json/md/csv — /me/chats/:id/export, ownership-checked, capped to user/assistant/summary so tool events never leak. - Insights — the detail view now renders topics/sentiment/resolved/language (were computed but not shown to the user). Manual summarize stays admin-only (LLM cost); the auto cron summary is shown. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Move sentiment/resolved/language up next to the 'Summary & insights' label (topics stay in their own row below), so the app detail view lays out the same way as the admin one. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Implement several new API endpoints for managing chat feedback and syncing user chats: - Add `syncMyChats` for reconciling the user's chats from S3. - Introduce endpoints for listing, creating, and deleting chat feedback. - Add an export functionality for downloading chat data in various formats. Enhance type definitions to support these new features and improve the user experience in the chat detail view with updated sentiment handling.
Refactor sentiment variant mappings to enhance clarity in the chat detail view. Adjust the sentiment display values for positive, neutral, and negative sentiments. Additionally, modify the summary message to remove the "click Summarize to generate one" prompt for a cleaner user experience.
Set CHAT_INSIGHT_INTERVAL_SEC=900 (inline, non-secret env) on both the GitOps platform manifest (ArgoCD-synced from main) and the k8s/deploy manifest, so the LLM summary batch runs in prod. Unset/<=0 disables it; on-demand POST /chats/:id/summarize is unaffected. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Replaces mazda-ai's "chats" with durable, browsable chat history across all
channels (web/Bridle, Telegram, Slack). Message content stays in the agents'
append-only S3 JSONL (source of truth); this adds a queryable Postgres index
so chats are listable/filterable without fanning out to agent pods and remain
available when a pod is down.
Architecture
live from S3 via
IFileGateway.ChatSyncService) + realtimesession_activityover the always-on agent↔hub socket.Phase 1 — API core (
chatslice)ChatSession+ChatFeedbackPrisma models (FK cascade toAgent) + migration.ChatSyncServicereconciles from S3 (IFileGateway.list→data/sessions/*.jsonl);env-gated
setInterval(CHAT_SYNC_INTERVAL_SEC) + manualPOST /chats/sync.Counts are monotonic (seed/raise, never lower — compaction shrinks the file).
GET /chats,/chats/:id,/chats/:id/messages,POST /chats/sync.TranscriptReaderService(agent/file): surfaces compactionsummaryeventsas a collapsed marker instead of a silent gap, and filters synthetic loop-control
noise (continuation prompts + the duplicated pre-cutoff partial). Bridle's transcript
endpoint now delegates to it (byte-identical).
Phase 2 — Admin UI (
chatslice)"show tool events" debug toggle, collapsible summary marker, assistant markdown.
ChatsService).Phase 3 — Realtime
IChatGateway.recordActivity: atomic increment (updateMany, race-free), dedup byeventId, create fallback with a retry on the concurrent-create unique violation.Realtime owns the monotonic counts.
bridleAgentWs.handlersubscribes tosession_activity→recordActivity, takingagentIdfrom the authenticated socket (never the payload).forwardRef.Phase 4 — Insights cron-batch
ChatInsightService: LLM summary + structured insights (topics / sentiment / resolved /
language) written onto ChatSession, driven by the same env-gated setInterval
(CHAT_INSIGHT_INTERVAL_SEC). Eligibility gate so we never spend tokens on forgotten/empty
chats: userMessageCount ≥ 3, settled (cooldown ≥ 1h), new activity since summaryAt,
channel ≠ internal, per-run batch cap. Manual POST /chats/:id/summarize kept; a rejected
credential (401/403) aborts the batch instead of hammering every session.
Phase 5 — Feedback + Export (web/admin)
ChatFeedback upsert, unique per (session, message, author); POST/DELETE/GET /chats/:id/feedback (source='admin', authorId = jwt.sub) — admin 👍/👎 in the bubble.
formatChatExport → json / markdown / csv (CSV-escaped); GET /chats/:id/export uses
@res() to bypass the {success,data} interceptor and stream the file with download
headers. Telegram inline-keyboard feedback (→ session_feedback over the agent socket)
deferred as a follow-up.
Phase 6 — App "my history" (app/slices/chat)
End-user-facing Nuxt-layer slice: list + read-only transcript with the summary gist,
insights, feedback, and export; nav link + /chats route guard. MyChatController at
/me/chats is JWT-only (any authenticated user), scoped server-side to the caller's OWN
bridle sessions — every id ownership-checked (404, not 403, so we never confirm someone
else's chat), tool events never exposed (capped types even in json export). Identity mirrors
the bridle client/HTTP handlers: owners/admins resolve to the shared admin channel (the
runtime recognizes them via adminIds containing the literal admin), everyone else their
JWT sub; feedback is authored by the real sub, not the channel. Self-service POST /me/chats/sync (syncForExternalUser) reconciles only the caller's own sessions — a manual
fallback when realtime indexing hasn't caught up.
Hardening (beyond the plan)
fix(llm) — anthropicAuthHeaders: sk-ant-oat… subscription OAuth tokens (what the agents
run on) go as Authorization: Bearer + anthropic-beta oauth,claude-code, not x-api-key
(a hard 401). Applied to chat summaries, the credential health check, and the scenario
generator — so all three work on the exact credential the bots use.
fix(bridle) — HTTP chat (POST /:agentId/message[/sync]) verifies the Bearer JWT the app
already sends and uses its sub/admin as clientId, instead of minting a throwaway
sync- per request; the runtime keys access-approval and session history on that id, so
a stable identity stops the "send the owner your code" prompt firing on every message.
fix(app) — default layout renders a single with a toggled wrapper class instead of
duplicating it across v-if/v-else on isFlush; the branch swap was remounting the page and
re-firing every useAsyncData request when navigating in/out of /agents/:id.
On the "source of truth"
Compaction windows long chats down (the JSONL is a rolling window, not a full ledger).
Product decision: history = gist, not a verbatim log — compaction is untouched and
the summary is surfaced instead.
Verification
tsc --noEmitclean. Unit specs: chat gateway (reconcile monotonic counts;recordActivity create/increment/dedup/create-race), transcript reader (hygiene,
summary, pagination), reconcile.
POST /chats/syncindexes S3 sessions; emittingsession_activityupdates the row live (count/dedup/freshness) with no Sync.Deploy notes
migrate:prod) — additive (two new tables, FKs, indexes).CHAT_SYNC_INTERVAL_SECto enable periodic reconcile (else chats index only onmanual Sync).
reconcile still populates the list.