Skip to content

Commit 39b07b1

Browse files
fix: persist user input items to state.messages across callModel invocations
User input items (role: 'user') were never written to state.messages. The two existing saveStateSafely write sites only persist response.output and toolResults. Within a single callModel invocation this is masked because makeFollowupRequest reconstructs input from the in-memory this.resolvedRequest.input. But when a new callModel resumes from persisted state, the loaded state.messages contains zero user items — prior user turns are silently dropped. This causes two problems: 1. cache_control prompt caching is defeated at every user-message boundary (cachedTokens = 0 on first request of each new message) 2. Conversation fidelity loss — the model never sees prior user turns Fix: In initStream(), after request resolution, persist fresh user input items to state.messages via saveStateSafely before the API call. Both code paths (loaded history and no history) now capture freshItemsForState and write it to state. Co-Authored-By: Ben Heidorn <ben.heidorn@openrouter.ai>
1 parent d8a8f65 commit 39b07b1

3 files changed

Lines changed: 373 additions & 0 deletions

File tree

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
'@openrouter/agent': patch
3+
---
4+
5+
fix: persist user input items to state.messages across callModel invocations
6+
7+
User input items (role: 'user') were never written to state.messages. The two
8+
existing saveStateSafely write sites only persist response.output and
9+
toolResults. When a new callModel resumes from persisted state, the loaded
10+
state.messages contains zero user items — prior user turns are silently dropped.
11+
12+
This causes two problems:
13+
1. cache_control prompt caching is defeated at every user-message boundary
14+
2. Conversation fidelity loss — the model never sees prior user turns

packages/agent/src/lib/model-result.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,7 @@ import {
7878
isServerTool,
7979
isToolCallOutputEvent,
8080
} from './tool-types.js';
81+
import { normalizeInputToArray } from './turn-context.js';
8182

8283
/**
8384
* Typeguard for plain-object records (non-null, non-array).
@@ -1498,6 +1499,13 @@ export class ModelResult<
14981499
Array.isArray(this.currentState.messages) &&
14991500
this.currentState.messages.length > 0;
15001501

1502+
// Track fresh user input items that need to be persisted to state.
1503+
// Without this, state.messages only contains response outputs and tool
1504+
// results — prior user turns are lost when a new callModel resumes from
1505+
// persisted state, breaking conversation fidelity and cache_control
1506+
// prompt caching at user-message boundaries.
1507+
let freshItemsForState: models.BaseInputsUnion[] | undefined;
1508+
15011509
if (hasLoadedHistory && this.currentState) {
15021510
// `currentState.messages` is InputsUnion — keep it as that union so
15031511
// appendToMessages (which expects InputsUnion) accepts it directly.
@@ -1527,6 +1535,8 @@ export class ModelResult<
15271535
? await this.applyHooksToFreshItems(freshItems, historicalMessages, initialContext)
15281536
: undefined;
15291537

1538+
freshItemsForState = hookedFresh;
1539+
15301540
baseRequest = {
15311541
...baseRequest,
15321542
input: hookedFresh
@@ -1548,6 +1558,23 @@ export class ModelResult<
15481558
...baseRequest,
15491559
input: hookedInput,
15501560
};
1561+
// All input is fresh when there's no loaded history — normalize to
1562+
// array form for state persistence.
1563+
freshItemsForState = normalizeInputToArray(hookedInput) as models.BaseInputsUnion[];
1564+
}
1565+
1566+
// Persist fresh user input items to state so they survive across
1567+
// callModel invocations. This ensures the resume path
1568+
// (state.messages + new input) reconstructs the full conversation.
1569+
if (
1570+
this.stateAccessor &&
1571+
this.currentState &&
1572+
freshItemsForState &&
1573+
freshItemsForState.length > 0
1574+
) {
1575+
await this.saveStateSafely({
1576+
messages: appendToMessages(this.currentState.messages, freshItemsForState),
1577+
});
15511578
}
15521579

15531580
// Store resolved request with stream mode

0 commit comments

Comments
 (0)