Skip to content

Harness: clientContext is persisted into session.history despite being documented as ephemeral, so every turn's context block replays in all later prompts #2357

Description

@arielweinberger

Link to a minimal reproduction

https://github.com/arielweinberger/eve-clientcontext-repro

Steps to reproduce

git clone https://github.com/arielweinberger/eve-clientcontext-repro
cd eve-clientcontext-repro
npm install
npx eve eval

Output:

turn 1 -> prompt contains 1 clientContext block(s): CTX-1
turn 2 -> prompt contains 2 clientContext block(s): CTX-1, CTX-2
turn 3 -> prompt contains 3 clientContext block(s): CTX-1, CTX-2, CTX-3

The whole reproduction is four files. No model credential required, no HITL, no approvals, no channel adapters. mockModel is used purely to report which clientContext blocks are present in the prompt eve assembled for that turn.

package.json

{
  "name": "eve-clientcontext-repro",
  "private": true,
  "type": "module",
  "engines": { "node": "24.x" },
  "dependencies": { "eve": "0.42.0", "ai": "^7.0.58", "zod": "^4.4.3" }
}

agent/instructions.md

You are a fixture agent used to inspect the prompt eve assembles.

agent/agent.ts

import { defineAgent } from "eve"
import { mockModel } from "eve/evals"

export default defineAgent({
  // Only needed so compaction can compile against a mock model.
  modelContextWindowTokens: 400_000,
  model: mockModel(({ messages }) => {
    const seen = messages
      .filter((m) => m.role === "user" && m.text.includes("CTX-"))
      .map((m) => m.text.match(/CTX-\d+/)?.[0] ?? "?")
    return `prompt contains ${seen.length} clientContext block(s): ${seen.join(", ")}`
  }),
})

evals/evals.config.ts

import { defineEvalConfig } from "eve/evals"

export default defineEvalConfig({ maxConcurrency: 1, timeoutMs: 60_000 })

evals/repro.eval.ts

import { defineEval } from "eve/evals"

export default defineEval({
  description: "clientContext from earlier turns should not appear in later prompts",
  tags: ["repro"],
  async test(t) {
    for (const n of [1, 2, 3]) {
      const turn = await t.send(`Turn ${n}`, { clientContext: `CTX-${n}` })
      console.log(`turn ${n} ->`, turn.message)
    }
  },
})

Current vs. expected behavior

Expected

Per dist/src/client/types.d.ts (SendTurnOptions.clientContext):

Ephemeral client/page context for the next model call only.

Strings are rendered as user-role model context messages. [...] Client context rides along with a message or HITL response; it does not dispatch a turn by itself and is never persisted to durable session history.

So turn 3's prompt should contain only CTX-3.

Actual

Turn 3's prompt contains CTX-1, CTX-2 and CTX-3. One block accumulates per turn, for the life of the session.

Root cause

The two doc comments in the package contradict each other, and the harness one matches the implementation:

  • dist/src/client/types.d.ts, SendTurnOptions.clientContext: "never persisted to durable session history"
  • dist/src/harness/types.d.ts, StepInput.context: "Each entry is appended as a role: \"user\" message to session.history before the delivery message."

In dist/src/harness/tool-loop.js the turn's prompt array is seeded from session.history, each context entry is pushed onto it as a user message, and that same array is handed to handleStepResult as promptMessages and persisted:

if (I?.context !== void 0 && B.deferredContext !== !0)
  for (let e of I.context) H.push({ content: e, role: `user` })
// ...
handleStepResult({ ..., promptMessages: H, ... })

// inside handleStepResult:
let oe = [...r, ...ae], D = { ...v, history: oe }

The next turn reseeds its messages from session.history, so the blocks compound.

Impact

For the page-context use case the field is designed for (sending the user's current editor/file/page state with each message), the model ends up holding N mutually contradictory snapshots of the same state, every one framed as current, with nothing marking which is newest.

In our case an in-product assistant repeatedly told users their file still contained a problem they had already fixed, because it answered from an earlier turn's block. It also costs prompt tokens linearly in turn count, and any compaction that fires summarizes the stale blocks rather than dropping them.

Related

#529 / #762 touched this same push site and shipped deferredContext in 0.24.2, but that fix is about ordering during approval resume. The persistence behaviour is unchanged, and this repro involves no HITL at all.

Possible resolutions

  1. Make the implementation match the documented contract: exclude context entries when persisting history. The runtime already knows which messages they are, since it pushes them itself.
  2. Or let context be keyed, so a new block with the same key replaces the previous one instead of appending.
  3. Or expose a way to prune/rewrite session history between turns.

(1) alone would satisfy the documented contract. (2) would additionally cover the general case, since any per-turn injected state has this problem in an append-only history.

Happy to open a PR for (1) if that is the direction you would take.

eve version

eve@0.42.0 and past version since eve@0.20.0 confirmed

Environment

OS: macOS 15.5 (arm64)
Node: v24.15.0
Package manager: npm 11.x (also reproduced with bun 1.3.5)
ai: 7.0.71 (resolved from ^7.0.58)
zod: 4.4.3
Model: not involved (eve/evals mockModel)

Reproduced locally via `eve eval`. Originally observed in production on Vercel through the
default eve channel (POST /eve/v1/session), where the app sends page context with every turn.

Where does the bug occur?

Production (eve start / deployed), Local dev (eve dev)

Deployment

No response

Build and runtime logs

There is no error and no stack trace: the bug is silent, and the accumulating prompt is the only observable. `EVE_LOG_LEVEL=debug` adds nothing beyond the run below, since nothing fails.

The `mockModel` reply is the runtime evidence, because it reports which `clientContext` blocks were present in the prompt eve assembled for that turn.


$ EVE_LOG_LEVEL=debug npx eve eval

EVALS 1
target http://127.0.0.1:57133/

turn 1 -> prompt contains 1 clientContext block(s): CTX-1
turn 2 -> prompt contains 2 clientContext block(s): CTX-1, CTX-2
turn 3 -> prompt contains 3 clientContext block(s): CTX-1, CTX-2, CTX-3
✓  repro

Results: 1 passed (1 total)

Completed in 489ms


Expected, per the documented contract, would be `1` block on every turn.

Suggested implementation prompt

Make clientContext behave as SendTurnOptions.clientContext documents it: ephemeral, for one model call, never persisted to durable session history.

Requirements:

  • Context entries delivered via StepInput.context may be included in the prompt for the current model call, but must not be present in session.history once the turn settles.
  • The harness already pushes those entries itself in tool-loop.js, so their positions are known and can be excluded when handleStepResult builds history from promptMessages.
  • Do not change ordering behaviour relative to a pending approval batch, so the deferredContext fix from fix(eve): defer context during tool approval #762 keeps working.
  • Update whichever of the two doc comments ends up being wrong, so client/types.d.ts and harness/types.d.ts agree.

Acceptance criteria:

If you would rather keep context in history and add a replace-by-key option instead, that also resolves our use case; the important part is that the docs and the runtime agree on which one it is.

Additional context

No response

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingcorep0

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions