AI Chat Sessions — Conversation State and Memory
Audiences: developer, internal
Every TUCAN turn is anchored to a
ChatSessionPrisma row — the durable identity of "a conversation" — with all messages, tool calls, costs, linked clinical entities, and the suspended-workflow handle attached.
Business Purpose
A chat session is the unit of clinical AI engagement that Dudoxx can:
- Audit — every assistant turn is replayable from
ChatMessagerows with prompt tokens, completion tokens, reasoning tokens, cost, and the actual model slug that served it. - Bill —
TucanUsageRecordis an append-only sibling ofChatMessageoptimized for consumption aggregation, with ais_byokflag so Bring-Your-Own-Key turns are excluded from the billable rollup. - Link — a session can be bound to a Visit, Intake, Appointment, or
Document via indexed FK columns (
visit_id,intake_id,appointment_id,document_id) — so "the AI conversation that produced this prescription" is one join away from the clinical record. - Resume —
session.metadata.suspendedWorkflows[taskId]carries the Mastra workflow's{runId, stepId, workflowId, toolId, ...}handle, so a HITL pause survives a browser refresh, a different device, or the TUCAN process restart.
For the doctor, the session is just "the conversation thread." For Dudoxx the vendor, it is the transactional boundary that ties AI cost to clinical revenue.
Audiences
- Investor: Per-session cost accounting is built into the schema, not
bolted on. Every chat message has its own row in
TucanUsageRecordwith acostUsdcolumn, so unit economics are observable per-turn. BYOK turns are flagged so the vendor's COGS is not contaminated. - Developer / partner: Sessions live in the
ddx_api_aiPrisma database (separate from clinical main). API surface is REST under/api/v1/agent/sessions. ThemetadataJSON column is the only surface allowed to evolve without a migration — typed slices (outcomes,metricsTimeline,sidePanel,suspendedWorkflows) are read through type-narrowing helpers (pickCreatedResources,pickExecutionPlan, …) rather thananycasts. - Internal (ops / support): A "what did this user do?" query becomes
one SQL select on
chat_sessions WHERE user_id = ?— joined tochat_messagesandtucan_usage_recordsfor the full audit trail. Multi-tenancy is enforced by theorganizationIdcolumn on every row. - Clinical buyer: The portal's "Conversations" tab is a window into this schema. A doctor can pin a session to a patient and continue it across visits — the session row is the only thing that needs to persist.
Architecture
A chat session is a 3-tier object:
┌─────────────────────────────────────────────────────────────┐
│ SessionController (HTTP / REST under /api/v1/agent/sessions)│
│ · GET / ─ list by user/org │
│ · POST / ─ create │
│ · GET /:id ─ hydrate (HydrationJson) │
│ · PATCH /:id ─ rename / metadata patch │
│ · POST /:id/messages ─ dispatch a turn │
│ · POST /:id/stop ─ abort in-flight turn │
│ · GET /:id/dispatch-status │
│ · GET /:id/snapshot ─ point-in-time materialized view│
│ · POST /:id/link-patient │
│ · GET /:id/events ─ SSE stream for this session │
└─────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ SessionService (domain logic, tenant isolation) │
│ · uses MessageService for ChatMessage CRUD │
│ · uses SessionRepository for typed Prisma calls │
│ · uses SessionMemoryService for assemble-turns / elision │
│ · uses SnapshotService for materialized point-in-time views │
└─────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ Prisma 7 (ddx_api_ai schema) │
│ · ChatSession │
│ · ChatMessage │
│ · TucanUsageRecord (1:1 with ChatMessage, billing-shaped) │
│ · LinkedEntityType (enum: visit / intake / appointment / …)│
└─────────────────────────────────────────────────────────────┘
The ChatSession.metadata JSON column is the one mutable evolvable
surface. Typed slices today include:
| Slice | Owner | Purpose |
|---|---|---|
lastCreatedResources | dispatcher | tool-result IDs from the most recent turn for "what did I just create" UI |
lastCreatedResourcesAt | dispatcher | ISO timestamp for the above |
executionPlan | orchestrator L1 | per-turn intent-planner output |
taskStateManifest | orchestrator | per-task running / suspended / done flags |
dispatchBypass | dispatcher | trivial-bypass flag for last turn |
dispatchReport | reporter | last reporter HTML / Markdown narration |
suspendedWorkflows | dispatcher | {taskId: {runId, stepId, workflowId, toolId, …}} for Mastra resume |
mode | chat-orchestrator | 'chat' once markSessionChatMode runs (routes resume to chat-orchestrator instead of legacy dispatcher) |
locale | session-create | fallback locale (dto-level wins per [[tucan-context-builder]]) |
lastQuickActions | quick-actions | anti-repetition guard (see [[tucan-assistant]] §Architecture) |
The MessageRole enum constrains who can author a row: user, assistant,
system, tool. Tool-call rounds are recorded as separate rows so the
orchestrator can replay them.
Tech Stack & Choices
| Component | Choice | Why |
|---|---|---|
| Schema | Separate Prisma schema ddx_api_ai (not the clinical ddx_api_main) | AI conversation data is large + high-churn; isolating it prevents a chat-message INSERT spike from blocking clinical writes. |
| ID generation | UUID v4 (@default(uuid())) | Tenant-portable, no leak of sequential IDs. |
metadata storage | Json? Prisma column | Schema flexibility for evolving dispatcher state without weekly migrations. |
| Typed metadata access | pickCreatedResources / pickExecutionPlan / pickIsoString helpers in session.service.ts | Backend any ban (CLAUDE.md): narrowing helpers + Zod parse instead of casts. |
| Linked entity (Visit / Intake / Appointment / Document) | Indexed FK columns + enum (linkedEntityType, linkedEntityId, visitId, intakeId, …) | Replaces the older metadata-JSON lookup pattern; queries like "all sessions for visit X" are O(log n) on a B-tree index. |
| Multi-tenancy | organizationId column on every table + composite indexes | Tenant isolation interceptor scopes every query; no row leak between clinics. |
| Usage tracking | TucanUsageRecord (1:1 with ChatMessage, ON DELETE CASCADE) | Hot aggregation queries (SUM(cost_usd) GROUP BY user_id) don't touch the wide ChatMessage row. |
| BYOK accounting | is_byok Boolean? column (forward-only, NULL for pre-migration rows) | Vendor COGS can be filtered to is_byok IS NULL OR is_byok = false. |
| Cascade | messages ChatMessage[] with onDelete: Cascade | Deleting a session deletes its messages + usage records atomically. |
| Memory hydration | Mastra mastraThreadId (optional) for Mastra-memory-backed sessions; assembleTurns for legacy | Dual-agent framework — `agentFramework: 'tucan' |
Alternatives rejected:
- Single sessions table in the main DB — high write rate from streaming assistant deltas would cause hot-page contention on clinical workloads.
- NoSQL (Mongo / Dynamo) for chat history — the audit + cost-aggregation queries are relational by nature. Keep it in Postgres, indexed.
- Storing all session state as JSON only — defeats indexing on
visit_id/appointment_id/linkedEntityType. The compromise: typed FK columns for queryable axes,metadataJSON for everything else.
Data Flow
Business outcome: A doctor opens a patient's chart, asks TUCAN three questions across two days, and when she opens the portal a week later the conversation thread is exactly as she left it — with messages, tool calls, costs, and the linked Visit ID intact.
Technical mechanism:
- Initial turn:
POST /api/v1/agent/sessions→ddx-api/src/ai/tucan/session/session.controller.ts:230→SessionService.createwrites aChatSessionrow withagentFramework: 'tucan',organizationId,userId, and (if provided)visitId/linkedEntityType.- First message:
POST /api/v1/agent/sessions/:id/messages→session.controller.ts:406→AgentDispatcherService.processMessage(see [[tucan-assistant]]).- Inside the dispatcher:
messageService.createUserMessagewrites aChatMessage{ role: 'user' }row;agentPublisher.publishAgentStartfires the first SSE event.- Tool execution: each tool call produces one or more
ChatMessage{ role: 'tool' }rows withtoolCallsJSON.- Assistant reply:
ChatMessage{ role: 'assistant' }row withpromptTokens,completionTokens,reasoningTokens(must be 0 whendudoxx-gemmais used),costUsd,modelSlug.- Usage rollup:
TucanUsageRecordrow is INSERTed 1:1 with the assistant message — denormalized for the consumption controller.- SSE close:
agent:completeevent emitted viaddx-api/src/ai/tucan/sse/event-bus.service.ts.- Suspend: if the turn paused,
session.metadata.suspendedWorkflows[taskId]is patched viaSessionService.updateSessionData(session.service.ts:1115) andworkflow:suspendedSSE event is published.- Resume (next day, possibly different device):
POST /:id/messagesis routed byChatOrchestratorService.resume(ddx-api/src/ai/tucan/orchestrator/chat-orchestrator.service.ts:152) which readssuspendedWorkflows, calls Mastraworkflow.createRun({ runId })andrun.resume({ step, resumeData }).- Hydration on portal open:
GET /:idreturnsHydrationJson(session.controller.ts:261) with session + messages + linked-entity tuple; the frontendTucanSessionProviderrehydrates from this single response.
Implicated Code
ddx-api/prisma-ai/schema.prisma:18—model ChatSessionwith 30+ columns includingmetadata Json?,agentFramework,mastraThreadId, and four indexed entity-link FK columns.ddx-api/prisma-ai/schema.prisma:70—model ChatMessagewith per-row token counts,reasoningTokens,costUsd,modelSlug,renderedSections(L5 persistency slice).ddx-api/prisma-ai/schema.prisma:101—model TucanUsageRecord— append-only billing-shaped sibling ofChatMessage, 1:1 bychat_message_id, CASCADE delete.ddx-api/prisma-ai/schema.prisma:114—is_byokcolumn (forward-only; legacy rows are NULL; writers populate fromMetricsMetadata.isByok).ddx-api/prisma-ai/schema.prisma:127—enum MessageRole(user,assistant,system,tool).ddx-api/prisma-ai/schema.prisma:226—enum LinkedEntityType(visit, intake, appointment, document, …).ddx-api/src/ai/tucan/session/session.controller.ts:130—@Controller('agent/sessions')— REST surface root.ddx-api/src/ai/tucan/session/session.controller.ts:154—@Get()list.ddx-api/src/ai/tucan/session/session.controller.ts:230—@Post()create.ddx-api/src/ai/tucan/session/session.controller.ts:261—@Get(':id')hydrate.ddx-api/src/ai/tucan/session/session.controller.ts:406—@Post(':id/messages')dispatch turn.ddx-api/src/ai/tucan/session/session.controller.ts:499—@Post(':id/stop')abort in-flight.ddx-api/src/ai/tucan/session/session.controller.ts:546—@Get(':id/snapshot')materialized view.ddx-api/src/ai/tucan/session/session.controller.ts:680—@Post(':id/link-patient')post-hoc patient binding.ddx-api/src/ai/tucan/session/session.controller.ts:745—@Get(':id/events')SSE stream gateway.ddx-api/src/ai/tucan/session/session.service.ts:216—SessionService.create(...).ddx-api/src/ai/tucan/session/session.service.ts:406—findById(sessionId, userId, organizationId)— tenant-scoped hydration entry.ddx-api/src/ai/tucan/session/session.service.ts:745—update(...)for title / metadata patches.ddx-api/src/ai/tucan/session/session.service.ts:940—delete(...)cascades into messages + usage records.ddx-api/src/ai/tucan/session/session.service.ts:1008—createdResources / createdResourcesAttyped metadata accessors.ddx-api/src/ai/tucan/session/session.service.ts:1115—updateSessionData(...)— atomic Prisma-side metadata patch used by the dispatcher forstreamingCompleted,suspendedWorkflows, etc.ddx-api/src/ai/tucan/session/session.repository.ts:11— classSessionRepository— the typed Prisma adapter (findById,findByEntityLink,getMessageCounts,getMetricsAggregates,getInfoContainers,getLastQuickActions).ddx-api/src/ai/tucan/session/session-memory.service.ts—assembleTurns(role-pairing assembly; DEF-104 mandates synthesis on orphaned rows, never guard-skip — see [[reminders]]).ddx-api/src/ai/tucan/session/snapshot.service.ts— point-in-time materialized view assembly forGET /:id/snapshot.
Operational Notes
- Multi-tenant scoping is enforced in the repository, not the
controller. Every
findBy*takesorganizationIdand adds it to the WHERE clause. Bypassing the repository to do raw Prisma calls in the service is a tenant-isolation violation (seeddx-api/CLAUDE.md§Forbidden Patterns). metadatawrites must useupdateSessionData— directprisma.chatSession.update({ data: { metadata: ... } })will overwrite sibling slices. The service does a read-merge-write under a small in-process lock.- Role-pairing assembly must synthesize on orphaned rows (DEF-104,
session-memory.service.ts::assembleTurns). Silent guard-skip on a missing tool-result row causes the assistant message to be dropped on reload — there is a regression test for this; do not weaken it. reasoning_tokensmust be 0 when the model isdudoxx-gemma. If aTucanUsageRecordrow hasreasoningTokens > 0, the thinking-off invariant has regressed somewhere in the LiteLLM → provider chain.agentFramework: 'mastra-orchestrator'is the path the chat-orchestrator (L1-L5 layered) will eventually use for ALL sessions. Today,markSessionChatMode(chat-orchestrator.service.ts:340) flips a session into chat-mode opportunistically;agentFramework='tucan'remains the production default.- Cascade deletes are loud. Deleting a session deletes all
ChatMessage+TucanUsageRecordrows. There is no soft-delete. If GDPR removal is requested, use the cascade; if audit retention is required for that user, copy out first. POST /:id/endvsDELETE /:id—endmarks the session ended but preserves rows;deletecascades.session.modelName / provider / temperature / maxTokensare historical fields. Current dispatch resolves the model fromcontext.llmConfigper-turn (set byLLMConfigFactory.buildDudoxxModel). These columns are kept for legacy hydration but not authoritative.
Related Topics
- [[tucan-assistant]] — orchestration entry point that creates / appends to chat-session rows.
- [[tucan-dispatcher]] — dispatcher pipeline that writes
suspendedWorkflowsandstreamingCompleted. - [[tucan-orchestrator]] — L1-L5 layered execution that owns
executionPlan,outcomes,sidePanel,metricsTimeline,renderedSections. - [[tucan-context-builder]] — context assembly that reads session + patient + visit on every turn.
- [[tucan-consumption-pricing]] —
TucanUsageRecordaggregation + per-step cost rollup. - [[sse-event-engine]] (W1) — canonical SSE contract used by
/:id/events. docs/claude-context/CLAUDE_DATABASE.md— full Prisma schema reference (link, do not duplicate).MEMORY.mdshardMEMORY_DDX_API— session-related defects history.