06-ai-tucanwave: W3filled28 citations

AI Chat Sessions — Conversation State and Memory

Audiences: developer, internal

AI Chat Sessions — Conversation State and Memory

Every TUCAN turn is anchored to a ChatSession Prisma 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 ChatMessage rows with prompt tokens, completion tokens, reasoning tokens, cost, and the actual model slug that served it.
  • BillTucanUsageRecord is an append-only sibling of ChatMessage optimized for consumption aggregation, with a is_byok flag 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.
  • Resumesession.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 TucanUsageRecord with a costUsd column, 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_ai Prisma database (separate from clinical main). API surface is REST under /api/v1/agent/sessions. The metadata JSON 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 than any casts.
  • Internal (ops / support): A "what did this user do?" query becomes one SQL select on chat_sessions WHERE user_id = ? — joined to chat_messages and tucan_usage_records for the full audit trail. Multi-tenancy is enforced by the organizationId column 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:

diagram
┌─────────────────────────────────────────────────────────────┐
│  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:

SliceOwnerPurpose
lastCreatedResourcesdispatchertool-result IDs from the most recent turn for "what did I just create" UI
lastCreatedResourcesAtdispatcherISO timestamp for the above
executionPlanorchestrator L1per-turn intent-planner output
taskStateManifestorchestratorper-task running / suspended / done flags
dispatchBypassdispatchertrivial-bypass flag for last turn
dispatchReportreporterlast reporter HTML / Markdown narration
suspendedWorkflowsdispatcher{taskId: {runId, stepId, workflowId, toolId, …}} for Mastra resume
modechat-orchestrator'chat' once markSessionChatMode runs (routes resume to chat-orchestrator instead of legacy dispatcher)
localesession-createfallback locale (dto-level wins per [[tucan-context-builder]])
lastQuickActionsquick-actionsanti-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

