From e80ca59b924cbd0152d63c111b63c9ea16656385 Mon Sep 17 00:00:00 2001 From: Joshua Mouch Date: Wed, 3 Jun 2026 16:04:16 -0400 Subject: [PATCH] TS client: add injectable currentTimestampProvider for reducer modifiedAt (#186) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The session reducer stamps summary.modifiedAt from the ambient wall clock (Date.now), which makes an identical (state, action) pair produce different output depending on when it runs — at odds with the documented 'pure reducers / same code on server and client' model. The Go, Kotlin, and Rust clients already expose an injectable now-seam for exactly this (Go SetNowProvider, Kotlin currentTimestampProvider, Rust MOCK_NOW_MS); the TypeScript client was the only one forcing consumers to monkeypatch the process-global Date.now. This adds a non-breaking, default-Date.now currentTimestampProvider + setCurrentTimestampProvider to the session reducer (mirroring Kotlin's name), re-exported through reducers.ts + index.ts, and replaces the 10 inline Date.now() sites with currentTimestampProvider(). The TS fixture test now injects the seam instead of monkeypatching the global Date.now (racy under concurrency), matching the other clients' fixture runners. typecheck clean; all 169 reducer tests pass (incl. the 163 shared fixtures). Refs: #186 Co-Authored-By: Claude Opus 4.8 --- types/channels-session/reducer.ts | 41 +++++++++++++++++++++++-------- types/index.ts | 2 ++ types/reducers.test.ts | 14 +++++------ types/reducers.ts | 2 +- 4 files changed, 41 insertions(+), 18 deletions(-) diff --git a/types/channels-session/reducer.ts b/types/channels-session/reducer.ts index ea162676..bfe71123 100644 --- a/types/channels-session/reducer.ts +++ b/types/channels-session/reducer.ts @@ -30,6 +30,27 @@ import { import type { SessionAction } from '../action-origin.generated.js'; import { softAssertNever } from '../common/reducer-helpers.js'; +// ─── Injectable clock ────────────────────────────────────────────────────────── + +/** + * Provider for the `modifiedAt` timestamps the session reducer stamps. Defaults to `Date.now`. + * + * The reducer is documented as pure, and "the same reducer code runs on both server and client, + * which is what makes write-ahead possible" — but reading the ambient wall clock makes an identical + * `(state, action)` pair produce different `summary.modifiedAt` depending on when it runs. The Go, + * Kotlin, and Rust clients already expose an injectable now-seam for exactly this (Go `SetNowProvider`, + * Kotlin `currentTimestampProvider`, Rust `MOCK_NOW_MS`); the TypeScript client was the only one + * forcing consumers to monkeypatch the process-global `Date.now`. This brings TS to parity. + * + * Tests/hosts set this for deterministic output; production leaves the default. See issue #186. + */ +export let currentTimestampProvider: () => number = () => Date.now(); + +/** Override {@link currentTimestampProvider} (e.g. tests/hosts needing deterministic `modifiedAt`). */ +export function setCurrentTimestampProvider(provider: () => number): void { + currentTimestampProvider = provider; +} + // ─── Helpers ───────────────────────────────────────────────────────────────── /** Extracts the common base fields shared by all tool call lifecycle states. */ @@ -152,7 +173,7 @@ function endTurn( ...state, turns: [...state.turns, turn], activeTurn: undefined, - summary: { ...state.summary, modifiedAt: Date.now() }, + summary: { ...state.summary, modifiedAt: currentTimestampProvider() }, }; delete next.inputRequests; return { @@ -172,7 +193,7 @@ function upsertInputRequest(state: SessionState, request: SessionInputRequest): inputRequests.push(request); } const next = { ...state, inputRequests }; - return { ...next, summary: { ...next.summary, status: withStatusFlag(summaryStatus(next), SessionStatus.IsRead, false), modifiedAt: Date.now() } }; + return { ...next, summary: { ...next.summary, status: withStatusFlag(summaryStatus(next), SessionStatus.IsRead, false), modifiedAt: currentTimestampProvider() } }; } /** @@ -295,7 +316,7 @@ export function sessionReducer(state: SessionState, action: SessionAction, log?: }; next = { ...next, - summary: { ...next.summary, status: withStatusFlag(summaryStatus(next), SessionStatus.IsRead, false), modifiedAt: Date.now() }, + summary: { ...next.summary, status: withStatusFlag(summaryStatus(next), SessionStatus.IsRead, false), modifiedAt: currentTimestampProvider() }, }; // If this turn was auto-started from a pending message, remove it @@ -517,7 +538,7 @@ export function sessionReducer(state: SessionState, action: SessionAction, log?: case ActionType.SessionTitleChanged: return { ...state, - summary: { ...state.summary, title: action.title, modifiedAt: Date.now() }, + summary: { ...state.summary, title: action.title, modifiedAt: currentTimestampProvider() }, }; case ActionType.SessionUsage: @@ -540,13 +561,13 @@ export function sessionReducer(state: SessionState, action: SessionAction, log?: case ActionType.SessionModelChanged: return { ...state, - summary: { ...state.summary, model: action.model, modifiedAt: Date.now() }, + summary: { ...state.summary, model: action.model, modifiedAt: currentTimestampProvider() }, }; case ActionType.SessionAgentChanged: return { ...state, - summary: { ...state.summary, agent: action.agent, modifiedAt: Date.now() }, + summary: { ...state.summary, agent: action.agent, modifiedAt: currentTimestampProvider() }, }; case ActionType.SessionIsReadChanged: @@ -587,7 +608,7 @@ export function sessionReducer(state: SessionState, action: SessionAction, log?: }, summary: { ...state.summary, - modifiedAt: Date.now(), + modifiedAt: currentTimestampProvider(), }, }; @@ -691,7 +712,7 @@ export function sessionReducer(state: SessionState, action: SessionAction, log?: ...state, turns, activeTurn: undefined, - summary: { ...state.summary, modifiedAt: Date.now() }, + summary: { ...state.summary, modifiedAt: currentTimestampProvider() }, }; delete next.inputRequests; return { @@ -726,7 +747,7 @@ export function sessionReducer(state: SessionState, action: SessionAction, log?: return { ...state, inputRequests: updated, - summary: { ...state.summary, modifiedAt: Date.now() }, + summary: { ...state.summary, modifiedAt: currentTimestampProvider() }, }; } @@ -746,7 +767,7 @@ export function sessionReducer(state: SessionState, action: SessionAction, log?: } return { ...next, - summary: { ...next.summary, status: summaryStatus(next), modifiedAt: Date.now() }, + summary: { ...next.summary, status: summaryStatus(next), modifiedAt: currentTimestampProvider() }, }; } diff --git a/types/index.ts b/types/index.ts index 5bfbe59c..b45a732e 100644 --- a/types/index.ts +++ b/types/index.ts @@ -215,6 +215,8 @@ export { changesetReducer, resourceWatchReducer, isClientDispatchable, + currentTimestampProvider, + setCurrentTimestampProvider, } from './reducers.js'; // Command types diff --git a/types/reducers.test.ts b/types/reducers.test.ts index 00c50f1b..80571aa5 100644 --- a/types/reducers.test.ts +++ b/types/reducers.test.ts @@ -24,6 +24,7 @@ import { changesetReducer, resourceWatchReducer, isClientDispatchable, + setCurrentTimestampProvider, } from './reducers.js'; import { IS_CLIENT_DISPATCHABLE } from './action-origin.generated.js'; import { ActionType } from './actions.js'; @@ -107,21 +108,20 @@ const fixtures: Fixture[] = fixtureFiles.map(f => { // ─── Fixture-Driven Reducer Tests ──────────────────────────────────────────── /** - * The reducers call Date.now() for modifiedAt timestamps. - * We mock it to a fixed value (9999) matching what was used during - * fixture generation, so expected values match exactly. + * The reducers stamp modifiedAt via the injectable `currentTimestampProvider` (issue #186). + * We override it to a fixed value (9999) matching what was used during fixture generation, so + * expected values match exactly — no longer monkeypatching the process-global `Date.now` (which was + * racy under concurrency). This mirrors the Go/Kotlin/Rust fixture runners, which inject the same now. */ const MOCK_NOW = 9999; -let originalDateNow: typeof Date.now; describe('reducer fixtures', () => { beforeEach(() => { - originalDateNow = Date.now; - Date.now = () => MOCK_NOW; + setCurrentTimestampProvider(() => MOCK_NOW); }); afterEach(() => { - Date.now = originalDateNow; + setCurrentTimestampProvider(() => Date.now()); }); for (const fixture of fixtures) { diff --git a/types/reducers.ts b/types/reducers.ts index b56a7e94..af15b405 100644 --- a/types/reducers.ts +++ b/types/reducers.ts @@ -6,7 +6,7 @@ */ export { rootReducer } from './channels-root/reducer.js'; -export { sessionReducer } from './channels-session/reducer.js'; +export { sessionReducer, currentTimestampProvider, setCurrentTimestampProvider } from './channels-session/reducer.js'; export { terminalReducer } from './channels-terminal/reducer.js'; export { changesetReducer } from './channels-changeset/reducer.js'; export { resourceWatchReducer } from './channels-resource-watch/reducer.js';