Multi-Turn Conversation State
The SDK can carry conversation context across turns so the model sees prior user and assistant messages without the caller re-sending full history on every request. This document describes how that state is keyed, configured, expired, and cleared.Implementation:src/lib/conversation-state-store.ts(store + backends),src/funcs/call-model-with-state.ts(turn-pipeline entry point). Integration tests:tests/unit/multi-turn-integration.test.ts.
Quick start
callModelWithState(client, { ... }) from src/funcs/call-model-with-state.ts.
How state is keyed
Every conversation is identified by a caller-suppliedconversationId string:
- The id is the only lookup key. Each turn loads the state document under that id, appends the new input and the response output to the stored message history, and persists the result back under the same id before the response is returned.
- Choose ids that are stable for the lifetime of the conversation — e.g. a
session id,
user-<id>, or a UUID minted when the chat starts. - Parallel conversations are fully isolated: two ids never share state, even when their turns interleave or run concurrently on the same store.
- The id guard: if the turn pipeline ever produces a state document whose id
differs from the accessor’s bound id (e.g. the first turn creates a
placeholder state internally), the store rebinds it to your
conversationIdon save. State can never strand under an id you don’t own.
callModel without a
conversationId behaves exactly as before and touches no store.
The state document
CorruptedStateError (see “Missing or corrupted
state” below).
Configuration
There are no environment variables or global config flags for multi-turn
state. Configuration is explicit per store instance — nothing changes unless
the caller constructs a store and passes it to
callModelWithState.
Backends
Two zero-dependency backends ship with the SDK:InMemoryConversationStateBackend— process-localMap. Right for tests and ephemeral single-process use. Documents are deep-copied on read/write so callers can’t mutate stored state by aliasing. Implementslist(), so store-wideexpire()works.FileConversationStateBackend— JSON-file-per-conversation under a directory (Node.js only). Ids are sanitized to prevent path traversal. Useful for local durable execution without Redis.
ConversationStateBackend interface:
Expiry and cleanup
- TTL expiry (read-through): with
ttlMsset,store.get(id)returnsnullfor idle-expired documents. When a turn then arrives for that id the pipeline starts a fresh conversation under the same id — the model does not see the expired history. - Store-wide sweep:
store.expire()removes every stale conversation (requires the backend to implementlist()) and returns the removed ids.store.expire(['id1', 'id2'])sweeps only the given ids. Run this on a timer if you want storage reclaimed rather than just hidden. - Explicit deletion:
store.clear(id)deletes one conversation immediately, regardless of TTL. The next turn for that id starts fresh. Clearing one conversation never affects siblings.
Missing or corrupted state
To recover a corrupted conversation, call
store.clear(id) and let the next
turn recreate it (or restore the document from a backup and re-put it).
Testing recipes
The integration tests intests/unit/multi-turn-integration.test.ts show the
patterns, all without a live API key:
- 3+ turn context: queue mocked
betaResponsesSendresponses and assert that turn N’s API request input contains every earlier turn’s user input and assistant output, in order. - Parallel isolation: interleave turns across two
conversationIds on one store and assert neither request input mentions the other conversation. - Expiry: construct the store with a short
ttlMs(or a fakenowclock), sleep past the TTL, and assert the next turn starts fresh. - Corruption: poison the backend (or the JSON file for
FileConversationStateBackend) and assertCorruptedStateError.