Algolia-Central

Documentation/09-Session-State.md

09 — Session State

Last updated: 2026-04-17. Schema verified from lib/search/redis.ts:20.


The SessionState interface

File: lib/search/redis.ts:20 Storage: Upstash Redis REST (one key per sessionId, prefixed rc2:)

export interface SessionState {
  // 4 Onion Signals (core qualification)
  stack?: string | null;                  // "React", "Shopify", "Vue"
  scale?: string | null;                  // "startup", "enterprise", "1M+ records"
  role?: string | null;                   // "developer", "merchandiser", "executive"
  pain?: string | null;                   // "latency", "relevance", "complexity"

  // 4 Business Entities (enrichment)
  brand?: string | null;                  // Company name
  industry?: string | null;               // "e-commerce", "SaaS", "media"
  product?: string | null;                // "Search", "Recommend", "NeuralSearch"
  architecture_concepts?: string[] | null; // array of tech concepts

  // Discovery tracking
  asked_signals?: string[];               // signals already asked (no-repeat)
  turn_count?: number;                    // conversation turn counter
  active_persona?: 'maverick' | 'elena' | 'bruno';

  // F-045 — Conversation timeline written every Maverick/specialist turn
  conversation_turns?: ConversationTurn[];

  // Handoff state (persists between handshake + execute calls)
  pendingHandoff?: {
    expandedQuestion: string;
    target: 'elena' | 'bruno';
  } | null;

  // Metadata
  locked_count?: number;                  // computed
  last_updated?: string;                  // ISO timestamp
}

interface ConversationTurn {
  turn: number;                          // turn_count at time of emission
  role: 'user' | 'assistant';
  content: string;
  persona?: 'maverick' | 'elena' | 'bruno';  // assistant turns only
  timestamp: string;                     // ISO
  extracted_attributes?: Record<string, any>;
}

Lifecycle

First turn

  • history.length === 0 at orchestrator.ts:75
  • Triggers resetSessionState(sessionId) — clears any prior state
  • Then reloads via getSessionState() which returns defaults

Per turn (Maverick)

  1. Load: getSessionState(sessionId) — ~41–53ms (warm)
  2. Merge: updateSessionState(sessionId, newSignals) after signal extraction — uses || operator (F-026 issue — never clears wrong values)
  3. Track: addAskedSignal(sessionId, signal) when discovery emits — appends to asked_signals[]
  4. Count: countLockedSignals(state) — counts non-null signal fields (treats "wrong value" as locked — also F-026 issue)
  5. Increment: updateSessionState(sessionId, {turn_count: +1}) — final step
  6. Append timeline (F-045): appendConversationTurns(sessionId, [userTurn, assistantTurn]) — writes both messages to conversation_turns
  7. Persist handoff: if handoff detected, updateSessionState(sessionId, {pendingHandoff, active_persona}) — carries expandedQuestion to the subsequent consent-execute call

Per turn (Specialist)

  1. Load: same — also reads pendingHandoff.expandedQuestion for execute triggers
  2. No writes to signals — specialist doesn't extract new signals, just consumes
  3. Writes telemetry only via persistSessionTelemetry()
  4. Append timeline (F-045): appendConversationTurns(sessionId, [userTurn, specialistTurn]) — specialist response joins the timeline with persona: 'elena' | 'bruno'

Redis access layer

File: lib/search/redis.ts

Function Purpose Timing
getSessionState(id) Load with defaults ~41–53ms
getDefaultSessionState() All fields null + empty arrays synchronous
updateSessionState(id, partial) Merge into existing ~40ms
countLockedSignals(state) Count non-null signal fields synchronous, O(1)
addAskedSignal(id, signal) Append to asked_signals[] ~40ms
resetSessionState(id) Clear ~40ms
deleteSessionState(id) Remove key ~40ms
incrementCounter(name, ttl) General-purpose counter ~40ms
setCache(name, value, ttl) / getCache(name) L1-style cache ~40ms
appendTurnsPure(existing, new) F-045 Pure append helper (no Redis) synchronous
appendConversationTurns(id, turns) F-045 Read-merge-write turns to timeline ~80ms (HGETALL + HSET)

Signal lifecycle example (run 001–006 observed)

Turn User input Locked signals (cumulative) Dossier length
0 "Our customers search 'casual summer dress under 80'..." 3 207
1 "I'm a merchandiser, but I also understand deep technology..." 5 259
2 "We have roughly about half a million products." 6 263
3 "We have a custom in-house platform. We are planning to go to Shopify..." 8 271
4 "Yeah, several of them: - Mobile experience..." 8 (QUALIFIED) 271

Note dossier growth: +52 chars → +4 chars → +8 chars. Signals ARE being persisted. F-034: but Maverick prompts don't reference them strongly enough in the response.


Handoff state carry-forward

Critical mechanism for the two-call specialist flow:

  1. Maverick's qualification turn detects handoff → calls updateSessionState(sessionId, {pendingHandoff: {expandedQuestion, target}, active_persona: target}).
  2. Client receives event: handoff_proposal with requiresConsent: true, shows "Bring in expert" button.
  3. User clicks → client re-POSTs /api/search with consent: true, persona: target.
  4. Backend enters consent branch (api-src/search.ts:193) → igniteSpecialist({trigger:'execute'})buildAgentStudioMessage reads sessionState.pendingHandoff.expandedQuestion and uses THAT as the effective query.

Without Redis persistence of pendingHandoff, the execute call would have nothing meaningful to ask the specialist.


Planned additions (Step 3+)

For F-033 (customer-story dedup):

referencedCustomers?: string[];  // [lowercased customer slugs already mentioned in this session]

For F-036 (anti-topic-repeat):

topicsCoveredInPreviousTurns?: string[];  // ['neural search', 'natural language understanding', ...]

For F-026 (pushback detector):

revisionCandidates?: string[];  // [signal fields that user contradicted — prioritize for revision in next discovery question]

These extend SessionState, read+write through the same updateSessionState path.


Known issues

F# Issue
F-026 Merge logic (\|\| operator) + countLockedSignals treat wrong values as valid
F-033 No referencedCustomers[] field → same customer namedropped 3× per session
F-036 No topicsCoveredInPreviousTurns[] → "neural search, natural language" repeat

Cross-references

  • 03-Files-Referenceredis.ts file entry
  • 04-Functions-ReferencegetSessionState, updateSessionState, countLockedSignals
  • 02-Data-Flow — where session is loaded and written in the pipeline
  • 10-Known-Issues — F-026, F-033, F-036