Observed on eve@0.27.6, browser client (useEveAgent), against a durable remote session.
Summary
ClientSession.send starts the new turn's stream at session.streamIndex, but that value is only published when a stream iteration ends. While EveAgentStore's background resume stream is open and consuming events, the session state stays frozen at the index that stream started from. A send issued in that window therefore opens the turn stream behind events the client has already consumed, and the server — correctly — replays them.
That would be harmless if the message reducer were idempotent under replay. Two branches are not:
input.requested erases a recorded HITL answer. The branch rebuilds the tool part wholesale via upsertPart, so it drops the inputResponse that client.input.responded wrote. (action.result is fine — it uses mergeToolMetadata.)
- Replayed
message.appended duplicates a completed text part. upsertRun only replaces the last matching part while its state is streaming; after message.completed marked it done, a replayed appended appends a second part for the same stepIndex.
For a HITL UI the first is user-visible and quite bad: the user answers a question, and a second later the answered card returns to approval-requested — live and clickable again — then settles showing no response at all, while the model goes on using the real answer. A reload renders it correctly, because our own persisted copy of the answer replays in the right order.
Reproduction — reducer only, no server
import { defaultMessageReducer } from 'eve/client';
const inputRequested = {
type: 'input.requested',
data: { turnId: 't1', stepIndex: 0, requests: [{
requestId: 'req-1',
action: { callId: 'call-1', kind: 'tool-call', toolName: 'ask_question', input: {} },
prompt: 'Which one?', options: [{ id: 'opt-a', label: 'A' }], allowFreeform: true,
}] },
};
const responded = {
type: 'client.input.responded',
data: { createdAt: 1, responses: [{ requestId: 'req-1', optionId: 'opt-a', text: 'A' }] },
};
const run = (events) => {
const r = defaultMessageReducer();
let s = r.initial();
for (const e of events) s = r.reduce(s, e);
return s.messages.flatMap((m) => m.parts).find((p) => p.type === 'dynamic-tool');
};
run([inputRequested, responded]).toolMetadata.eve.inputResponse;
// => { requestId: 'req-1', optionId: 'opt-a', text: 'A' } ✅
run([inputRequested, responded, inputRequested]);
// => state: 'approval-requested', toolMetadata.eve.inputResponse: undefined ❌
And for the text case:
const appended = (text) => ({ type: 'message.appended', data: { turnId: 't1', stepIndex: 0, messageSoFar: text } });
const completed = (text) => ({ type: 'message.completed', data: { turnId: 't1', stepIndex: 0, message: text, finishReason: 'stop' } });
const stream = [appended('Hel'), appended('Hello'), completed('Hello')];
// one delivery → 1 text part
// [...stream, ...stream] → 2 text parts, both { state: 'done', stepIndex: 0, text: 'Hello' } ❌
Why the replay happens
ClientSession.#r (the send stream) takes c = n.sessionId === e ? n.streamIndex : 0 from the session snapshot captured at the start of send.
ClientSession.#i (the resume stream) only writes this.#t in its finally, via advanceSession. followStreamIterable reconnects inside #i, so a resume stream that keeps receiving events never returns and never publishes its cursor.
EveAgentStore.#F keeps that resume stream open indefinitely; its idle-retry budget resets on every event received, so on any session that is emitting at all, it effectively never exhausts.
The result is that streamIndex can stay stale for the entire life of the resume stream. In one production session I traced, it sat frozen for ~20 minutes while the internal cursor advanced by ~90 events; the send that followed opened 88 events behind, and a later one 419 events behind. Both were HITL answers, and both replayed the input.requested the user had just answered.
The precondition is that the resume stream — not the send stream — owned the turn's events. That happens whenever the send stream ends early: a page mounted or reloaded mid-turn, or a turn stream that threw. (In our case a proxy in front of the eve host cuts every stream at ~120s, which made this routine.)
Suggested fixes
Either half alone would fix the user-visible symptom; both seem worth doing:
- Publish the cursor as it advances.
#i could update this.#t.streamIndex per event (or at each internal reconnect boundary) rather than only in finally, so a concurrent send sees the live cursor. Alternatively EveAgentStore.send could await the resume stream's teardown before ClientSession.send captures session state.
- Make the reducer idempotent under replay, which a durable cursor-addressed stream seems entitled to assume:
input.requested should merge into an existing part rather than replace it, so an already-recorded inputResponse survives (mirroring what action.result already does).
upsertRun should replace the matching stepIndex part regardless of its state, rather than appending a duplicate once it is done.
Happy to open a PR for either if that's useful.
Observed on
eve@0.27.6, browser client (useEveAgent), against a durable remote session.Summary
ClientSession.sendstarts the new turn's stream atsession.streamIndex, but that value is only published when a stream iteration ends. WhileEveAgentStore's background resume stream is open and consuming events, the session state stays frozen at the index that stream started from. Asendissued in that window therefore opens the turn stream behind events the client has already consumed, and the server — correctly — replays them.That would be harmless if the message reducer were idempotent under replay. Two branches are not:
input.requestederases a recorded HITL answer. The branch rebuilds the tool part wholesale viaupsertPart, so it drops theinputResponsethatclient.input.respondedwrote. (action.resultis fine — it usesmergeToolMetadata.)message.appendedduplicates a completed text part.upsertRunonly replaces the last matching part while its state isstreaming; aftermessage.completedmarked itdone, a replayedappendedappends a second part for the samestepIndex.For a HITL UI the first is user-visible and quite bad: the user answers a question, and a second later the answered card returns to
approval-requested— live and clickable again — then settles showing no response at all, while the model goes on using the real answer. A reload renders it correctly, because our own persisted copy of the answer replays in the right order.Reproduction — reducer only, no server
And for the text case:
Why the replay happens
ClientSession.#r(the send stream) takesc = n.sessionId === e ? n.streamIndex : 0from the session snapshot captured at the start ofsend.ClientSession.#i(the resume stream) only writesthis.#tin itsfinally, viaadvanceSession.followStreamIterablereconnects inside#i, so a resume stream that keeps receiving events never returns and never publishes its cursor.EveAgentStore.#Fkeeps that resume stream open indefinitely; its idle-retry budget resets on every event received, so on any session that is emitting at all, it effectively never exhausts.The result is that
streamIndexcan stay stale for the entire life of the resume stream. In one production session I traced, it sat frozen for ~20 minutes while the internal cursor advanced by ~90 events; thesendthat followed opened 88 events behind, and a later one 419 events behind. Both were HITL answers, and both replayed theinput.requestedthe user had just answered.The precondition is that the resume stream — not the send stream — owned the turn's events. That happens whenever the send stream ends early: a page mounted or reloaded mid-turn, or a turn stream that threw. (In our case a proxy in front of the eve host cuts every stream at ~120s, which made this routine.)
Suggested fixes
Either half alone would fix the user-visible symptom; both seem worth doing:
#icould updatethis.#t.streamIndexper event (or at each internal reconnect boundary) rather than only infinally, so a concurrentsendsees the live cursor. AlternativelyEveAgentStore.sendcould await the resume stream's teardown beforeClientSession.sendcaptures session state.input.requestedshould merge into an existing part rather than replace it, so an already-recordedinputResponsesurvives (mirroring whataction.resultalready does).upsertRunshould replace the matchingstepIndexpart regardless of its state, rather than appending a duplicate once it isdone.Happy to open a PR for either if that's useful.