Skip to content

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.

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;
}

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.

The host applied it and echoed it back. The action moves from optimistic to confirmed and serverSeq says where it landed in the sequence.

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)));
}

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.

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.

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.