Summary
The docs state reducers are pure and that "the same reducer code runs on both server and client, which is what makes write-ahead possible" (docs/guide/actions.md L196; also README.md L9). In practice the session reducer reads the ambient wall clock to stamp summary.modifiedAt. In the published @microsoft/agent-host-protocol@0.2.0 TypeScript client, this is a hardcoded inline Date.now() with no injection seam, so an identical (state, action) pair yields different output depending on when it runs.
Evidence (types/channels-session/reducer.ts)
10 Date.now() sites, all stamping summary.modifiedAt: L155 (endTurn), L175 (upsertInputRequest), L298 (turnStarted), L520 (titleChanged), L543 (modelChanged), L549 (agentChanged), L590 (configChanged), L694 (turn truncate), L729 (inputAnswerChanged), L749 (inputCompleted). For example:
case ActionType.SessionTitleChanged:
return { ...state, summary: { ...state.summary, title: action.title, modifiedAt: Date.now() } };
The action carries no timestamp (SessionTitleChangedAction = { type, title }), and sessionReducer(state, action, log?)'s third param is a logging callback, not a clock — so there is no way to make this deterministic per-call in the TS client today.
Why this matters — field ownership
summary.modifiedAt reads as server-authoritative: the server streams it to clients via root/sessionSummaryChanged (state-model.md L381). But under write-ahead reconciliation the client replays the same action to compute optimisticState (reconciliation.md L11, L14), so the client stamps a different modifiedAt (its local Date.now()) than the server stamped — a transient divergence until the server echo overwrites confirmedState. This is the classic "one timestamp, two writers" / domain-value-vs-data-meta problem (e.g. serverModifiedAt vs clientModifiedAt).
Cross-client observation
The Go, Kotlin, and Rust clients deliberately stamp modifiedAt from a clock too — but each exposes an injectable now-seam for deterministic replay/tests:
- Go:
nowProvider / SetNowProvider (clients/go/ahp/reducers.go L33)
- Kotlin:
currentTimestampProvider (Reducers.kt L190–195)
- Rust:
now_ms() + MOCK_NOW_MS (reducers.rs L84–95)
The shared fixture suite encodes this contract (types/test-cases/reducers/030-session-titlechanged-updates-title.json expects modifiedAt: 9999, with each client injecting now = 9999). The TypeScript client is the only one with no seam — its own tests have to monkeypatch the process-global Date.now (reducers.test.ts L114–124), which is racy under concurrency and not controllable per-call.
Questions for maintainers (I don't want to presume your intent)
- Is reducer-stamped
modifiedAt the intended design (clock-on-apply + server-wins echo), with "pure" meaning "pure modulo an injected clock"? If so, would you accept a docs clarification?
- If the clock-on-apply design stands, would you accept bringing the TypeScript client to parity with Go/Kotlin/Rust by adding an injectable now-seam (e.g. an optional
sessionReducer(state, action, { now }) or a module-level currentTimestampProvider), removing the global-Date.now test monkeypatch?
- Or do you consider the optimistic-vs-confirmed
modifiedAt divergence a real defect that warrants threading the timestamp through the action payload or splitting server/client timestamp fields (both wire-breaking)?
Happy to send a PR for whichever direction you prefer. Filing issue-first since the cross-client now-seam precedent suggests this is intentional and the right fix depends on your intent.
Summary
The docs state reducers are pure and that "the same reducer code runs on both server and client, which is what makes write-ahead possible" (
docs/guide/actions.mdL196; alsoREADME.mdL9). In practice the session reducer reads the ambient wall clock to stampsummary.modifiedAt. In the published@microsoft/agent-host-protocol@0.2.0TypeScript client, this is a hardcoded inlineDate.now()with no injection seam, so an identical(state, action)pair yields different output depending on when it runs.Evidence (
types/channels-session/reducer.ts)10
Date.now()sites, all stampingsummary.modifiedAt: L155 (endTurn), L175 (upsertInputRequest), L298 (turnStarted), L520 (titleChanged), L543 (modelChanged), L549 (agentChanged), L590 (configChanged), L694 (turn truncate), L729 (inputAnswerChanged), L749 (inputCompleted). For example:The action carries no timestamp (
SessionTitleChangedAction = { type, title }), andsessionReducer(state, action, log?)'s third param is a logging callback, not a clock — so there is no way to make this deterministic per-call in the TS client today.Why this matters — field ownership
summary.modifiedAtreads as server-authoritative: the server streams it to clients viaroot/sessionSummaryChanged(state-model.mdL381). But under write-ahead reconciliation the client replays the same action to computeoptimisticState(reconciliation.mdL11, L14), so the client stamps a differentmodifiedAt(its localDate.now()) than the server stamped — a transient divergence until the server echo overwritesconfirmedState. This is the classic "one timestamp, two writers" / domain-value-vs-data-meta problem (e.g.serverModifiedAtvsclientModifiedAt).Cross-client observation
The Go, Kotlin, and Rust clients deliberately stamp
modifiedAtfrom a clock too — but each exposes an injectable now-seam for deterministic replay/tests:nowProvider/SetNowProvider(clients/go/ahp/reducers.goL33)currentTimestampProvider(Reducers.ktL190–195)now_ms()+MOCK_NOW_MS(reducers.rsL84–95)The shared fixture suite encodes this contract (
types/test-cases/reducers/030-session-titlechanged-updates-title.jsonexpectsmodifiedAt: 9999, with each client injectingnow = 9999). The TypeScript client is the only one with no seam — its own tests have to monkeypatch the process-globalDate.now(reducers.test.tsL114–124), which is racy under concurrency and not controllable per-call.Questions for maintainers (I don't want to presume your intent)
modifiedAtthe intended design (clock-on-apply + server-wins echo), with "pure" meaning "pure modulo an injected clock"? If so, would you accept a docs clarification?sessionReducer(state, action, { now })or a module-levelcurrentTimestampProvider), removing the global-Date.nowtest monkeypatch?modifiedAtdivergence a real defect that warrants threading the timestamp through the action payload or splitting server/client timestamp fields (both wire-breaking)?Happy to send a PR for whichever direction you prefer. Filing issue-first since the cross-client now-seam precedent suggests this is intentional and the right fix depends on your intent.