Skip to content

feat(eve): stamp every stream event with a stable id - #1239

Merged
AndrewBarba merged 22 commits into
mainfrom
barba/stable-stream-event-ids
Jul 29, 2026
Merged

feat(eve): stamp every stream event with a stable id#1239
AndrewBarba merged 22 commits into
mainfrom
barba/stable-stream-event-ids

Conversation

@AndrewBarba

@AndrewBarba AndrewBarba commented Jul 27, 2026

Copy link
Copy Markdown
Collaborator

Refs #1193.

Summary

  • Add a durable meta.id to every session stream event. Each id is an evt_-prefixed ULID stamped once before persistence and shared with authored hooks.
  • Make MessageStreamEvent the canonical public stream type. HandleMessageStreamEvent remains as a deprecated alias.
  • Deduplicate replayed events in EveAgentStore and the dev TUI while continuing to admit legacy id-less events.
  • Update the stream protocol to version 20, including public types, extension contracts, documentation, tests, and evals.

Breaking change

MessageStreamEvent now requires both meta.id and meta.at. Code that constructs unstamped events using HandleMessageStreamEvent must add the envelope or use the appropriate event-specific input type.

Events persisted before stream version 20 may have meta.at without meta.id. eve passes these events through, but they cannot be deduplicated. Retried steps produce new emissions with new ids; replaying an existing event preserves its id.

Verification

  • pnpm fmt
  • pnpm lint
  • pnpm typecheck
  • pnpm guard:invariants
  • pnpm docs:check
  • Targeted unit coverage for ULIDs, event ids and deduplication, client store/reducer behavior, and TUI replay handling

PR Checklist

  • I linked an issue with prior discussion confirming this change is wanted
  • I ran the relevant checks from CONTRIBUTING.md
  • I added tests and documentation where relevant
  • I added a changeset if this touches the published eve package
  • DCO sign-off passes for every commit (git commit --signoff)

Every session stream event now carries `meta.id`, a unique `evt_`-prefixed
ULID minted once at emission. The id is stable across reconnects, rewinds,
tail reads, and replays, so a consumer can key durable work on it — for
example `INSERT ... ON CONFLICT (id) DO NOTHING` in a hook — instead of
guessing identity from payload content.

Events are stamped before the channel adapter runs, so the adapter, the
persisted chunk, and hooks all observe the same id.

Client consumers now use it. `EveAgentStore` no longer double-applies an
`initialEvents` prefix that the live stream replays, which also protects the
React, Vue, and Svelte bindings and any user-authored reducer. The dev TUI
drops re-delivered chunks up front, which fixes a subagent section that
rendered its child transcript twice when the child stream reopened, and
retires four content-comparison replay heuristics that could silently drop a
new model call whose text prefixed the previous one.

Bumps the message stream version to 20 and the hook, dynamicTool,
dynamicSkill, and dynamicInstructions capability epochs. All four changed
only additively, so prior-epoch extensions stay compatible and are retained.

Signed-off-by: Andrew Barba <barba@hey.com>
@vercel

vercel Bot commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
eve-docs Ready Ready Preview, Comment Jul 29, 2026 6:40pm
eve-docs-1644 Ready Ready Preview, Comment, Open in v0 Jul 29, 2026 6:40pm
eve-docs-4759 Ready Ready Preview, Comment, Open in v0 Jul 29, 2026 6:40pm

@github-actions

github-actions Bot commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

Bundle + Package Summary: apps/fixtures/weather-agent

Key takeaways

  • No notable deltas vs main (c1bf6d4).

Delta vs main (c1bf6d4)

Area Metric Baseline Current Delta
Package Packed tarball 7.61 MB 7.61 MB +8.6 kB ⚠️
Package Unpacked publish size 28.74 MB 28.76 MB +19.9 kB ⚠️
Package Installed footprint 91.05 MB 91.07 MB +19.9 kB ⚠️
Package Published files 2855 2861 +6
Package Installed files 6642 6648 +6
Runtime Unique function payloads 2 2 0
Runtime Total function bytes 16.72 MB 16.75 MB +22.2 kB ⚠️
Runtime Public routes 11 11 0
Changed function payloads vs main (c1bf6d4) (2)
Function Status Baseline Current Delta Route changes
functions/__server.func changed 8.36 MB 8.37 MB +11.1 kB ⚠️ none
functions/.well-known/workflow/v1/flow.func changed 8.36 MB 8.37 MB +11.1 kB ⚠️ none

