Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/stable-stream-event-ids.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
"eve": minor
---

Every session stream event now carries a `meta.id`: a unique, `evt_`-prefixed ULID minted once when the event is written to the durable stream. Re-reading a stream — reconnecting from a cursor, rewinding to `startIndex=0`, or replaying a finished session — returns the same id for the same event, so it is safe to use as a primary key when persisting events (`on conflict (id) do nothing`). Note that a retried step re-emits its events under new ids, so the id deduplicates re-delivery, not re-emission. Authored hooks receive the same envelope.

Stream consumers now drop re-delivered events by id instead of guessing from payload content. `EveAgentStore` (and so the React, Vue, and Svelte bindings) no longer double-applies an `initialEvents` prefix that the live stream replays, and the dev TUI no longer renders a subagent's transcript twice when its child stream reopens.

**Breaking:** events read from a stream are now typed as `StampedHandleMessageStreamEvent`, which guarantees `meta` is present. `initialEvents` on `useEveAgent`/`EveAgentStore` requires that type, so a saved log typed as `HandleMessageStreamEvent[]` no longer typechecks — widen it to `StampedHandleMessageStreamEvent[]`. The `eve eval` API carries the stamped type end to end: turn, session, and live-turn `events`, `waitForEvent` results, and `eventsSatisfy` predicates all see `meta.id` without guards. Events persisted by an earlier version carry `meta.at` but no `meta.id`, so rewinding into a session that started before this release yields events whose id is absent despite the type. eve passes those through rather than dropping them, and they cannot be deduplicated; the exposure ends when those sessions do.
2 changes: 1 addition & 1 deletion docs/concepts/execution-model-and-durability.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ Provider keys, tool secrets, and MCP, OpenAPI, and connection credentials stay i

## Resuming after a crash

Crash the process, hit a timeout, or redeploy mid-turn, and the run picks up from the last completed step rather than replaying the whole turn. Completed steps never re-run; eve replays the recorded result. A step interrupted mid-execution re-runs, so make non-idempotent side effects like charges or emails idempotent, or gate them with approval.
Crash the process, hit a timeout, or redeploy mid-turn, and the run picks up from the last completed step rather than replaying the whole turn. Completed steps never re-run; eve replays the recorded result. A step interrupted mid-execution re-runs, so make non-idempotent side effects like charges or emails idempotent, or gate them with approval. Whatever that step already wrote to the session stream stays there, and the re-run emits its events again under new ids, so a stream consumer sees both attempts — see [the event envelope](./sessions-runs-and-streaming#the-event-envelope).

There's nothing to configure. eve owns the workflow lifecycle, and sessions are durable by default.

Expand Down
55 changes: 55 additions & 0 deletions docs/concepts/sessions-runs-and-streaming.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,59 @@ A delegated subagent publishes progress on its own child-session stream. The par

`step.failed` and `turn.failed` carry `{ code, message, details? }` for the failed fragment or turn, and `session.failed` is the terminal session-level variant. `turn.cancelled` is not a failure: the cancelled turn ends without any failure event, `session.waiting` follows, and the session accepts the next message normally — whatever the turn streamed before cancellation stays on the stream, while durable history keeps only what had already settled. When a turn requested an output schema, the finalized payload lands on `result.completed` as `data.result` before the turn boundary. `authorization.required` carries the sign-in challenge (`data.authorization` may include `url`, `userCode`, `expiresAt`, `instructions`), and `authorization.completed` carries `data.outcome` (`"authorized" | "declined" | "failed" | "timed-out"`).

## The event envelope

Alongside `type` and `data`, every event carries a `meta` envelope:

```json
{
"type": "message.completed",
"data": {
"message": "Sunny and 72°F.",
"finishReason": "stop",
"sequence": 0,
"stepIndex": 0,
"turnId": "turn_0"
},
"meta": { "id": "evt_01KYJBZA88B4M9XN3RTC5FDGHJ", "at": "2026-07-27T18:04:11.912Z" }
}
```

- **`meta.id`** uniquely identifies the event. It is an `evt_`-prefixed [ULID](https://github.com/ulid/spec): a millisecond timestamp followed by random bits, so ids are broadly time-ordered.
- **`meta.at`** is the ISO-8601 time the event was emitted.

`meta.id` is stable. eve mints it once, when the event is written to the durable stream, and stores it with the event. Reconnecting from a cursor, rewinding to `startIndex=0`, or replaying a finished session all return the same id for the same event.

`meta.at` has always been there; `meta.id` arrived in stream version 20. Events written by an earlier version are stored with the envelope but no id inside it, so rewinding into the part of a session that ran before you upgraded yields events whose `meta.id` is absent, even though the type says it is always a string. eve passes those events through rather than dropping them, and they cannot be deduplicated. The exposure ends when the sessions that predate your upgrade do.

That makes it the key for ingesting a stream into a database without duplicating rows when you re-read it:

```sql
insert into agent_events (id, session_id, type, data, emitted_at)
values ($1, $2, $3, $4, $5)
on conflict (id) do nothing;
```

Because ids lead with a timestamp, a `primary key (id)` stays roughly append-ordered and keeps inserts clustered.

**What the id covers.** Reconnecting is not the only way the same event reaches you twice. Keying on `meta.id` is what makes ingestion correct in all of these:

- Reconnecting mid-turn and overlapping events you already handled.
- Rewinding with `startIndex=0`, or reading back from the tail with a negative `startIndex`.
- Restoring a saved event log that overlaps the prefix the live stream replays.

**What it does not cover: a retried step re-emits under new ids.** eve runs each durable step up to four times. If a step is interrupted partway — a crash, a timeout, a model error it retries through — whatever it already wrote stays on the stream, and the new attempt emits its own events with their own ids. Both attempts carry the same `turnId`, `stepIndex`, and `sequence`, because the retry restores that state from the step's input, but they are distinct events and no field records which attempt finished.

Replaying a _completed_ step is a different thing and emits nothing at all: eve serves the recorded result from its journal without re-running the body. Crash recovery, redeploys, and resuming a parked turn therefore add nothing to the stream. Only an interrupted step re-runs.

Three more things to know:

- **Ids are time-ordered, not a total order.** The turn steps of one session can run in different processes, each generating ids from its own clock and its own random bits. Two events emitted in the same millisecond by different steps may sort either way, and clock skew between machines can invert neighbours. Record your own ingestion sequence, or read the stream in order and store the index, when you need an exact ordering to page against — do not use `where id > $cursor` as a lossless cursor. The stream itself is authoritative: `startIndex` is an absolute event count.
- **Ids identify events, not intent.** Two events with identical payloads — the `step.failed` → `turn.failed` → `session.failed` cascade, or two identical text deltas in one step — are distinct events with distinct ids. Deduplicate on `meta.id` only; matching on content would drop real data.
- **A subagent's event is re-emitted, not shared.** When a parent forwards a child's event onto its own stream, the parent's copy is a separate event with its own id. Correlate the two streams through `subagent.called.data.childSessionId`.

Authored [hooks](../guides/hooks) receive the same envelope, but observe each event as it is emitted rather than as it is read — so a hook sees a retry as new events, and `meta.id` is a key for a stored row rather than a retry guard. Two things a hook does not have to defend against: a turn that parks for human input resumes without re-emitting anything it already sent, and a retried turn dispatch cannot double-stream a turn, because only one turn run can claim a session's turn inbox.

## Send a follow-up message

Once the session is waiting (you'll see `session.waiting`), POST your follow-up to the session endpoint with `event.data.continuationToken`:
Expand Down Expand Up @@ -112,6 +165,8 @@ Custom channel routes request the same cancellation without knowing the session

The stream is durable. Every event is recorded before a step completes, so consumers can reconnect from their cursor when an HTTP connection ends. A nonnegative `startIndex` is an absolute event count: use it to pick up where you dropped off or pass `0` to rewind to the start.

If a reconnect overlaps events you already handled, [`meta.id`](#the-event-envelope) identifies the duplicates: it is unchanged across reconnects and rewinds, so a consumer keyed on it can replay safely.

```bash
curl "http://127.0.0.1:2000/eve/v1/session/<sessionId>/stream?startIndex=<count>"
```
Expand Down
10 changes: 7 additions & 3 deletions docs/guides/client/streaming.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -60,13 +60,15 @@ for await (const event of response) {

## Handle event types

Import event types from `eve/client` when you want exhaustiveness or helpers:
Import event types from `eve/client` when you want exhaustiveness or helpers. Events read from a stream are `StampedHandleMessageStreamEvent`: the same union, with the `meta` envelope guaranteed present.

```ts
import type { HandleMessageStreamEvent } from "eve/client";
import type { StampedHandleMessageStreamEvent } from "eve/client";
import { isCurrentTurnBoundaryEvent } from "eve/client";

function handleEvent(event: HandleMessageStreamEvent) {
function handleEvent(event: StampedHandleMessageStreamEvent) {
console.log(event.meta.id, event.meta.at);

if (isCurrentTurnBoundaryEvent(event)) {
console.log("turn settled:", event.type);
}
Expand Down Expand Up @@ -105,6 +107,8 @@ If you support refresh while an authorization prompt is pending, keep the sessio

HTTP connections can end before a run does. The client reconnects from the number of events already consumed, so long turns continue without replaying events. It stops at a turn boundary, when aborted, or when the stream can no longer make progress.

If your consumer persists events, key on `event.meta.id`. It is stable across reconnects and rewinds, so an overlapping replay is safe to ingest twice. See [the event envelope](../../concepts/sessions-runs-and-streaming#the-event-envelope).

Set `streamReconnectPolicy: { reconnect: false }` when a relay or proxy owns the cursor and reconnection policy. This makes a single stream GET attempt and returns when that connection ends; it does not stop the server-side turn:

```ts
Expand Down
6 changes: 4 additions & 2 deletions docs/guides/frontend/overview.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -226,10 +226,10 @@ const agent = useEveAgent({ reducer: toolCounter });
The browser conversation lives durably on the server. Persist both the rendered event log and the `session` cursor to pick it back up after a reload:

```tsx
import type { HandleMessageStreamEvent, SessionState } from "eve/client";
import type { SessionState, StampedHandleMessageStreamEvent } from "eve/client";

type SavedEveChat = {
events?: readonly HandleMessageStreamEvent[];
events?: readonly StampedHandleMessageStreamEvent[];
session?: SessionState;
};

Expand All @@ -255,6 +255,8 @@ const agent = useEveAgent({

Store the full `session` object (`sessionId`, `continuationToken`, `streamIndex`), not a single field. The session cursor lets eve continue the durable conversation; the event log lets your UI render historical messages without replaying the whole stream. A database-backed chat app should usually persist stream events as they arrive with `onEvent` and then save a final snapshot in `onFinish`.

You do not have to make `initialEvents` line up exactly with where the stream resumes. Every event carries a stable [`meta.id`](/docs/concepts/sessions-runs-and-streaming#the-event-envelope), and the store drops any event whose id it has already applied — so a saved log that overlaps the replayed prefix renders once, and `onEvent` only fires for events your UI has not seen.

For multiple chat threads, keep one saved event log and session cursor per thread. `agent`, `host`, `reducer`, `session`, `initialEvents`, `initialSession`, `auth`, `headers`, and `optimistic` are read when the hook creates its store, so remount the chat component when switching threads, for example with `key={chat.id}`.

If the user can refresh or navigate immediately after pressing send, create your app-level chat row and store the pending user message before calling `send()`. After the request starts, persist the session state as soon as it contains a `sessionId`, then reconnect an interrupted in-flight turn with `session.stream({ startIndex: savedEvents.length })` from the lower-level client.
Expand Down
20 changes: 10 additions & 10 deletions docs/guides/frontend/use-eve-agent-svelte.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -23,16 +23,16 @@ Import from `eve/svelte` and read the reactive getters directly. No `$` prefix:

## What it returns

| Property | Type | Description |
| --------- | ------------------------------------------- | ------------------------------------------------------------------------- |
| `data` | `TData` | Projected state. With the default reducer, `EveMessageData` (`messages`). |
| `status` | `UseEveAgentStatus` | `"ready"`, `"submitted"`, `"streaming"`, or `"error"`. |
| `error` | `Error \| undefined` | Last transport-level error. |
| `events` | `readonly HandleMessageStreamEvent[]` | Raw server events for this session. |
| `session` | `SessionState` | Snapshot of session state. |
| `send` | `(input: SendTurnPayload) => Promise<void>` | Send text or a full turn (multi-part, attachments, HITL responses). |
| `stop` | `() => void` | Abort the client's in-flight stream. |
| `reset` | `() => void` | Clear state and start a new session. |
| Property | Type | Description |
| --------- | -------------------------------------------- | ------------------------------------------------------------------------- |
| `data` | `TData` | Projected state. With the default reducer, `EveMessageData` (`messages`). |
| `status` | `UseEveAgentStatus` | `"ready"`, `"submitted"`, `"streaming"`, or `"error"`. |
| `error` | `Error \| undefined` | Last transport-level error. |
| `events` | `readonly StampedHandleMessageStreamEvent[]` | Raw server events for this session. |
| `session` | `SessionState` | Snapshot of session state. |
| `send` | `(input: SendTurnPayload) => Promise<void>` | Send text or a full turn (multi-part, attachments, HITL responses). |
| `stop` | `() => void` | Abort the client's in-flight stream. |
| `reset` | `() => void` | Clear state and start a new session. |

These state fields are reactive getters, so read them straight from templates, `$derived`, or `$effect`. They are not stores, so don't prefix them with `$`.

Expand Down
20 changes: 10 additions & 10 deletions docs/guides/frontend/use-eve-agent-vue.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -25,16 +25,16 @@ const { data } = useEveAgent();

## What it returns

| Property | Type | Description |
| --------- | -------------------------------------------------- | ------------------------------------------------------------------------- |
| `data` | `ComputedRef<TData>` | Projected state. With the default reducer, `EveMessageData` (`messages`). |
| `status` | `ComputedRef<UseEveAgentStatus>` | `"ready"`, `"submitted"`, `"streaming"`, or `"error"`. |
| `error` | `ComputedRef<Error \| undefined>` | Last transport-level error. |
| `events` | `ComputedRef<readonly HandleMessageStreamEvent[]>` | Raw server events for this session. |
| `session` | `ComputedRef<SessionState>` | Snapshot of session state. |
| `send` | `(input: SendTurnPayload) => Promise<void>` | Send text or a full turn (multi-part, attachments, HITL responses). |
| `stop` | `() => void` | Abort the client's in-flight stream. |
| `reset` | `() => void` | Clear state and start a new session. |
| Property | Type | Description |
| --------- | --------------------------------------------------------- | ------------------------------------------------------------------------- |
| `data` | `ComputedRef<TData>` | Projected state. With the default reducer, `EveMessageData` (`messages`). |
| `status` | `ComputedRef<UseEveAgentStatus>` | `"ready"`, `"submitted"`, `"streaming"`, or `"error"`. |
| `error` | `ComputedRef<Error \| undefined>` | Last transport-level error. |
| `events` | `ComputedRef<readonly StampedHandleMessageStreamEvent[]>` | Raw server events for this session. |
| `session` | `ComputedRef<SessionState>` | Snapshot of session state. |
| `send` | `(input: SendTurnPayload) => Promise<void>` | Send text or a full turn (multi-part, attachments, HITL responses). |
| `stop` | `() => void` | Abort the client's in-flight stream. |
| `reset` | `() => void` | Clear state and start a new session. |

The first five are `ComputedRef`s; the rest are methods. Destructure whatever you need, since refs keep their reactivity through destructuring. Read them with `.value` in `<script>`, and unwrapped in `<template>`.

Expand Down
Loading
Loading