Skip to content
Closed
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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@ All notable changes to the **`@reticlehq/*`** packages are documented here (each

## [Unreleased]

### Changed

- **server:** Session tool tests share `createFakeSession` so a new public method is a compile error in one file, not a runtime miss in seven (`#726`).

## [2.13.1] — 2026-09-02

### Fixed
Expand Down
3 changes: 3 additions & 0 deletions packages/server/src/orphan-modules.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,9 @@ const DECLARED_UNWIRED: Record<string, string> = {
'decision logic for scripts/check-stale-issues.mjs, which runs in CI and imports it from dist. ' +
'A repo-hygiene guard has no caller inside the product by definition; the unit tests are here ' +
'so the rule is testable without a network or a repo.',
'session/fake-session.ts':
'test-only Session stub. Extracted after lostSince had to be copied into seven files; ' +
'imported by specs, which this scan deliberately does not count as production importers.',
'project/memory-fs.ts':
'test-only in-memory FileSystemPort. Extracted after a third spec hand-rolled its own copy; ' +
'imported by specs, which this scan deliberately does not count as production importers.',
Expand Down
27 changes: 27 additions & 0 deletions packages/server/src/session/fake-session.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
/**
* `createFakeSession` is a complete Session. A new public method on the class is a compile error
* in fake-session.ts, not a runtime miss in seven unrelated stubs.
*/
import { describe, expect, it } from 'vitest';
import { SessionState } from '@reticlehq/core';
import { createFakeSession } from './fake-session.js';

describe('createFakeSession', () => {
it('supplies the methods that previously had to be copied into every stub', () => {
const session = createFakeSession();
expect(session.lostSince(0)).toBe(false);
expect(session.bufferHealth()).toEqual({ total: 0, dropped: 0 });
expect(session.takeSessionLease()).toBeUndefined();
expect(session.getState()).toBe(SessionState.ACTIVE);
});

it('lets a test override only the field it cares about', () => {
const session = createFakeSession({
id: 'tab-2',
lostSince: () => true,
});
expect(session.id).toBe('tab-2');
expect(session.lostSince(0)).toBe(true);
expect(session.bufferHealth().dropped).toBe(0);
});
});
135 changes: 135 additions & 0 deletions packages/server/src/session/fake-session.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
/**
* A complete `Session` for tool tests. Test-only.
*
* Ad-hoc `Partial<Session> as Session` casts defeat the type system: a new method on Session is a
* runtime error in every stub that never listed it, in files that have nothing to do with the
* change. `inertSession` is typed as the public shape, so the next method is a compile error here
* — and adding it gets a default for free. The single `as Session` on the way out is the private-
* field brand; a class with `#fields` cannot be constructed as a literal.
*
* Callers pass only the fields they actually exercise. Do not change a test's assertions to match
* a default; if a test goes red, the default is wrong for that test — override it.
*/
import {
SessionState,
type CommandResult,
type HumanControlData,
type ImpactSnapshot,
type JournalAction,
type PresenterTone,
type ReticleEvent,
} from '@reticlehq/core';
import { CaptureLedger } from '../honesty/feature-capture.js';
import { GapLedger } from '../honesty/gap-ledger.js';
import type { EventQueryOptions } from '../journal/journal-query.js';
import type { JournalReader, JournalRecorder } from '../journal/journal-recorder.js';
import { LastAct } from './last-act.js';
import type { Session } from './session.js';
import type { SessionHealth } from './session-health.js';
import type { SessionInfo } from './session-info.js';
import type { SessionLease } from './session-lease.js';
import type { InboxMessage } from './live-control.js';
import type { ReviewMark } from './review-store.js';

/** Public members only — private fields are why a stub cannot be a real `Session` without a cast. */
type SessionShape = { [K in keyof Session]: Session[K] };

const OK: CommandResult = { kind: 'command_result', id: 'c', ok: true, result: {} };

const HEALTHY: SessionHealth = { lastSeenMs: 0, throttled: false, focused: true };

function inertSession(): SessionShape {
return {
id: 'demo',
projectId: undefined,
artifactRoot: undefined,
url: 'http://localhost:5173/',
title: '',
adapters: [],
hasCapabilities: false,
redactKeys: [],
lastAct: new LastAct(),
gaps: new GapLedger(),
capture: new CaptureLedger(),
runtime: undefined,
engine: undefined,
brand: undefined,
currentDocumentId: undefined,
currentEditEpoch: undefined,
actionCount: 0,
elapsed: () => 0,
touch: () => undefined,
lastSeenMs: () => 0,
applyHealth: () => undefined,
throttled: () => false,
health: () => HEALTHY,
info: (): SessionInfo => ({
sessionId: 'demo',
url: 'http://localhost:5173/',
adapters: [],
hasCapabilities: false,
lastSeenMs: 0,
hidden: false,
focused: true,
throttled: false,
}),
unresponsive: () => false,
staleMs: () => 0,
pushEvent: (_event: ReticleEvent, _byteSize?: number) => undefined,
recordActedRef: (_ref: string) => undefined,
actedLabels: () => new Set<string>(),
actedRefs: () => new Set<string>(),
noteRateLimited: (_dropped: number) => undefined,
blindSpots: () => ({}),
ambientCounts: () => ({}),
ownAmbientCounts: () => ({}),
seedAmbient: () => undefined,
setJournal: (_recorder: JournalRecorder, _reader?: JournalReader) => undefined,
queryEvents: (_options: EventQueryOptions) => Promise.resolve([]),
beginAction: (_tool: string, _args: Record<string, unknown>) => 'a1',
recordAction: (_tool: string, _args: Record<string, unknown>, _effect?: unknown) => 'a1',
finishAction: (_effect?: unknown, _settled?: boolean, _settledInMs?: number) => undefined,
readJournalActions: () => Promise.resolve([] as JournalAction[]),
flushJournal: () => Promise.resolve(),
eventsSince: (_cursor: number) => [],
markAgentActivity: () => undefined,
agentIdleMs: () => 0,
idleEndMs: () => 0,
setIdleEndMs: (_ms: number) => undefined,
autoEnd: (_text?: string, _tone?: PresenterTone) => undefined,
eventsInWindow: (_windowMs: number) => [],
bufferHealth: () => ({ total: 0, dropped: 0 }),
lostSince: (_cursor: number) => false,
onEvent: (_listener: (event: ReticleEvent) => void) => () => undefined,
onDisconnect: (_listener: () => void) => () => undefined,
command: (_name: string, _args?: Record<string, unknown>, _timeoutMs?: number) =>
Promise.resolve(OK),
handleResult: (_result: CommandResult) => undefined,
rejectAll: (_reason: string, _replaced?: boolean) => undefined,
succeededBy: (_next: Session) => undefined,
disconnect: (_reason: string, _replaced?: boolean) => undefined,
getState: () => SessionState.ACTIVE,
isPaused: () => false,
isEnded: () => false,
setState: (_next: SessionState, _text?: string, _tone?: PresenterTone) => undefined,
pushMessage: (_text: string) => undefined,
drainInbox: () => [] as InboxMessage[],
inboxSize: () => 0,
inboxHistory: () => [] as InboxMessage[],
pendingMarks: () => [] as ReviewMark[],
allMarks: () => [] as ReviewMark[],
pendingMarkCount: () => 0,
resolveMark: (_id: string) => false,
applyHumanControl: (_data: HumanControlData) => undefined,
pushImpact: (_read: () => ImpactSnapshot | undefined, _immediate?: boolean) => undefined,
pushPresenter: (_state: SessionState, _text?: string, _tone?: PresenterTone) => undefined,
pushNarration: (_text: string) => undefined,
takeSessionLease: (): SessionLease | undefined => undefined,
ageWarning: () => undefined,
setViewers: (_read: () => Session[]) => undefined,
};
}

export function createFakeSession(overrides: Partial<Session> = {}): Session {
return { ...inertSession(), ...overrides } as Session;
}
7 changes: 3 additions & 4 deletions packages/server/src/session/tools.session-health.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { FlowStore } from '../flows/flows.js';
import { ProjectStore } from '../project/project-store.js';
import { AnnotationStore } from '../flows/annotation-store.js';
import type { Session, SessionInfo, SessionManager } from './session.js';
import { createFakeSession } from './fake-session.js';

const SESSION_URL = 'http://localhost:5173/app';

Expand All @@ -31,7 +32,7 @@ function fakeSession(throttled: boolean): Session {
recommendation: UNSCRIPTABLE_TAB_RECOMMENDATION,
}
: { lastSeenMs: 0, throttled: false, focused: true };
const stub: Partial<Session> = {
return createFakeSession({
id: 'demo',
url: SESSION_URL,
elapsed: () => 0,
Expand All @@ -49,12 +50,10 @@ function fakeSession(throttled: boolean): Session {
lostSince: () => false,
blindSpots: () => ({}),
throttled: () => throttled,
// Live-control: a clean active session — no pause short-circuit, no piggyback.
getState: () => SessionState.ACTIVE,
drainInbox: () => [],
inboxSize: () => 0,
};
return stub as Session;
});
}

function fakeDeps(throttled: boolean, listRows: SessionInfo[]): ToolDeps {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@ import {
import { LastAct } from '../session/last-act.js';
import { TOOLS, type ToolDef, type ToolDeps } from './tools.js';
import { ReticleTool } from './tool-names.js';
import type { Session, SessionManager } from '../session/session.js';
import type { SessionManager } from '../session/session.js';
import { createFakeSession } from '../session/fake-session.js';

/**
* `reticle_assert` must SURFACE a contradiction, not merely be able to find one.
Expand All @@ -33,7 +34,7 @@ import type { Session, SessionManager } from '../session/session.js';
* well-tested producer, consumers that stub it, and nothing on the delegation between them.
*/
function depsWith(events: ReticleEvent[]): ToolDeps {
const session: Partial<Session> = {
const session = createFakeSession({
id: 'demo',
recordAction: () => 'a1',
lastAct: new LastAct(),
Expand All @@ -43,11 +44,11 @@ function depsWith(events: ReticleEvent[]): ToolDeps {
eventsSince: () => events,
queryEvents: () => Promise.resolve(events),
elapsed: () => 1000,
health: () => ({ lastSeenMs: 5, throttled: false, focused: true, hidden: false }),
health: () => ({ lastSeenMs: 5, throttled: false, focused: true }),
getState: () => SessionState.ACTIVE,
drainInbox: () => [],
};
const sessions: Partial<SessionManager> = { resolve: () => session as Session };
});
const sessions: Partial<SessionManager> = { resolve: () => session };
return { sessions: sessions as SessionManager } as unknown as ToolDeps;
}

Expand Down
25 changes: 13 additions & 12 deletions packages/server/src/tools/assert-coverage-honesty.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,8 @@ import { RecordingStore } from '../flows/recordings.js';
import { FlowStore } from '../flows/flows.js';
import { ProjectStore } from '../project/project-store.js';
import { AnnotationStore } from '../flows/annotation-store.js';
import type { Session, SessionManager } from '../session/session.js';
import type { SessionManager } from '../session/session.js';
import { createFakeSession } from '../session/fake-session.js';

/**
* A green verdict must never imply more coverage than the SDK actually had.
Expand All @@ -38,7 +39,7 @@ function depsWithBlindSpots(
blindSpots: Record<string, number>,
runtime?: (typeof AppRuntime)[keyof typeof AppRuntime],
): ToolDeps {
const session: Partial<Session> = {
const session = createFakeSession({
id: 'demo',
bufferHealth: () => ({ total: 5, dropped: 0 }),
lostSince: () => false,
Expand All @@ -55,12 +56,12 @@ function depsWithBlindSpots(
eventsSince: () => [],
queryEvents: () => Promise.resolve([]),
elapsed: () => 1000,
health: () => ({ lastSeenMs: 5, throttled: false, focused: true, hidden: false }),
health: () => ({ lastSeenMs: 5, throttled: false, focused: true }),
getState: () => SessionState.ACTIVE,
drainInbox: () => [],
...(runtime === undefined ? {} : { runtime }),
};
const sessions: Partial<SessionManager> = { resolve: () => session as Session };
});
const sessions: Partial<SessionManager> = { resolve: () => session };
return { sessions: sessions as SessionManager } as unknown as ToolDeps;
}

Expand Down Expand Up @@ -222,7 +223,7 @@ describe('reticle_assert carries the verdict, not just pass', () => {
// only in act_and_wait — the tool an agent actually calls never consulted it.
/** A console-absence assertion over a window we control, so only the WRITE varies. */
const runAssert = async (events: ReticleEvent[]): Promise<unknown> => {
const session: Partial<Session> = {
const session = createFakeSession({
id: 'demo',
bufferHealth: () => ({ total: 5, dropped: 0 }),
lostSince: () => false,
Expand All @@ -232,11 +233,11 @@ describe('reticle_assert carries the verdict, not just pass', () => {
eventsSince: () => events,
queryEvents: () => Promise.resolve(events),
elapsed: () => 1000,
health: () => ({ lastSeenMs: 5, throttled: false, focused: true, hidden: false }),
health: () => ({ lastSeenMs: 5, throttled: false, focused: true }),
getState: () => SessionState.ACTIVE,
drainInbox: () => [],
};
const sessions: Partial<SessionManager> = { resolve: () => session as Session };
});
const sessions: Partial<SessionManager> = { resolve: () => session };
const deps = { sessions: sessions as SessionManager } as unknown as ToolDeps;
return tool(ReticleTool.ASSERT).handler(deps, absentConsole);
};
Expand Down Expand Up @@ -284,7 +285,7 @@ describe('reticle_act_and_wait downgrades absence when blind spots hide the targ
result: { dispatched: true, settled: true, effect: { domMutatedWithin: 1 } },
});
};
const session: Partial<Session> = {
const session = createFakeSession({
id: 'demo',
url: 'http://localhost:5173/app',
elapsed: () => 1000,
Expand All @@ -304,8 +305,8 @@ describe('reticle_act_and_wait downgrades absence when blind spots hide the targ
inboxSize: () => 0,
onEvent: () => () => undefined,
ambientCounts: () => ({}),
};
const sessions: Partial<SessionManager> = { resolve: () => session as Session };
});
const sessions: Partial<SessionManager> = { resolve: () => session };
return {
sessions: sessions as SessionManager,
baselines: new BaselineStore(),
Expand Down
12 changes: 6 additions & 6 deletions packages/server/src/tools/assert-source-attribution.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import { FlowStore } from '../flows/flows.js';
import { ProjectStore } from '../project/project-store.js';
import { AnnotationStore } from '../flows/annotation-store.js';
import type { Session, SessionManager } from '../session/session.js';
import { createFakeSession } from '../session/fake-session.js';

const ACTED_SOURCE = 'app/page.tsx:22';
const NAV_SOURCE = 'ui/global-nav.tsx:54';
Expand Down Expand Up @@ -59,7 +60,7 @@ function fakeSession(
ok: true,
result: { matched: elements.length > 0, count: elements.length, elements },
});
const stub: Partial<Session> = {
const session = createFakeSession({
id: 'demo',
url: 'http://localhost:3000/',
lastAct,
Expand All @@ -80,8 +81,7 @@ function fakeSession(
throttled: () => false,
getState: () => SessionState.ACTIVE,
drainInbox: () => [],
};
const session = stub as Session;
});
const sessions: Partial<SessionManager> = { resolve: () => session };
return { session, deps: { sessions: sessions as SessionManager } as unknown as ToolDeps };
}
Expand Down Expand Up @@ -190,7 +190,7 @@ describe('act_and_wait still reports the element it drove', () => {
effect: { domMutatedWithin: 1 },
},
});
const stub: Partial<Session> = {
const session = createFakeSession({
id: 'demo',
url: 'http://localhost:3000/',
lastAct: new LastAct(),
Expand All @@ -212,8 +212,8 @@ describe('act_and_wait still reports the element it drove', () => {
onEvent: () => () => undefined,
ambientCounts: () => ({}),
elapsed: () => 1000,
};
const sessions: Partial<SessionManager> = { resolve: () => stub as Session };
});
const sessions: Partial<SessionManager> = { resolve: () => session };
return {
sessions,
baselines: new BaselineStore(),
Expand Down
Loading
Loading