eve init install

Metric Baseline Current Delta
Installed footprint 129.45 MB 129.47 MB +19.9 kB ⚠️
Installed packages 130 130 0
dependencies 4 4 0
devDependencies 2 2 0
Dependency package bytes 43.08 MB 43.10 MB +19.9 kB ⚠️
devDependency package bytes 5.04 MB 5.04 MB 0 B ➖
Build Metadata
  • Preset: vercel
  • Nitro: nitro@3.0.260610-beta
  • Output directory: apps/fixtures/weather-agent/.vercel/output
  • Build metadata timestamp: 2026-07-29T18:41:55.674Z
  • Route aliases: 11 public, 1 internal (12 total aliases)
  • Vercel routes in config: 14
  • Severity legend: 🔴 dominant/large, 🟠 notable, 🟡 watch, ⚪ small
Package Drill-Down

Package Details

  • Package: eve@0.27.12
  • Package directory: packages/eve
  • Tarball: 7.61 MB (eve-0.27.12.tgz)
  • Unpacked payload: 28.76 MB across 2861 published files
  • Installed footprint: 91.07 MB across 6648 installed files
  • Installed root package: 27.40 MB
  • Installed dependencies: 63.66 MB
  • Runtime dependencies: 2
  • Peer dependencies: 5 (4 optional)

Installed footprint is measured from an isolated temporary npm install of the packed tarball.

Heavy installed dependencies

  • eve: 27.40 MB (30.1%)
  • @rolldown/binding-linux-x64-gnu: 19.28 MB (21.2%)
  • @rolldown/binding-wasm32-wasi: 10.66 MB (11.7%)
  • ai: 6.53 MB (7.2%)
  • @napi-rs/wasm-runtime: 6.39 MB (7.0%)
Publish payload breakdown
Published file size
🔴 dist/src/compiled/shadcn-registry/index.js       [########################] 13.15 MB 45.7%
🟠 dist/src/compiled/experimental-ai-sdk-code-mo... [###.....................] 1.51 MB 5.2%
🟡 dist/src/compiled/@vercel/sandbox/index.js       [#.......................] 632.5 kB 2.2%
🟡 dist/src/compiled/_chunks/workflow/undici-DWL... [#.......................] 502.4 kB 1.7%
🟡 dist/src/compiled/@chat-adapter/slack/index.js   [#.......................] 440.5 kB 1.5%
🔴 Other published files                            [#######################.] 12.53 MB 43.6%
Installed footprint breakdown
Installed package size
🔴 eve                             [########################] 27.40 MB 30.1%
🔴 @rolldown/binding-linux-x64-gnu [#################.......] 19.28 MB 21.2%
🔴 @rolldown/binding-wasm32-wasi   [#########...............] 10.66 MB 11.7%
🔴 ai                              [######..................] 6.53 MB 7.2%
🔴 @napi-rs/wasm-runtime           [######..................] 6.39 MB 7.0%
🔴 zod                             [####....................] 5.07 MB 5.6%
🔴 Other installed packages        [##############..........] 15.73 MB 17.3%
Runtime dependencies (2)
Package Range Notes
nitro 3.0.260610-beta
undici 8.9.0
Peer dependencies (5)
Package Range Notes
@opentelemetry/api ^1.0.0 optional peer
ai catalog:
braintrust ^3.0.0 optional peer
just-bash ^3.0.0 optional peer
microsandbox ^0.5.0 optional peer
eve init install drill-down

eve init install details

  • Command: eve init my-agent
  • Package manager: npm
  • Installed footprint: 129.47 MB across 8516 installed files
  • Installed packages: 130 total (124 transitive-only)
  • dependencies: 4 direct packages totaling 43.10 MB
  • devDependencies: 2 direct packages totaling 5.04 MB
  • Other transitive package files: 81.33 MB

Installed footprint is measured from an isolated temporary eve init my-agent using the current packed eve tarball.

Heavy installed dependencies

  • @typescript/typescript-linux-x64: 27.95 MB (21.6%)
  • eve: 27.40 MB (21.2%)
  • @rolldown/binding-linux-x64-gnu: 19.28 MB (14.9%)
  • @rolldown/binding-wasm32-wasi: 10.66 MB (8.2%)
  • zod: 9.02 MB (7.0%)
Installed footprint breakdown
Installed package size
🔴 @typescript/typescript-linux-x64 [########################] 27.95 MB 21.6%
🔴 eve                              [########################] 27.40 MB 21.2%
🔴 @rolldown/binding-linux-x64-gnu  [#################.......] 19.28 MB 14.9%
🔴 @rolldown/binding-wasm32-wasi    [#########...............] 10.66 MB 8.2%
🔴 zod                              [########................] 9.02 MB 7.0%
🔴 ai                               [######..................] 6.53 MB 5.0%
🔴 @napi-rs/wasm-runtime            [#####...................] 6.39 MB 4.9%
🔴 Other installed packages         [###################.....] 22.23 MB 17.2%
dependencies (4)
Package Range Installed size Share
@vercel/connect 0.4.2 135.8 kB 0.1%
ai ^7.0.38 6.53 MB 5.0%
eve file:eve-0.27.12.tgz 27.40 MB 21.2%
zod 4.4.3 9.02 MB 7.0%
devDependencies (2)
Package Range Installed size Share
@types/node 24.x 2.54 MB 2.0%
typescript 7.0.2 2.50 MB 1.9%
Function Drill-Down

Payload Size Graph

Unique function payload size and share of total
🔴 functions/.well-known/workflow/v1/flow.func     [########################] 8.37 MB 50.0%
🔴 functions/__server.func                         [########################] 8.37 MB 50.0%

Top Function Payloads

🟠 functions/.well-known/workflow/v1/flow.func • 1 public route • 8.37 MB
Metric Value
Public routes /.well-known/workflow/v1/flow
Runtime nodejs24.x
Handler index.mjs
Payload 8.37 MB
Function files 8.37 MB across 44 files
Traced dependencies 0 B
Signal 🟠 Bundled file index.mjs is 2.27 MB (27.2%)

🟠 🔎 Dependency Analysis

📦 Bundled files:

Bundled file size
🟠 index.mjs                       [#######################.] 2.27 MB 27.2%
🟠 _chunks/runtime-artifacts.mjs   [################........] 1.59 MB 19.0%
🟡 _libs/undici.mjs                [##########..............] 980.5 kB 11.7%
🟡 _chunks/sandbox.mjs             [########................] 768.8 kB 9.2%
🟡 _libs/@ai-sdk/gateway+[...].mjs [####....................] 432.8 kB 5.2%
🟠 Other bundled files             [########################] 2.33 MB 27.8%

🧾 Vercel Config

{
  "handler": "index.mjs",
  "launcherType": "Nodejs",
  "shouldAddHelpers": false,
  "supportsResponseStreaming": true,
  "runtime": "nodejs24.x",
  "maxDuration": "max",
  "experimentalTriggers": [
    {
      "type": "queue/v2beta",
      "topic": "__eve776561746865722d6167656e74_wkf_workflow_*",
      "consumer": "default",
      "retryAfterSeconds": 5,
      "initialDelaySeconds": 0
    }
  ],
  "environment": {
    "WORKFLOW_PRECONDITION_GUARD": "1"
  }
}

🟠 functions/__server.func • 10 public routes, 1 internal alias • 8.37 MB
Metric Value
Public routes /
/eve/v1/callback/[token]
/eve/v1/connections/[name]/callback/[token]
/eve/v1/health
/eve/v1/info
/eve/v1/session
/eve/v1/session/[sessionId]
/eve/v1/session/[sessionId]/cancel
/eve/v1/session/[sessionId]/stream
/eve/v1/session/reset
Internal aliases /__server
Runtime nodejs24.x
Handler index.mjs
Payload 8.37 MB
Function files 8.37 MB across 44 files
Traced dependencies 0 B
Signal 🟠 Bundled file index.mjs is 2.27 MB (27.2%)

🟠 🔎 Dependency Analysis

📦 Bundled files:

Bundled file size
🟠 index.mjs                       [#######################.] 2.27 MB 27.2%
🟠 _chunks/runtime-artifacts.mjs   [################........] 1.59 MB 19.0%
🟡 _libs/undici.mjs                [##########..............] 980.5 kB 11.7%
🟡 _chunks/sandbox.mjs             [########................] 768.8 kB 9.2%
🟡 _libs/@ai-sdk/gateway+[...].mjs [####....................] 432.8 kB 5.2%
🟠 Other bundled files             [########################] 2.32 MB 27.8%

🧾 Vercel Config

{
  "handler": "index.mjs",
  "launcherType": "Nodejs",
  "shouldAddHelpers": false,
  "supportsResponseStreaming": true,
  "runtime": "nodejs24.x"
}

Build Timing: e2e/fixtures/agent-tools-sandbox

This is an informational timing measurement inside eve build, from preflight through publication. Output-size measurement and profile writing are excluded.

Build mode: deployable Vercel build with sandbox template prewarm included.

  • Build pipeline: 1.91 s -> 1.94 s (+21.0 ms) vs main (c1bf6d4).
  • Timing is informational: shared GitHub runners are too variable for a hard timing budget.
Detailed phase timings vs `main (c1bf6d4)`
Phase Baseline Current Delta
extension.check 1.1 ms 6.6 ms +5.5 ms
project.resolve 0.7 ms 3.4 ms +2.7 ms
workspace.create 0.8 ms 1.2 ms +0.4 ms
host.prepare 137.6 ms 156.0 ms +18.4 ms
vercel.service-prefix.resolve 2.5 ms 2.4 ms -0.1 ms
nitro.create 230.8 ms 194.1 ms -36.7 ms
sandbox.prewarm 232.8 ms 266.4 ms +33.6 ms
nitro.cache.prepare 0.3 ms 0.3 ms 0.0 ms
nitro.prepare 0.9 ms 0.9 ms 0.0 ms
nitro.public-assets 0.8 ms 0.8 ms 0.0 ms
nitro.prerender 0.5 ms 0.5 ms 0.0 ms
nitro.bundle 1.27 s 1.27 s -2.2 ms
nitro.cache.write 0.4 ms 0.4 ms 0.0 ms
vercel.workflow-function.materialize 25.0 ms 24.8 ms -0.2 ms
agent-summary.emit 0.5 ms 0.5 ms 0.0 ms
nitro.close 0.1 ms 0.1 ms 0.0 ms
output.publish 3.6 ms 3.4 ms -0.2 ms
workspace.remove 2.4 ms 2.1 ms -0.3 ms

Review pass over the stamped-event-id work.

Correctness:

- Remove the event deduper's bounded window. Once a stream exceeded the
  capacity, rewinding to the start re-admitted the evicted id, which evicted
  the next, cascading until the whole replay was applied again. Every caller
  already retains an object per event, so the cap bought nothing.
- Tolerate a missing envelope. Events written before stream version 20 have
  no `meta`, so rewinding into an older part of a live session threw instead
  of admitting the event.
- Stop promising that `meta.id` makes hook side effects idempotent. A durable
  step that is interrupted re-runs and re-emits with fresh ids, so
  `on conflict (id) do nothing` does not dedupe a retry. The guarantee is
  scoped to re-reading an already persisted stream.
- Stop promising a total order. Ids are minted per process, so two steps on
  different machines can sort either way; drop the `where id > $cursor`
  pagination guidance in favour of stream position.
- Narrow `RouteContext.agent.getEventStream()`, which still returned
  unstamped events.
- Mark the changeset `minor`: `initialEvents` now requires stamped events.

Structure:

- Extract the ULID generator to `#shared/ulid.ts` with a `createUlidFactory()`
  for callers needing isolated monotonic state, leaving `event-id.ts` as the
  `evt_` prefix layer. Records why the generator is in-repo rather than an npm
  package, and pins the timestamp encoding with golden vectors cross-checked
  against the reference implementation.
- Revert `callAdapterEventHandler` to its original signature and stamp after
  the adapter runs. Adapter handlers only ever receive `event.data`, so they
  never observed the envelope the comments claimed; the parameter change was
  churn across thirteen files.

Also drops development notes, redundant assertions, and an unreachable
`padStart`, and removes the test-only override parameter from
`stampMessageStreamEvent`.

Signed-off-by: Andrew Barba <barba@hey.com>
…vent-ids

# Conflicts:
#	packages/eve/src/channel/schedule.test.ts
#	packages/eve/src/channel/send.test.ts
#	packages/eve/src/channel/session.ts
#	packages/eve/src/channel/types.ts
#	packages/eve/src/client/open-stream.ts
The ULID in the event envelope example decoded to 2025-07-28 while the
`at` beside it read 2026-07-27. The section links the ULID spec and
describes the id as a millisecond timestamp, so the mismatch is
reachable by anyone who follows that.

Signed-off-by: Andrew Barba <barba@hey.com>
meta and meta.at have existed since the first release; version 20 adds
meta.id alone. The docs, the changeset, and the deduper comment all said
older events arrive with no envelope, which would lead a reader to guard
on the wrong thing. The dedupe test built that same impossible shape, so
it covered a case that cannot occur while leaving the real one — an
envelope holding only at — untested. Both are covered now.

The runtime read was already correct: event.meta?.id is undefined either
way.

Signed-off-by: Andrew Barba <barba@hey.com>
The envelope docs framed re-delivery as purely a reconnect concern and
left the retry limitation to a trailing sentence. Three gaps mattered.

Stream writes are batched, and a batch that fails partway re-sends pages
that already landed, so the durable log can hold the same chunk twice.
That, not just reconnects, is why keying on the id is required.

Replay and retry read as synonyms and behave oppositely: replaying a
completed step serves the journaled result and emits nothing, while an
interrupted step re-runs and re-emits under new ids. Only the second
produces duplicates, and the docs never separated them.

The hooks guide told authors to key idempotency on the turn and step
coordinates. That is right for gating a once-per-turn side effect, since
a retry restores those coordinates. It is wrong for stored content: the
retry re-invokes the model, so one coordinate can carry different text
per attempt, and conflict-on-coordinates keeps the abandoned attempt and
drops the one that finished.

Signed-off-by: Andrew Barba <barba@hey.com>
Rename EventDeduper.isDuplicate to admit: the old name read as a pure
query while the call recorded the id, which let a side-effecting filter
hide in the EveAgentStore constructor. Unroll that filter into an
explicit loop and drop a copy-pasted test comment.

Signed-off-by: Andrew Barba <barba@hey.com>
…vent-ids

Signed-off-by: Andrew Barba <barba@hey.com>

# Conflicts:
#	apps/docs/public/r/channel/slack.json
#	apps/docs/public/r/channel/web.json
#	packages/eve/src/evals/session.ts
#	pnpm-lock.yaml
Signed-off-by: Andrew Barba <barba@hey.com>
Pin the full ULID encoding and range, cover legacy id-less events through the live store path, and align reducer fixtures and public guidance with the stamped stream contract.

Signed-off-by: Andrew Barba <barba@hey.com>
// Yield any trailing content without a final newline.
const trailing = buffer.trim();
if (trailing.length > 0) {
yield JSON.parse(trailing) as HandleMessageStreamEvent;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we MessageStreamEvent.safeParse(JSON.parse(trailing))?

Risk is that an old event is assumed to have a meta.id when it doesn't

@AndrewBarba
AndrewBarba merged commit 7ff4f77 into main Jul 29, 2026
215 of 221 checks passed
@AndrewBarba
AndrewBarba deleted the barba/stable-stream-event-ids branch July 29, 2026 20:42
ncosentino added a commit to ncosentino/eve-client that referenced this pull request Aug 1, 2026
Closes #24.

## Upstream change

[vercel/eve#1239](vercel/eve#1239) — `feat(eve):
stamp every stream event with a stable id`, merged as
[`7ff4f7797a43`](vercel/eve@7ff4f77).

Verified directly against upstream rather than the radar summary:

- `packages/eve/src/protocol/message.ts` raises
`EVE_MESSAGE_STREAM_VERSION` from `19` to `20` and adds a required `id`
to the durable event envelope.
- `packages/eve/src/protocol/event-id.ts` mints it as `evt_` + a
Crockford base32 ULID.
- `packages/eve/src/protocol/event-dedupe.ts` keys deduplication on
`meta.id`, admits id-less events, and keeps an unbounded window on
purpose.
- `packages/eve/src/client/open-stream.ts` still yields every event;
only `client/eve-agent-store.ts` and the CLI TUI dedupe, and both are
out of scope for this package.

First released in eve **0.28.0** — confirmed by unpacking the npm
tarballs: `0.27.13` still ships `EVE_MESSAGE_STREAM_VERSION=19` with no
`evt_` prefix, `0.28.0` ships `20` with it.

## Changes

- `EveStreamEventMetadata.Id` projects `meta.id`, and is `null` for
events persisted before protocol 20.
- `EveStreamEventDeduplicator` mirrors the upstream framework-neutral
deduper: it keys on the durable identifier, admits identifier-less
events because there is nothing to deduplicate on, and never bounds its
window.
- `EveStreamFollower` deliberately still yields every event the server
sends, matching `followStreamIterable`. Deduplication stays a caller
decision.
- `docs/streaming.md` and `docs/compatibility.md` document the identity
contract and the legacy gap.

## Validation

| Gate | Result |
|---|---|
| `dotnet format --verify-no-changes` | clean |
| `dotnet build -c Release` | 0 warnings, 0 errors |
| `dotnet test -c Release` | **91 passed**, 0 failed, 0 skipped (79
pre-existing + 12 new) |
| `npm run test:client` (real eve `0.27.6` server) | passed |
| `mkdocs build --strict` | built |
| `dotnet pack` + `validate-packages.ps1` | validated
`0.1.0-alpha-0002-g54eea4a9d9` |

New coverage: stamped metadata projection; legacy id-less metadata;
blank / non-string / null identifier values; nested `subagent.event`
without an envelope; deduper replay-drop vs. retry-admit; id-less admit;
`Clear()`; null argument; and an end-to-end turn where a reconnect
replays a handled event and the deduper drops exactly one of three
delivered events.

The compatibility probe now asserts against the **real** pinned server
that every event carries a non-empty `meta.at`, that no event carries
`meta.id` on protocol 19, and that identifier-less events are never
dropped.

## Disclosed gaps

**HIGH — none.**

**MEDIUM**

- The compatibility baseline is intentionally **not** advanced.
`EveProtocol.ReferenceEveVersion` stays `0.27.6` and
`MessageStreamVersion` stays `19`, because the issue requires all
changes through the upstream release to land first, and eve 0.28.0 also
carries the input-request-kind change tracked by #25. Advancing the
pinned fixture should be a separate PR once both are merged.
- Because the pinned fixture is `0.27.6`, `meta.id` **presence** is
verified only against synthetic payloads. Only the legacy-tolerance half
is verified against a real server.
- `EveStreamEventMetadata` is still surfaced only when `meta.at` is
present, so a hypothetical id-only envelope would be dropped. No
released eve version emits one.

**LOW**

- Event identifiers are compared with `StringComparer.Ordinal`;
`NLF0016` is suppressed at that single field with a justification, since
an opaque server-minted token that differs by case is a different token.
- `EveStreamEventDeduplicator` is not thread-safe, matching the upstream
deduper, and is documented as single-reader.

Copilot-Session: 6f397bc8-d45e-4945-a1f7-eb8d8d91a4fc
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants