Skip to content

Reducers

A reducer is a pure function from (state, action) to state. Every AHP client ships the same seven — one per channel — and they must behave identically, because that is the only reason two clients converge on the same view of a session.

The wire types in this package are generated from the protocol’s TypeScript sources. The reducers are not.

They are logic rather than schema, so every client hand-ports them from types/channels-*/reducer.ts and proves parity against a shared corpus of 246 fixtures — the same ones the Rust, Go, Swift, and Kotlin clients assert against.

Terminal window
dart test test/reducers_fixture_test.dart # from a checkout

All 246 pass with nothing skipped.

Reducers are pure with one exception: the chat reducer stamps modifiedAt.

That makes it the one place a wall-clock read would break determinism, so the clock is a parameter:

chatReducer(state, action, now: AhpClock.system);

The fixture corpus assumes Date.now() === 9999, which the harness supplies as AhpClock.fixtureFrozen. Parity is unreachable without matching it.

Unlike the Go client, which uses a process-global SetNowProvider, this is a parameter — Dart tests share an isolate and would race each other over a global.

Reconciliation needs the same seam for a second reason. A pending action is replayed on every rebase, and if each replay re-stamped with a fresh now, the optimistic state would change identity on every recompute — non-deterministic tests, and an endless rebuild loop in any bound UI. Pending entries therefore capture a stamp once and replay with that frozen value.

These are not about what a reducer computes but about what it allocates, and the Flutter bindings depend on both.

A no-op arm returns state identically, never an equal copy:

if (action is! SessionAction) return state; // not state.copyWith()

Generated state classes deliberately do not override ==, so ValueNotifier compares by identity. An identical return is free rebuild suppression all the way up the tree.

Untouched sub-objects are structurally shared:

ChatState _appendToken(ChatState s, String id, String token) => s.copyWith(
messages: {...s.messages, id: s.messages[id]!.append(token)},
messageIds: s.messageIds, // the same list instance — do not rebuild it
);

Appending a streamed token must not reallocate the message-id list. If it does, the structural selector described in narrowing rebuilds fires on every token and the whole scheme collapses.

The fixture corpus cannot check either of these — they are about identity, not value — so they are enforced by review.

A host on a newer protocol version may send actions this client has never heard of. The spec says to ignore them silently, and the generated StateAction union has an Unknown variant that preserves the raw payload, so a decode-encode round-trip re-emits it unchanged.

Reducers treat that variant as a no-op rather than a failure. Your client keeps working against a newer host; it simply does not act on what it does not understand.