Skip to content

useEveAgent: send starts the turn stream from a stale streamIndex while a resume stream is open, replaying events (erases HITL answers, duplicates text parts) #1507

Description

@MatthewFoster02

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:

  1. 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.)
  2. 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:

  1. 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.
  2. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions