Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 31 additions & 10 deletions types/channels-session/reducer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -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 {
Expand All @@ -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() } };
}

/**
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand All @@ -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:
Expand Down Expand Up @@ -588,7 +609,7 @@ export function sessionReducer(state: SessionState, action: SessionAction, log?:
},
summary: {
...state.summary,
modifiedAt: Date.now(),
modifiedAt: currentTimestampProvider(),
},
};

Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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() },
};
}

Expand All @@ -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() },
};
}

Expand Down
2 changes: 2 additions & 0 deletions types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,8 @@ export {
annotationsReducer,
resourceWatchReducer,
isClientDispatchable,
currentTimestampProvider,
setCurrentTimestampProvider,
} from './reducers.js';

// Command types
Expand Down
14 changes: 7 additions & 7 deletions types/reducers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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) {
Expand Down
2 changes: 1 addition & 1 deletion types/reducers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down