Streaming¶
Iterate an EveMessageResponse to process events as they arrive:
await foreach (EveStreamEvent streamEvent in
response.WithCancellation(cancellationToken))
{
if (streamEvent.Kind == EveStreamEventKind.MessageAppended)
{
string delta = streamEvent.Data.GetProperty("messageDelta").GetString() ?? "";
Console.Write(delta);
}
}
Known event names map to EveStreamEventKind. The original wire-level Type
and Data remain available for forward compatibility.
Preliminary tool output¶
A tool implemented as an async generator streams provisional snapshots. Each
non-terminal snapshot arrives as action.partial
(EveStreamEventKind.ActionPartial); the final one arrives as action.result
and is the only value exposed to the model.
await foreach (EveStreamEvent streamEvent in
response.WithCancellation(cancellationToken))
{
switch (streamEvent.Kind)
{
case EveStreamEventKind.ActionPartial:
RenderProvisional(streamEvent.Data.GetProperty("result"));
break;
case EveStreamEventKind.ActionResult:
CommitResult(streamEvent.Data.GetProperty("result"));
break;
}
}
Treat a partial snapshot as provisional display state. Never persist one as a final tool result, and expect it to be superseded.
Streamed tool input¶
Eve 0.46.1 introduced action.input.appended
(EveStreamEventKind.ActionInputAppended) while the model generates one tool
call's input. Starting in protocol v25 (Eve 0.50.0), text append events are
persisted as deltas only. Stream responses carry the required x-eve-stream-version
header (versions 21 through 25), and legacy cumulative properties (inputTextOffset,
messageSoFar, reasoningSoFar) are normalized out of the event payload at the decode boundary.
await foreach (EveStreamEvent streamEvent in
response.WithCancellation(cancellationToken))
{
if (streamEvent.Kind == EveStreamEventKind.ActionInputAppended)
{
Console.Write(streamEvent.Data.GetProperty("inputTextDelta").GetString());
}
}
Events are retained in wire order and do not end a response. A
message.completed event can precede tool-input deltas when assistant text is
emitted before the tool call. Accumulate only contiguous deltas whose offset
matches the current UTF-16 string length. The later actions.requested event
contains the validated input and remains authoritative.
The JavaScript UI reducer's gap handling, replacement, late-delta suppression, and cancellation cleanup are presentation-state policies and are not part of this transport-focused client.
Approval lifecycle¶
eve 0.34.0 publishes the durable lifecycle of a human approval request. Each
responder attempt arrives as approval.candidate
(EveStreamEventKind.ApprovalCandidate) with a stable candidateId and an
outcome of pending, rejected, failed, timed-out, or stale. A terminal
candidate outcome may carry a reason. The request's terminal result arrives once
as approval.settled (EveStreamEventKind.ApprovalSettled) with an outcome of
approved or cancelled.
await foreach (EveStreamEvent streamEvent in
response.WithCancellation(cancellationToken))
{
switch (streamEvent.Kind)
{
case EveStreamEventKind.ApprovalCandidate:
TrackCandidate(
streamEvent.Data.GetProperty("candidateId").GetString(),
streamEvent.Data.GetProperty("outcome").GetString());
break;
case EveStreamEventKind.ApprovalSettled:
SettleRequest(
streamEvent.Data.GetProperty("requestId").GetString(),
streamEvent.Data.GetProperty("outcome").GetString());
break;
}
}
Candidate events precede settlement for the same requestId. Neither event ends a
turn, and neither changes how EveTurnOutcome aggregates messages, results, or
input requests. Outcome values are read from raw data rather than projected to an
enum, so a future outcome added upstream is still readable.
An agent older than eve 0.34.0 never emits these events. Because the client maps
by wire type, they simply do not appear.
Callback authorization parking¶
eve 0.41.0 may emit authorization.required with a webhookUrl, followed by an
interim session.waiting, while a framework-owned callback is pending. Keep enumerating
the active EveMessageResponse: it remains attached until the matching
authorization.completed event arrives and the resumed turn reaches its next session
boundary.
Pending authorizations are correlated by Data["name"], so multiple callbacks can settle
independently. An authorization.required event without webhookUrl is non-blocking and
the following session.waiting ends the response normally.
EveStreamEvent.IsCurrentTurnBoundary identifies session-level boundary event types. Its
value is intentionally context-free, so it remains true for an interim waiting event.
Do not add a manual break for that property while iterating an active response; allow
EveMessageResponse to apply the pending-authorization context.
Resolved human input¶
eve 0.39.1 emits input.resolved after accepting pending human input and before
the resumed step.started. Aggregate a response to read the authoritative outcomes:
EveTurnOutcome outcome = await response.GetOutcomeAsync(cancellationToken);
foreach (EveInputResolution resolution in outcome.InputResolutions)
{
Console.WriteLine(
$"{resolution.RequestId}: {resolution.Outcome} at " +
$"{resolution.TurnId}/{resolution.StepIndex}/{resolution.Sequence}");
}
Response contains the accepted option or text when one exists. It is null for
authoritative response-less outcomes such as Ignored. Unknown request kinds and
outcomes project as Unknown while RawKind, RawOutcome, and Raw preserve the
wire values.
Durable event identity¶
Stream protocol version 20 stamps every persisted event with a stable
evt_-prefixed identifier before it is written, exposed as
EveStreamEvent.Metadata.Id. Replaying that event — through a reconnect, a
rewind, or a re-read of a finished session — yields the same identifier. A
retried step is not a replay: it is emitted again under a new identifier.
Events persisted before protocol version 20 carry no identifier and report
null, so they cannot be deduplicated. eve 0.27.6 emits protocol version 19
and never stamps one.
EveStreamEventDeduplicator encodes that contract, so a caller that resumes a
stream can drop events it already processed:
EveStreamEventDeduplicator seen = new();
await foreach (EveStreamEvent streamEvent in
session.StreamAsync(cancellationToken))
{
if (!seen.Admit(streamEvent))
{
continue;
}
Handle(streamEvent);
}
The remembered set is unbounded because a bounded window cannot survive a rewind past its capacity. Callers that retain nothing per event should bound their reads with the session cursor instead.
Reconnection¶
The default policy follows a durable stream from the next absolute event index.
SendAsync and RespondAsync responses keep reconnecting until the current turn
reaches a session boundary or the caller cancels consumption. Manually attached
StreamAsync reads retain a finite idle reconnect budget.
Each underlying stream read also has a fixed 15-second idle deadline. If an open connection stops producing bytes without closing, the client disposes that response and reconnects from the cursor after every fully consumed event. This deadline is separate from the retry delays and attempt budget. Explicit caller cancellation remains terminal.
Set EveStreamReconnectPolicy.StreamIdleRetry.MaxAttempts to give an active
response an explicit finite budget. Progress resets any finite idle budget.
Disable reconnection when a proxy owns cursor recovery:
Cancel the exact response turn¶
Start consuming a response before requesting cancellation. CancelAsync waits
for that response's turn.started, sends its turn identifier as a guard, and
keeps stream consumption attached through the durable boundary:
EveMessageResponse response = await session.SendAsync(
"Run the long operation.",
cancellationToken);
Task<EveTurnOutcome> outcomeTask = response.GetOutcomeAsync(cancellationToken);
EveCancellationOutcome cancellation =
await response.CancelAsync(cancellationToken);
EveTurnOutcome outcome = await outcomeTask;
Concurrent calls share one in-flight cancellation request. The first call's
token controls that request; later callers can cancel only their own wait. A
failed request can be retried while the turn remains active, and a settled
response returns NoActiveTurn without another HTTP request. Use
session.CancelAsync(turnId, cancellationToken) when only an attached session
and an observed turn identifier are available.
Attach to an existing stream¶
await foreach (EveStreamEvent streamEvent in
session.StreamAsync(cancellationToken))
{
// Process historical and future events.
}
Negative start indexes are relative to the current tail and intentionally do not advance the stored absolute cursor.
Bounded catch-up reads¶
Set Follow = false to read everything recorded through the durable tail
observed when the stream opens, then stop instead of waiting for future events:
await foreach (EveStreamEvent streamEvent in session.StreamAsync(
new EveStreamOptions
{
Follow = false,
},
cancellationToken))
{
// Process only the backlog, then regain control.
}
The first bounded request sends includeTailIndex=1 and the server answers with
the x-eve-stream-tail-index response header. That first tail is an immutable
upper bound: reconnects resume from the advancing cursor without requesting or
rebasing the tail, and the stream completes as soon as the cursor passes the
bound, including immediately when the stored cursor is already past it. The
stored cursor advances past every consumed event.
Bounded reads require a nonnegative effective start cursor, so combining
Follow = false with a tail-relative StartIndex throws
ArgumentOutOfRangeException. A server that omits the tail header, or reports a
malformed or out-of-range value, throws EveProtocolException; eve 0.27.6
never reported the header, so bounded reads against that release fail.
Bound individual events¶
The upstream TypeScript client does not limit NDJSON event size. Preserve that behavior by default, or opt into a client-wide UTF-8 byte limit for defense in depth:
The limit applies to one raw NDJSON line before trimming, excluding its line ending.
An oversized event throws EveProtocolException without including the rejected
payload, and the deterministic protocol failure is not retried.