ComponentChoiceWhy
SchemaSeparate 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 generationUUID v4 (@default(uuid()))Tenant-portable, no leak of sequential IDs.
metadata storageJson? Prisma columnSchema flexibility for evolving dispatcher state without weekly migrations.
Typed metadata accesspickCreatedResources / pickExecutionPlan / pickIsoString helpers in session.service.tsBackend 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-tenancyorganizationId column on every table + composite indexesTenant isolation interceptor scopes every query; no row leak between clinics.
Usage trackingTucanUsageRecord (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 accountingis_byok Boolean? column (forward-only, NULL for pre-migration rows)Vendor COGS can be filtered to is_byok IS NULL OR is_byok = false.
Cascademessages ChatMessage[] with onDelete: CascadeDeleting a session deletes its messages + usage records atomically.
Memory hydrationMastra mastraThreadId (optional) for Mastra-memory-backed sessions; assembleTurns for legacyDual-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, metadata JSON 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:

  1. Initial turn: POST /api/v1/agent/sessionsddx-api/src/ai/tucan/session/session.controller.ts:230SessionService.create writes a ChatSession row with agentFramework: 'tucan', organizationId, userId, and (if provided) visitId / linkedEntityType.
  2. First message: POST /api/v1/agent/sessions/:id/messagessession.controller.ts:406AgentDispatcherService.processMessage (see [[tucan-assistant]]).
  3. Inside the dispatcher: messageService.createUserMessage writes a ChatMessage{ role: 'user' } row; agentPublisher.publishAgentStart fires the first SSE event.
  4. Tool execution: each tool call produces one or more ChatMessage{ role: 'tool' } rows with toolCalls JSON.
  5. Assistant reply: ChatMessage{ role: 'assistant' } row with promptTokens, completionTokens, reasoningTokens (must be 0 when dudoxx-gemma is used), costUsd, modelSlug.
  6. Usage rollup: TucanUsageRecord row is INSERTed 1:1 with the assistant message — denormalized for the consumption controller.
  7. SSE close: agent:complete event emitted via ddx-api/src/ai/tucan/sse/event-bus.service.ts.
  8. Suspend: if the turn paused, session.metadata.suspendedWorkflows[taskId] is patched via SessionService.updateSessionData (session.service.ts:1115) and workflow:suspended SSE event is published.
  9. Resume (next day, possibly different device): POST /:id/messages is routed by ChatOrchestratorService.resume (ddx-api/src/ai/tucan/orchestrator/chat-orchestrator.service.ts:152) which reads suspendedWorkflows, calls Mastra workflow.createRun({ runId }) and run.resume({ step, resumeData }).
  10. Hydration on portal open: GET /:id returns HydrationJson (session.controller.ts:261) with session + messages + linked-entity tuple; the frontend TucanSessionProvider rehydrates from this single response.

Implicated Code

  • ddx-api/prisma-ai/schema.prisma:18model ChatSession with 30+ columns including metadata Json?, agentFramework, mastraThreadId, and four indexed entity-link FK columns.
  • ddx-api/prisma-ai/schema.prisma:70model ChatMessage with per-row token counts, reasoningTokens, costUsd, modelSlug, renderedSections (L5 persistency slice).
  • ddx-api/prisma-ai/schema.prisma:101model TucanUsageRecord — append-only billing-shaped sibling of ChatMessage, 1:1 by chat_message_id, CASCADE delete.
  • ddx-api/prisma-ai/schema.prisma:114is_byok column (forward-only; legacy rows are NULL; writers populate from MetricsMetadata.isByok).
  • ddx-api/prisma-ai/schema.prisma:127enum MessageRole (user, assistant, system, tool).
  • ddx-api/prisma-ai/schema.prisma:226enum 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:216SessionService.create(...).
  • ddx-api/src/ai/tucan/session/session.service.ts:406findById(sessionId, userId, organizationId) — tenant-scoped hydration entry.
  • ddx-api/src/ai/tucan/session/session.service.ts:745update(...) for title / metadata patches.
  • ddx-api/src/ai/tucan/session/session.service.ts:940delete(...) cascades into messages + usage records.
  • ddx-api/src/ai/tucan/session/session.service.ts:1008createdResources / createdResourcesAt typed metadata accessors.
  • ddx-api/src/ai/tucan/session/session.service.ts:1115updateSessionData(...) — atomic Prisma-side metadata patch used by the dispatcher for streamingCompleted, suspendedWorkflows, etc.
  • ddx-api/src/ai/tucan/session/session.repository.ts:11 — class SessionRepository — the typed Prisma adapter (findById, findByEntityLink, getMessageCounts, getMetricsAggregates, getInfoContainers, getLastQuickActions).
  • ddx-api/src/ai/tucan/session/session-memory.service.tsassembleTurns (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 for GET /:id/snapshot.

Operational Notes

  • Multi-tenant scoping is enforced in the repository, not the controller. Every findBy* takes organizationId and adds it to the WHERE clause. Bypassing the repository to do raw Prisma calls in the service is a tenant-isolation violation (see ddx-api/CLAUDE.md §Forbidden Patterns).
  • metadata writes must use updateSessionData — direct prisma.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_tokens must be 0 when the model is dudoxx-gemma. If a TucanUsageRecord row has reasoningTokens > 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 + TucanUsageRecord rows. 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/end vs DELETE /:idend marks the session ended but preserves rows; delete cascades.
  • session.modelName / provider / temperature / maxTokens are historical fields. Current dispatch resolves the model from context.llmConfig per-turn (set by LLMConfigFactory.buildDudoxxModel). These columns are kept for legacy hydration but not authoritative.
  • [[tucan-assistant]] — orchestration entry point that creates / appends to chat-session rows.
  • [[tucan-dispatcher]] — dispatcher pipeline that writes suspendedWorkflows and streamingCompleted.
  • [[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]] — TucanUsageRecord aggregation + 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.md shard MEMORY_DDX_API — session-related defects history.
    AI Chat Sessions — Conversation State and Memory — Dudoxx Docs | Dudoxx