Dispatching actions
Dispatching is optimistic. The action applies to local state before it leaves the machine, so a UI reacts at input latency rather than round-trip latency — and then reconciles against whatever the host actually decided.
The call
Section titled “The call”final handle = runtime.dispatch(channel, action);It returns synchronously. By the time it does, the action has already applied to
optimistic and any listener has already fired. That is the point: nothing
about the UI’s response is waiting on the network.
The handle carries what happens next:
final class RuntimeDispatchHandle { final int clientSeq; final Future<DispatchOutcome> settled;}Settling
Section titled “Settling”settled completes exactly once, three ways:
switch (await handle.settled) { case DispatchConfirmed(:final serverSeq): // The host accepted it and echoed it back at serverSeq. case DispatchRejected(:final reason): // The host refused. The optimistic effect is already reverted. case DispatchDiscarded(:final cause): // It never got an answer — the connection went away first.}None of the three requires you to undo anything. The store has already rebased by the time the future completes; the outcome tells you what happened so you can report it, not repair it.
Rejection is a value rather than an exception, deliberately. A host declining an
action — the turn already finished, the tool call already resolved — is an
ordinary outcome, and awaiting one should not need try.
Confirmed
Section titled “Confirmed”The host applied it and echoed it back. The action moves from optimistic to
confirmed and serverSeq says where it landed in the sequence.
Rejected
Section titled “Rejected”The host refused it. The optimistic effect is gone by the time you see this, and
reason is the host’s explanation — worth surfacing, because it is the only
thing that distinguishes “you cannot do that” from “that failed”.
if (await handle.settled case DispatchRejected(:final reason)) { messenger.showSnackBar(SnackBar(content: Text(reason)));}Discarded
Section titled “Discarded”The connection dropped before the host answered. cause says which flavour:
| Cause | |
|---|---|
DiscardCause.connectionLost |
Socket went away with the action in flight |
DiscardCause.superseded |
A reconnect replaced the state it applied to |
An action that never reached the host is not the same as one that was refused, and treating them alike is how a UI ends up lying about what happened.
Without the runtime
Section titled “Without the runtime”AhpConnection.dispatch works the same way at a lower level, returning a
DispatchHandle with the same three outcomes. The runtime version additionally
survives reconnects — it knows to discard in-flight actions when the state they
applied to is replaced.
Ordering
Section titled “Ordering”clientSeq is per-channel and monotonic. The host echoes it back, which is how
the client recognizes its own action among everything else arriving.
Two dispatches to one channel settle in the order they were made. Across
channels there is no ordering guarantee, because clientSeq is scoped to a
channel and the sequences are independent.