Skip to content

feat(chat): chat history — index, admin UI, realtime (Phases 1–3)#8

Merged
maksymhryzodub-prog merged 21 commits into
mainfrom
feat/chat-history
Jul 16, 2026
Merged

feat(chat): chat history — index, admin UI, realtime (Phases 1–3)#8
maksymhryzodub-prog merged 21 commits into
mainfrom
feat/chat-history

Conversation

@maksymhryzodub-prog

@maksymhryzodub-prog maksymhryzodub-prog commented Jul 15, 2026

Copy link
Copy Markdown
Contributor

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

  • Postgres = index only (metadata + monotonic counts). Transcript content is read
    live from S3 via IFileGateway.
  • Index populated two ways: S3 reconciliation (ChatSyncService) + realtime
    session_activity over the always-on agent↔hub socket.
  • The agent runtime stays host-agnostic (no DB dependency) — see companion runtime PR.

Phase 1 — API core (chat slice)

  • ChatSession + ChatFeedback Prisma models (FK cascade to Agent) + migration.
  • ChatSyncService reconciles from S3 (IFileGateway.listdata/sessions/*.jsonl);
    env-gated setInterval (CHAT_SYNC_INTERVAL_SEC) + manual POST /chats/sync.
    Counts are monotonic (seed/raise, never lower — compaction shrinks the file).
  • Endpoints (Owner/Admin): GET /chats, /chats/:id, /chats/:id/messages, POST /chats/sync.
  • Shared TranscriptReaderService (agent/file): surfaces compaction summary events
    as 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 (chat slice)

  • Chats list: search / channel / archived / internal filters, server pagination, Sync.
  • Chat detail: meta header, tail-first transcript with scroll-up "load older",
    "show tool events" debug toggle, collapsible summary marker, assistant markdown.
  • Sidebar "Chats" nav + a "Chats" tab on the agent detail page (scoped by agentId).
  • Regenerated admin + app OpenAPI clients (ChatsService).

Phase 3 — Realtime

  • IChatGateway.recordActivity: atomic increment (updateMany, race-free), dedup by
    eventId, create fallback with a retry on the concurrent-create unique violation.
    Realtime owns the monotonic counts.
  • bridleAgentWs.handler subscribes to session_activityrecordActivity, taking
    agentId from the authenticated socket (never the payload).
  • Fixed a DI cycle (Bridle → Chat → File → Bridle) with 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 --noEmit clean. Unit specs: chat gateway (reconcile monotonic counts;
    recordActivity create/increment/dedup/create-race), transcript reader (hygiene,
    summary, pagination), reconcile.
  • Live e2e vs local Postgres: POST /chats/sync indexes S3 sessions; emitting
    session_activity updates the row live (count/dedup/freshness) with no Sync.

Deploy notes

  • Run the migration (migrate:prod) — additive (two new tables, FKs, indexes).
  • Set CHAT_SYNC_INTERVAL_SEC to enable periodic reconcile (else chats index only on
    manual Sync).
  • Realtime updates require agents on runtime ≥ 0.27.0 (companion PR); without it,
    reconcile still populates the list.

maksymhryzodub-prog and others added 21 commits July 15, 2026 15:16
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>
@maksymhryzodub-prog maksymhryzodub-prog merged commit 245c399 into main Jul 16, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant