Skip to content

feat: multi-turn conversation state management (DEV-127) - #843

Open
LukasParke wants to merge 3 commits into
mainfrom
dev-127-conversation-state-store
Open

feat: multi-turn conversation state management (DEV-127)#843
LukasParke wants to merge 3 commits into
mainfrom
dev-127-conversation-state-store

Conversation

@LukasParke

@LukasParke LukasParke commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

Summary

Implements multi-turn conversation state management for the TypeScript SDK per the approved design RFC (docs/multi-turn-state.md, ratifies merged PR #124):

  • ConversationStateStore (src/lib/conversation-state-store.ts): create/get/appendTurn/put/expire/clear over a pluggable persistence backend — zero-dependency in-memory backend + JSON-file backend; full ConversationState JSON serialization with schema validation and CorruptedStateError; TTL expiry keyed on updatedAt; single-writer last-writer-wins concurrency per RFC §6; createStateAccessor bridge to the StateAccessor contract consumed by callModel/ModelResult.
  • Turn-pipeline wiring (src/funcs/call-model-with-state.ts, src/sdk/sdk.ts): new callModelWithState() entry point (func + OpenRouter SDK class method via Speakeasy custom-code regions, regeneration-safe) deriving the StateAccessor per conversation id. Missing/expired state falls back to a fresh document under the caller's id; single-turn callModel untouched.
  • Latent fixes found in integration: createStateAccessor guards the bound id on save (prevents stranded conversations from ModelResult's random-id fallback); ModelResult.initStream now persists each turn's input into history so later turns see prior context; InMemoryConversationStateBackend implements list() so store-wide expire() works.
  • Tests & docs: 7 entry-point tests + 11 multi-turn integration tests (3+ turn context retention, parallel conversation isolation, TTL expiry fallback, corrupted-state handling) + 28 store unit tests; docs/multi-turn-state.md developer guide linked from README.

Test plan

  • vitest unit suite 232/232 (verified at d2792b5)
  • eslint --max-warnings=0 clean on all touched files
  • tsc --noEmit: 0 new errors (10 pre-existing TS4111 unchanged)
  • e2e suite (requires live OPENROUTER_API_KEY; pre-existing)

Linear: DEV-127


Open in Devin Review

Luke Parke and others added 3 commits August 19, 2026 21:04
Implement the session/conversation state store per the approved multi-turn
state design (docs/multi-turn-state.md / PR #124):

- ConversationStateStore: create / get / appendTurn / put / expire / clear
  over a pluggable ConversationStateBackend (load/save/delete/list)
- Full turn-data serialization (messages, tool results, metadata) with
  schema validation; CorruptedStateError on missing/corrupted state
- TTL-based expiry driven by updatedAt, backend-storage delegation intact
- InMemory (deep-copy isolation) and JSON-file backends, both zero-deps
- createStateAccessor() bridge to the StateAccessor contract for callModel
- Unit tests: CRUD, expiry, corruption, concurrent access, path-traversal
  hardening, durability across store instances
Integrate the ConversationStateStore into the request/turn flow so a
conversation id carries context across turns through the normal entry
point:

- New callModelWithState() entry point (funcs + OpenRouter SDK method via
  Speakeasy custom-code regions): takes conversationId + stateStore,
  derives the StateAccessor, and delegates to callModel — single-turn
  callers that omit a conversation id keep the existing callModel path
  with no store interaction
- ModelResult.initStream() now persists each new turn's input items into
  the stored message history before the request, so later turns see prior
  input + output (previously only response output was saved)
- createStateAccessor() guards the bound conversation id on save: a
  random-id state created by ModelResult on first turn can no longer
  strand the conversation under an unreachable key — missing/expired
  state falls back to a fresh document under the caller's id
- Store/backends importable via package subpath exports (no edits to
  generated src/index.ts)

Tests (7 new, end-to-end through the entry point with the API mocked at
betaResponsesSend): first-turn creation, multi-turn context visibility,
expired-state fallback, id-guard regression, input validation, and
single-turn no-store behavior. Unit suite 186/186, eslint clean, no new
tsc errors.
Cover the four integration scenarios end-to-end through the turn pipeline
(betaResponsesSend mocked, runs in the CI unit project):
- 3+ turn conversation retaining context from every earlier turn, in order
- parallel conversations with isolated state (interleaved + concurrent)
- state expiry/cleanup: mid-conversation TTL fallback, store.expire()
  sweeps, store.clear() + fresh restart
- missing or corrupted state: first-turn create, CorruptedStateError on
  invalid JSON/schema in both memory and file backends, sibling isolation

Fix a gap the tests exposed: InMemoryConversationStateBackend now
implements list(), so store-wide expire() actually sweeps on the
documented testing backend (previously a silent no-op).

Docs: docs/multi-turn-state.md describes how state is keyed
(conversationId + id guard), configured (per-store ttlMs/now; no env
vars), expired, cleared, and recovered from corruption, with testing
recipes. Linked from README.

Unit suite 232/232 (11 new), eslint clean, no new tsc errors.
@mintlify

mintlify Bot commented Aug 20, 2026

Copy link
Copy Markdown

Preview deployment for your docs. Learn more about Mintlify Previews.

Project Status Preview Updated (UTC)
openrouter-production 🟢 Ready View Preview Aug 20, 2026, 4:16 AM

@mintlify

mintlify Bot commented Aug 20, 2026

Copy link
Copy Markdown

Preview deployment for your docs. Learn more about Mintlify Previews.

Project Status Preview Updated (UTC)
openrouter-staging 🟢 Ready View Preview Aug 20, 2026, 4:16 AM

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 3 potential issues.

View 3 additional findings in Devin Review.

Open in Devin Review

Comment thread src/lib/model-result.ts
Comment on lines +1060 to +1074
} else if (baseRequest.input) {
// First turn of a fresh state: the whole input is the new turn.
const inputArray = Array.isArray(baseRequest.input)
? baseRequest.input
: [baseRequest.input];
newTurnItems = inputArray as models.BaseInputsUnion[];
}

// Persist the new-turn input into the stored message history so later
// turns can see it (the response output is appended separately once the
// API responds, and tool results as they execute).
if (this.stateAccessor && this.currentState && newTurnItems) {
await this.saveStateSafely({
messages: appendToMessages(this.currentState.messages, newTurnItems),
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Follow-up turns send raw text where the service expects structured messages, so continuing a conversation can fail

The caller's plain-text prompt is stored into the conversation history verbatim (appendToMessages(this.currentState.messages, newTurnItems) at src/lib/model-result.ts:1071-1074) instead of being wrapped as a user message, so every later turn sends a request the service can reject.
Impact: A second or third turn of a stored conversation can be rejected by the API (or lose the user's earlier wording), breaking multi-turn chats that pass a plain string prompt.

Mechanism: unwrapped string items inside the input array

When input is a string, initStream builds newTurnItems = [baseRequest.input] (src/lib/model-result.ts:1060-1066) and persists that array element as-is into state.messages. On the next turn the same function builds the API input via appendToMessages(this.currentState.messages, newTurnItems) (src/lib/model-result.ts:1052), producing e.g. ["My name is Ada.", {type:'message',role:'assistant',...}, "What is my name?"].

models.InputsUnion allows a bare string or an array of item objects (src/models/inputsunion.ts:442+); the array variant has no string member, i.e. array elements must be items such as InputsMessage. The rest of the pipeline recognizes this: makeFollowupRequest explicitly wraps a non-array input as { role: 'user', content: originalInput } (src/lib/model-result.ts:847-853). The new persistence path skips that normalization, so stored history (and the resulting request) contains raw strings mixed with item objects. The new tests assert this behavior (tests/unit/call-model-with-state.test.ts:189 expects messages[0] to be the raw string) but mock betaResponsesSend, so no real request validation occurs.

Suggested fix: normalize string inputs into { role: 'user', content: <string> } items before appending them to state.messages (and before appending them to an existing-history input array).

Prompt for agents
In src/lib/model-result.ts initStream, the new-turn items derived from baseRequest.input are appended verbatim into conversation state messages and, on continuation turns, into the request input array. When the caller passes `input` as a plain string, the stored/sent array ends up containing a bare string element mixed with structured response items. The Responses API input array requires item objects (role/content messages, function_call_output, etc.) — a bare string is only valid as the whole `input`, not as an array element. Note makeFollowupRequest already handles this by wrapping a non-array input as { role: 'user', content: input }. Fix by normalizing string (and any non-item) inputs to user message items before appending them to state.messages and before merging them with existing history for the request. Existing tests in tests/unit/call-model-with-state.test.ts and tests/unit/multi-turn-integration.test.ts assert the raw-string shape and will need updating.
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment thread src/lib/model-result.ts
Comment on lines +1068 to +1074
// Persist the new-turn input into the stored message history so later
// turns can see it (the response output is appended separately once the
// API responds, and tool results as they execute).
if (this.stateAccessor && this.currentState && newTurnItems) {
await this.saveStateSafely({
messages: appendToMessages(this.currentState.messages, newTurnItems),
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 A prompt is recorded in the conversation history before the request succeeds, duplicating it on retry

The new turn's prompt is written into the saved conversation history (saveStateSafely({ messages: ... }) at src/lib/model-result.ts:1071-1074) before the request to the model is made, so a failed request leaves the prompt stored and a retry stores it twice.
Impact: After a network error or rate-limit failure, the user's message is remembered as if it had been asked, and retrying the same turn makes it appear twice in the conversation.

Mechanism: persist-before-send ordering in initStream

initStream persists messages: appendToMessages(this.currentState.messages, newTurnItems) and only afterwards calls betaResponsesSend (src/lib/model-result.ts:1070-1078). If betaResponsesSend fails (!apiResult.ok throws) the stored document already contains the new input with no corresponding assistant output. A caller retrying the same turn with the same conversationId loads that history and appends the identical input again, so the request carries the prompt twice and history diverges from what the model actually answered.

A safer ordering is to persist the new-turn input together with the response (or only after the API call is accepted), or to make the append idempotent for an unanswered trailing input.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +285 to +305
private pathFor(id: string): string {
// Sanitize the id to prevent path traversal; ids are `conv_<uuid>` by
// default but callers may supply arbitrary strings.
const safe = normalize(id).replace(/[^a-zA-Z0-9_.-]/g, '_');
const p = join(this.dir, `${safe}.json`);
if (!p.startsWith(this.dir + sep)) {
throw new CorruptedStateError(id, 'id resolves outside state directory');
}
return p;
}

async load(id: string): Promise<ConversationState<TTools> | null> {
let raw: string;
try {
raw = await readFile(this.pathFor(id), 'utf8');
} catch (err) {
if ((err as NodeJS.ErrnoException).code === 'ENOENT') return null;
throw err;
}
return deserializeState<TTools>(raw, id);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟨 File state backend loads and validates arbitrary on-disk JSON without id binding

FileConversationStateBackend.load reads a JSON document from disk and returns it after only structural validation (assertValidState), never verifying that the document's id matches the requested conversation id (src/lib/conversation-state-store.ts:296-305, src/lib/conversation-state-store.ts:103-127). Because pathFor sanitizes ids lossily (every non [A-Za-z0-9_.-] character becomes _), distinct conversation ids collide onto the same file, so a caller can load a document belonging to another conversation/tenant and have it replayed as their own message history.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

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