Skip to content

Reducers documented as "pure" stamp summary.modifiedAt from the wall clock (Date.now); TS client lacks the now-seam the other clients have #186

@joshmouch

Description

@joshmouch

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)

  1. 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?
  2. 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?
  3. 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.

Metadata

Metadata

Assignees

Labels

debtCode quality issues

Type

No type
No fields configured for issues without a type.

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions