diff --git a/types/channels-session/reducer.ts b/types/channels-session/reducer.ts index 7f657a05..88a277b7 100644 --- a/types/channels-session/reducer.ts +++ b/types/channels-session/reducer.ts @@ -32,6 +32,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. */ @@ -154,7 +175,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 { @@ -174,7 +195,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() } }; } /** @@ -297,7 +318,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 @@ -519,7 +540,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: @@ -542,13 +563,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: @@ -588,7 +609,7 @@ export function sessionReducer(state: SessionState, action: SessionAction, log?: }, summary: { ...state.summary, - modifiedAt: Date.now(), + modifiedAt: currentTimestampProvider(), }, }; @@ -748,7 +769,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 { @@ -783,7 +804,7 @@ export function sessionReducer(state: SessionState, action: SessionAction, log?: return { ...state, inputRequests: updated, - summary: { ...state.summary, modifiedAt: Date.now() }, + summary: { ...state.summary, modifiedAt: currentTimestampProvider() }, }; } @@ -803,7 +824,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 5a250428..d1ec31d2 100644 --- a/types/index.ts +++ b/types/index.ts @@ -229,6 +229,8 @@ export { annotationsReducer, resourceWatchReducer, isClientDispatchable, + currentTimestampProvider, + setCurrentTimestampProvider, } from './reducers.js'; // Command types diff --git a/types/reducers.test.ts b/types/reducers.test.ts index 4a99a3dd..a1572b1b 100644 --- a/types/reducers.test.ts +++ b/types/reducers.test.ts @@ -25,6 +25,7 @@ import { annotationsReducer, resourceWatchReducer, isClientDispatchable, + setCurrentTimestampProvider, } from './reducers.js'; import { IS_CLIENT_DISPATCHABLE } from './action-origin.generated.js'; import { ActionType } from './actions.js'; @@ -106,21 +107,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 eb2d615a..e02c6332 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 { annotationsReducer } from './channels-annotations/reducer.js';