refactor(server/tests): solve class-stub mock pattern (T1.e) (#757)

Introduce createStub<T> (Proxy-based) and asInternals<T> (single-cast wrapper)
in test-utils/class-mocks.ts to replace all as unknown as casts in server test
files that construct fakes of private-field classes or access class internals.

- class-mocks.ts: one justified as unknown as T cast inside createStub; Proxy
  throws on any unstubbed method call, adding real runtime safety vs bare cast
- session-stubs.ts: all 19 as unknown as casts replaced with createStub/asInternals
- websocket-server.notifications.test.ts + relay-reconnect.test.ts: createStub
  for HTTPServer / pino.Logger / AgentManager stubs; asInternals for internals access
- snapshot-mutation-ownership.test.ts: createStub for SessionOptions field stubs
- session.workspace-git-watch.test.ts: createStub for all SessionOptions fields;
  asInternals for SessionInternals access
- session.workspace-resolution-invariants.test.ts: createStub + asInternals
- acp-agent.test.ts, claude-agent.redesign.test.ts, codex-app-server-agent.test.ts,
  acp-wrapper-smoke.test.ts: asInternals for all internal-access casts

Errors cleared: ~80 as unknown as casts removed across 11 files.
Lint: 0 errors on all 11 files. Typecheck: green.
This commit is contained in:
Mohamed Boudra
2026-05-05 19:58:56 +07:00
parent eb6d19969d
commit 53e1be4da6
11 changed files with 307 additions and 226 deletions
@@ -22,6 +22,7 @@ import {
import { transformPiModels } from "./pi-direct-agent.js"; import { transformPiModels } from "./pi-direct-agent.js";
import type { AgentStreamEvent } from "../agent-sdk-types.js"; import type { AgentStreamEvent } from "../agent-sdk-types.js";
import { createTestLogger } from "../../../test-utils/test-logger.js"; import { createTestLogger } from "../../../test-utils/test-logger.js";
import { asInternals } from "../../test-utils/class-mocks.js";
interface ACPSessionInternals { interface ACPSessionInternals {
sessionId: string | null; sessionId: string | null;
@@ -159,7 +160,7 @@ function prepareConfiguredOverrideSession(
const setSessionConfigOption = vi.fn(async () => ({ const setSessionConfigOption = vi.fn(async () => ({
configOptions: options.configOptions ?? [], configOptions: options.configOptions ?? [],
})); }));
const internals = session as unknown as ACPConfiguredOverrideInternals; const internals = asInternals<ACPConfiguredOverrideInternals>(session);
internals.sessionId = "session-1"; internals.sessionId = "session-1";
internals.connection = { internals.connection = {
setSessionMode, setSessionMode,
@@ -179,7 +180,7 @@ function prepareConfiguredOverrideSession(
test("ACP setModel only uses config-option fallback when the matching select choice contains the model", async () => { test("ACP setModel only uses config-option fallback when the matching select choice contains the model", async () => {
const logger = createTestLogger(); const logger = createTestLogger();
const childLogger = { warn: vi.fn() }; const childLogger = { warn: vi.fn() };
vi.spyOn(logger, "child").mockReturnValue(childLogger as unknown as typeof logger); vi.spyOn(logger, "child").mockReturnValue(asInternals<typeof logger>(childLogger));
const session = createSessionWithConfig({}, logger); const session = createSessionWithConfig({}, logger);
const setSessionConfigOption = vi.fn(async () => ({ const setSessionConfigOption = vi.fn(async () => ({
configOptions: [ configOptions: [
@@ -193,7 +194,7 @@ test("ACP setModel only uses config-option fallback when the matching select cho
}, },
], ],
})); }));
const internals = session as unknown as ACPModelSelectionInternals; const internals = asInternals<ACPModelSelectionInternals>(session);
internals.sessionId = "session-1"; internals.sessionId = "session-1";
internals.connection = { setSessionConfigOption }; internals.connection = { setSessionConfigOption };
internals.configOptions = [ internals.configOptions = [
@@ -235,7 +236,7 @@ describe("createLoggedNdJsonStream", () => {
const consoleError = vi.spyOn(console, "error").mockImplementation(() => {}); const consoleError = vi.spyOn(console, "error").mockImplementation(() => {});
const stream = createLoggedNdJsonStream(output.writable, input.readable, { const stream = createLoggedNdJsonStream(output.writable, input.readable, {
logger: logger as unknown as ReturnType<typeof createTestLogger>, logger: asInternals<ReturnType<typeof createTestLogger>>(logger),
provider: "gemini", provider: "gemini",
}); });
const reader = stream.readable.getReader(); const reader = stream.readable.getReader();
@@ -276,7 +277,7 @@ describe("createLoggedNdJsonStream", () => {
}; };
const stream = createLoggedNdJsonStream(output.writable, input.readable, { const stream = createLoggedNdJsonStream(output.writable, input.readable, {
logger: logger as unknown as ReturnType<typeof createTestLogger>, logger: asInternals<ReturnType<typeof createTestLogger>>(logger),
provider: "gemini", provider: "gemini",
}); });
const reader = stream.readable.getReader(); const reader = stream.readable.getReader();
@@ -475,7 +476,7 @@ describe("ACPAgentSession Zed parity", () => {
modelId: "sonnet", modelId: "sonnet",
}); });
const modeEvents = (validSession as unknown as ACPSessionInternals).translateSessionUpdate({ const modeEvents = asInternals<ACPSessionInternals>(validSession).translateSessionUpdate({
sessionUpdate: "current_mode_update", sessionUpdate: "current_mode_update",
currentModeId: "default", currentModeId: "default",
}); });
@@ -494,7 +495,7 @@ describe("ACPAgentSession Zed parity", () => {
const logger = createTestLogger(); const logger = createTestLogger();
const childLogger = { warn: vi.fn() }; const childLogger = { warn: vi.fn() };
vi.spyOn(logger, "child").mockReturnValue(childLogger as unknown as typeof logger); vi.spyOn(logger, "child").mockReturnValue(asInternals<typeof logger>(childLogger));
const invalidSession = createSessionWithConfig( const invalidSession = createSessionWithConfig(
{ modeId: "acceptEdits", model: "opus" }, { modeId: "acceptEdits", model: "opus" },
logger, logger,
@@ -540,7 +541,7 @@ describe("ACPAgentSession Zed parity", () => {
test("routes config_option_update and refreshes derived mode, model, and thinking state", async () => { test("routes config_option_update and refreshes derived mode, model, and thinking state", async () => {
const session = createSession(); const session = createSession();
const internals = session as unknown as ACPSessionInternals; const internals = asInternals<ACPSessionInternals>(session);
const events = internals.translateSessionUpdate({ const events = internals.translateSessionUpdate({
sessionUpdate: "config_option_update", sessionUpdate: "config_option_update",
@@ -595,7 +596,7 @@ describe("ACPAgentSession Zed parity", () => {
test("keeps pushed mode when a later config_option_update has no mode payload", async () => { test("keeps pushed mode when a later config_option_update has no mode payload", async () => {
const session = createSession(); const session = createSession();
const internals = session as unknown as ACPSessionInternals; const internals = asInternals<ACPSessionInternals>(session);
internals.translateSessionUpdate({ internals.translateSessionUpdate({
sessionUpdate: "current_mode_update", sessionUpdate: "current_mode_update",
@@ -616,7 +617,7 @@ describe("ACPAgentSession Zed parity", () => {
test("uses last writer when current_mode_update and config_option_update both include a mode", async () => { test("uses last writer when current_mode_update and config_option_update both include a mode", async () => {
const session = createSession(); const session = createSession();
const internals = session as unknown as ACPSessionInternals; const internals = asInternals<ACPSessionInternals>(session);
const configEvents = internals.translateSessionUpdate({ const configEvents = internals.translateSessionUpdate({
sessionUpdate: "config_option_update", sessionUpdate: "config_option_update",
@@ -634,7 +635,7 @@ describe("ACPAgentSession Zed parity", () => {
test("uses canonical mode returned by setSessionConfigOption response", async () => { test("uses canonical mode returned by setSessionConfigOption response", async () => {
const session = createSession(); const session = createSession();
const internals = session as unknown as ACPModelSelectionInternals; const internals = asInternals<ACPModelSelectionInternals>(session);
const events: AgentStreamEvent[] = []; const events: AgentStreamEvent[] = [];
const unsubscribe = session.subscribe((event) => events.push(event)); const unsubscribe = session.subscribe((event) => events.push(event));
internals.sessionId = "session-1"; internals.sessionId = "session-1";
@@ -664,7 +665,7 @@ describe("ACPAgentSession Zed parity", () => {
test("uses canonical model returned by setSessionConfigOption response", async () => { test("uses canonical model returned by setSessionConfigOption response", async () => {
const session = createSession(); const session = createSession();
const internals = session as unknown as ACPModelSelectionInternals; const internals = asInternals<ACPModelSelectionInternals>(session);
const events: AgentStreamEvent[] = []; const events: AgentStreamEvent[] = [];
const unsubscribe = session.subscribe((event) => events.push(event)); const unsubscribe = session.subscribe((event) => events.push(event));
internals.sessionId = "session-1"; internals.sessionId = "session-1";
@@ -688,7 +689,7 @@ describe("ACPAgentSession Zed parity", () => {
test("uses canonical thinking option returned by setSessionConfigOption response", async () => { test("uses canonical thinking option returned by setSessionConfigOption response", async () => {
const session = createSession(); const session = createSession();
const internals = session as unknown as ACPModelSelectionInternals; const internals = asInternals<ACPModelSelectionInternals>(session);
const events: AgentStreamEvent[] = []; const events: AgentStreamEvent[] = [];
const unsubscribe = session.subscribe((event) => events.push(event)); const unsubscribe = session.subscribe((event) => events.push(event));
internals.sessionId = "session-1"; internals.sessionId = "session-1";
@@ -725,7 +726,7 @@ describe("ACPAgentSession Zed parity", () => {
{ optionId: "reject-once", name: "Reject", kind: "reject_once" }, { optionId: "reject-once", name: "Reject", kind: "reject_once" },
]; ];
(session as unknown as ACPSessionInternals).sessionId = "session-1"; asInternals<ACPSessionInternals>(session).sessionId = "session-1";
session.subscribe((event) => { session.subscribe((event) => {
events.push(event as { type: string; request?: { id: string } }); events.push(event as { type: string; request?: { id: string } });
}); });
@@ -869,7 +870,7 @@ describe("ACPAgentClient modelTransformer", () => {
}), }),
}, },
initialize: { agentCapabilities: {} }, initialize: { agentCapabilities: {} },
} as unknown as SpawnedACPProcess; } as SpawnedACPProcess;
} }
protected override async closeProbe(): Promise<void> {} protected override async closeProbe(): Promise<void> {}
@@ -915,7 +916,7 @@ describe("ACPAgentClient sessionResponseTransformer", () => {
newSession: vi.fn().mockResolvedValue(response), newSession: vi.fn().mockResolvedValue(response),
}, },
initialize: { agentCapabilities: {} }, initialize: { agentCapabilities: {} },
} as unknown as SpawnedACPProcess; } as SpawnedACPProcess;
} }
protected override async closeProbe(): Promise<void> {} protected override async closeProbe(): Promise<void> {}
@@ -956,7 +957,7 @@ describe("ACPAgentClient listModes", () => {
child: { kill: vi.fn(), exitCode: 0, signalCode: null, once: vi.fn() }, child: { kill: vi.fn(), exitCode: 0, signalCode: null, once: vi.fn() },
connection: { newSession }, connection: { newSession },
initialize: { agentCapabilities: {} }, initialize: { agentCapabilities: {} },
} as unknown as SpawnedACPProcess; } as SpawnedACPProcess;
} }
protected override async closeProbe(): Promise<void> {} protected override async closeProbe(): Promise<void> {}
@@ -1007,7 +1008,7 @@ describe("ACPAgentClient listModes", () => {
}), }),
}, },
initialize: { agentCapabilities: {} }, initialize: { agentCapabilities: {} },
} as unknown as SpawnedACPProcess; } as SpawnedACPProcess;
} }
protected override async closeProbe(): Promise<void> {} protected override async closeProbe(): Promise<void> {}
@@ -1111,7 +1112,7 @@ describe("ACPAgentSession slash commands", () => {
const listCommandsPromise = session.listCommands(); const listCommandsPromise = session.listCommands();
(session as unknown as ACPSessionInternals).translateSessionUpdate({ asInternals<ACPSessionInternals>(session).translateSessionUpdate({
sessionUpdate: "available_commands_update", sessionUpdate: "available_commands_update",
availableCommands: [ availableCommands: [
{ {
@@ -1157,7 +1158,7 @@ describe("ACPAgentSession", () => {
test("emits assistant and reasoning chunks as deltas while user chunks stay accumulated", async () => { test("emits assistant and reasoning chunks as deltas while user chunks stay accumulated", async () => {
const session = createSession(); const session = createSession();
const events: Array<{ type: string; item?: { type: string; text?: string } }> = []; const events: Array<{ type: string; item?: { type: string; text?: string } }> = [];
(session as unknown as ACPSessionInternals).sessionId = "session-1"; asInternals<ACPSessionInternals>(session).sessionId = "session-1";
session.subscribe((event) => { session.subscribe((event) => {
events.push(event as { type: string; item?: { type: string; text?: string } }); events.push(event as { type: string; item?: { type: string; text?: string } });
@@ -1169,7 +1170,7 @@ describe("ACPAgentSession", () => {
sessionUpdate: "agent_message_chunk", sessionUpdate: "agent_message_chunk",
messageId: "assistant-1", messageId: "assistant-1",
content: { type: "text", text: "Hey!" }, content: { type: "text", text: "Hey!" },
} as unknown as SessionUpdate, } as SessionUpdate,
}); });
await session.sessionUpdate({ await session.sessionUpdate({
sessionId: "session-1", sessionId: "session-1",
@@ -1177,7 +1178,7 @@ describe("ACPAgentSession", () => {
sessionUpdate: "agent_message_chunk", sessionUpdate: "agent_message_chunk",
messageId: "assistant-1", messageId: "assistant-1",
content: { type: "text", text: " How are you?" }, content: { type: "text", text: " How are you?" },
} as unknown as SessionUpdate, } as SessionUpdate,
}); });
await session.sessionUpdate({ await session.sessionUpdate({
sessionId: "session-1", sessionId: "session-1",
@@ -1185,7 +1186,7 @@ describe("ACPAgentSession", () => {
sessionUpdate: "agent_thought_chunk", sessionUpdate: "agent_thought_chunk",
messageId: "thought-1", messageId: "thought-1",
content: { type: "text", text: "Thinking" }, content: { type: "text", text: "Thinking" },
} as unknown as SessionUpdate, } as SessionUpdate,
}); });
await session.sessionUpdate({ await session.sessionUpdate({
sessionId: "session-1", sessionId: "session-1",
@@ -1193,7 +1194,7 @@ describe("ACPAgentSession", () => {
sessionUpdate: "agent_thought_chunk", sessionUpdate: "agent_thought_chunk",
messageId: "thought-1", messageId: "thought-1",
content: { type: "text", text: " more" }, content: { type: "text", text: " more" },
} as unknown as SessionUpdate, } as SessionUpdate,
}); });
await session.sessionUpdate({ await session.sessionUpdate({
sessionId: "session-1", sessionId: "session-1",
@@ -1201,7 +1202,7 @@ describe("ACPAgentSession", () => {
sessionUpdate: "user_message_chunk", sessionUpdate: "user_message_chunk",
messageId: "user-1", messageId: "user-1",
content: { type: "text", text: "hel" }, content: { type: "text", text: "hel" },
} as unknown as SessionUpdate, } as SessionUpdate,
}); });
await session.sessionUpdate({ await session.sessionUpdate({
sessionId: "session-1", sessionId: "session-1",
@@ -1209,7 +1210,7 @@ describe("ACPAgentSession", () => {
sessionUpdate: "user_message_chunk", sessionUpdate: "user_message_chunk",
messageId: "user-1", messageId: "user-1",
content: { type: "text", text: "lo" }, content: { type: "text", text: "lo" },
} as unknown as SessionUpdate, } as SessionUpdate,
}); });
const timeline = events const timeline = events
@@ -1238,8 +1239,8 @@ describe("ACPAgentSession", () => {
}), }),
); );
(session as unknown as ACPSessionInternals).sessionId = "session-1"; asInternals<ACPSessionInternals>(session).sessionId = "session-1";
(session as unknown as ACPSessionInternals).connection = { prompt }; asInternals<ACPSessionInternals>(session).connection = { prompt };
session.subscribe((event) => { session.subscribe((event) => {
events.push(event as { type: string; turnId?: string }); events.push(event as { type: string; turnId?: string });
@@ -1252,7 +1253,7 @@ describe("ACPAgentSession", () => {
type: "turn_started", type: "turn_started",
turnId, turnId,
}); });
expect((session as unknown as ACPSessionInternals).activeForegroundTurnId).toBe(turnId); expect(asInternals<ACPSessionInternals>(session).activeForegroundTurnId).toBe(turnId);
resolvePrompt({ stopReason: "end_turn", usage: { outputTokens: 3 } }); resolvePrompt({ stopReason: "end_turn", usage: { outputTokens: 3 } });
await Promise.resolve(); await Promise.resolve();
@@ -1262,7 +1263,7 @@ describe("ACPAgentSession", () => {
type: "turn_completed", type: "turn_completed",
turnId, turnId,
}); });
expect((session as unknown as ACPSessionInternals).activeForegroundTurnId).toBeNull(); expect(asInternals<ACPSessionInternals>(session).activeForegroundTurnId).toBeNull();
}); });
test("startTurn converts background prompt rejections into turn_failed events", async () => { test("startTurn converts background prompt rejections into turn_failed events", async () => {
@@ -1276,8 +1277,8 @@ describe("ACPAgentSession", () => {
}), }),
); );
(session as unknown as ACPSessionInternals).sessionId = "session-1"; asInternals<ACPSessionInternals>(session).sessionId = "session-1";
(session as unknown as ACPSessionInternals).connection = { prompt }; asInternals<ACPSessionInternals>(session).connection = { prompt };
session.subscribe((event) => { session.subscribe((event) => {
events.push(event as { type: string; turnId?: string; error?: string }); events.push(event as { type: string; turnId?: string; error?: string });
@@ -1295,6 +1296,6 @@ describe("ACPAgentSession", () => {
turnId, turnId,
error: "prompt failed", error: "prompt failed",
}); });
expect((session as unknown as ACPSessionInternals).activeForegroundTurnId).toBeNull(); expect(asInternals<ACPSessionInternals>(session).activeForegroundTurnId).toBeNull();
}); });
}); });
@@ -14,6 +14,7 @@ import type {
AgentTimelineItem, AgentTimelineItem,
} from "../agent-sdk-types.js"; } from "../agent-sdk-types.js";
import { ACPAgentSession } from "./acp-agent.js"; import { ACPAgentSession } from "./acp-agent.js";
import { asInternals } from "../../test-utils/class-mocks.js";
const smokeSelection = new Set( const smokeSelection = new Set(
(process.env.ACP_SMOKE ?? "") (process.env.ACP_SMOKE ?? "")
@@ -117,7 +118,7 @@ function createSmokeLogger(): SmokeLogger {
error: vi.fn(), error: vi.fn(),
fatal: vi.fn(), fatal: vi.fn(),
}; };
return logger as unknown as SmokeLogger; return asInternals<SmokeLogger>(logger);
} }
function createTrace(config: WrapperSmokeConfig): SmokeTrace { function createTrace(config: WrapperSmokeConfig): SmokeTrace {
@@ -365,7 +366,7 @@ async function bootSession(
} }
async function captureFinalState(session: ACPAgentSession, trace: SmokeTrace): Promise<void> { async function captureFinalState(session: ACPAgentSession, trace: SmokeTrace): Promise<void> {
const internals = session as unknown as SessionInternals; const internals = asInternals<SessionInternals>(session);
trace.finalState = { trace.finalState = {
runtime: await session.getRuntimeInfo(), runtime: await session.getRuntimeInfo(),
availableModes: await session.getAvailableModes(), availableModes: await session.getAvailableModes(),
@@ -376,7 +377,7 @@ async function captureFinalState(session: ACPAgentSession, trace: SmokeTrace): P
} }
function getModelIds(session: ACPAgentSession): string[] { function getModelIds(session: ACPAgentSession): string[] {
const internals = session as unknown as SessionInternals; const internals = asInternals<SessionInternals>(session);
return internals.availableModels?.map((model) => model.modelId) ?? []; return internals.availableModels?.map((model) => model.modelId) ?? [];
} }
@@ -384,7 +385,7 @@ function getSelectOption(
session: ACPAgentSession, session: ACPAgentSession,
category: string, category: string,
): Extract<SessionConfigOption, { type: "select" }> | null { ): Extract<SessionConfigOption, { type: "select" }> | null {
const internals = session as unknown as SessionInternals; const internals = asInternals<SessionInternals>(session);
return ( return (
internals.configOptions.find( internals.configOptions.find(
(option): option is Extract<SessionConfigOption, { type: "select" }> => (option): option is Extract<SessionConfigOption, { type: "select" }> =>
@@ -515,7 +516,7 @@ for (const config of wrappers) {
const modeAfterSuccess = await session.getCurrentMode(); const modeAfterSuccess = await session.getCurrentMode();
originalSessionId = session.id; originalSessionId = session.id;
(session as unknown as { sessionId: string }).sessionId = `${originalSessionId}-missing`; asInternals<{ sessionId: string }>(session).sessionId = `${originalSessionId}-missing`;
await expect(session.setMode(mode.id)).rejects.toThrow(); await expect(session.setMode(mode.id)).rejects.toThrow();
expect(await session.getCurrentMode()).toBe(modeAfterSuccess); expect(await session.getCurrentMode()).toBe(modeAfterSuccess);
trace.notes.push( trace.notes.push(
@@ -523,7 +524,7 @@ for (const config of wrappers) {
); );
} finally { } finally {
if (session && originalSessionId) { if (session && originalSessionId) {
(session as unknown as { sessionId: string }).sessionId = originalSessionId; asInternals<{ sessionId: string }>(session).sessionId = originalSessionId;
} }
await captureFinalStateIfOpen(session, trace); await captureFinalStateIfOpen(session, trace);
emitEvidence(`${config.id}:b`, trace); emitEvidence(`${config.id}:b`, trace);
@@ -622,7 +623,7 @@ for (const config of wrappers) {
expect( expect(
trace.notifications.some((entry) => JSON.stringify(entry).includes("tool_call_update")), trace.notifications.some((entry) => JSON.stringify(entry).includes("tool_call_update")),
).toBe(true); ).toBe(true);
expect((session as unknown as SessionInternals).toolCalls.size).toBeGreaterThan(0); expect(asInternals<SessionInternals>(session).toolCalls.size).toBeGreaterThan(0);
trace.criteria.toolSnapshot = { trace.criteria.toolSnapshot = {
outcome: "PASS", outcome: "PASS",
detail: detail:
@@ -2,6 +2,7 @@ import { afterEach, beforeEach, expect, test, vi } from "vitest";
import type { Logger } from "pino"; import type { Logger } from "pino";
import { createTestLogger } from "../../../test-utils/test-logger.js"; import { createTestLogger } from "../../../test-utils/test-logger.js";
import { asInternals } from "../../test-utils/class-mocks.js";
import { ClaudeAgentClient, readEventIdentifiers } from "./claude-agent.js"; import { ClaudeAgentClient, readEventIdentifiers } from "./claude-agent.js";
import { streamSession } from "./test-utils/session-stream-adapter.js"; import { streamSession } from "./test-utils/session-stream-adapter.js";
import type { AgentStreamEvent, AgentTimelineItem } from "../agent-sdk-types.js"; import type { AgentStreamEvent, AgentTimelineItem } from "../agent-sdk-types.js";
@@ -124,7 +125,7 @@ function createSpyLogger(): {
loggerLike.child.mockReturnValue(loggerLike); loggerLike.child.mockReturnValue(loggerLike);
return { return {
logger: loggerLike as unknown as Logger, logger: asInternals<Logger>(loggerLike),
calls, calls,
debug, debug,
info, info,
@@ -261,7 +262,7 @@ test("logs redacted query summary and never leaks sentinel secrets", async () =>
test("interruptActiveTurn only interrupts the active query without info logs", async () => { test("interruptActiveTurn only interrupts the active query without info logs", async () => {
const spy = createSpyLogger(); const spy = createSpyLogger();
const session = await createSessionWithLogger(spy.logger); const session = await createSessionWithLogger(spy.logger);
const internal = session as unknown as { const internal: {
query: { query: {
interrupt: () => Promise<void>; interrupt: () => Promise<void>;
return?: () => Promise<void>; return?: () => Promise<void>;
@@ -270,7 +271,7 @@ test("interruptActiveTurn only interrupts the active query without info logs", a
input: { end: () => void } | null; input: { end: () => void } | null;
queryRestartNeeded: boolean; queryRestartNeeded: boolean;
interruptActiveTurn: () => Promise<void>; interruptActiveTurn: () => Promise<void>;
}; } = asInternals(session);
const interrupt = vi.fn(async () => undefined); const interrupt = vi.fn(async () => undefined);
const queryReturn = vi.fn(async () => undefined); const queryReturn = vi.fn(async () => undefined);
const end = vi.fn(() => undefined); const end = vi.fn(() => undefined);
@@ -383,7 +384,7 @@ test("extracts identifiers from fixture-driven protocol shape variants", () => {
for (const fixture of fixtures) { for (const fixture of fixtures) {
expect( expect(
readEventIdentifiers( readEventIdentifiers(
fixture.message as unknown as Parameters<typeof readEventIdentifiers>[0], asInternals<Parameters<typeof readEventIdentifiers>[0]>(fixture.message),
), ),
).toEqual(fixture.expected); ).toEqual(fixture.expected);
} }
@@ -411,12 +412,12 @@ test("captures session IDs from fixture-driven init message variants", async ()
await Promise.all( await Promise.all(
fixtures.map(async (fixture) => { fixtures.map(async (fixture) => {
const session = await createSession(); const session = await createSession();
const internal = session as unknown as { const internal: {
handleSystemMessage: (message: Record<string, unknown>) => { handleSystemMessage: (message: Record<string, unknown>) => {
threadStartedSessionId: string | null; threadStartedSessionId: string | null;
notice: AgentTimelineItem | null; notice: AgentTimelineItem | null;
}; };
}; } = asInternals(session);
try { try {
const started = internal.handleSystemMessage({ const started = internal.handleSystemMessage({
type: "system", type: "system",
@@ -439,12 +440,12 @@ test("captures session IDs from fixture-driven init message variants", async ()
test("waits for complete JSON values before updating tool input from input_json_delta", async () => { test("waits for complete JSON values before updating tool input from input_json_delta", async () => {
const session = await createSession(); const session = await createSession();
const internal = session as unknown as { const internal: {
mapPartialEvent: (event: Record<string, unknown>) => AgentTimelineItem[]; mapPartialEvent: (event: Record<string, unknown>) => AgentTimelineItem[];
toolUseCache: Map<string, { input?: Record<string, unknown> }>; toolUseCache: Map<string, { input?: Record<string, unknown> }>;
toolUseIndexToId: Map<number, string>; toolUseIndexToId: Map<number, string>;
toolUseInputBuffers: Map<string, string>; toolUseInputBuffers: Map<string, string>;
}; } = asInternals(session);
const toolUseId = "tool-input-delta"; const toolUseId = "tool-input-delta";
const index = 7; const index = 7;
@@ -501,7 +502,7 @@ test("waits for complete JSON values before updating tool input from input_json_
] as const; ] as const;
for (const fixture of deltaFixtures) { for (const fixture of deltaFixtures) {
internal.mapPartialEvent(fixture.event as unknown as Record<string, unknown>); internal.mapPartialEvent(asInternals<Record<string, unknown>>(fixture.event));
expect(readCommand()).toBe(fixture.expectedCommand); expect(readCommand()).toBe(fixture.expectedCommand);
} }
@@ -518,10 +519,10 @@ test("waits for complete JSON values before updating tool input from input_json_
test("does not surface incomplete string values from input_json_delta", async () => { test("does not surface incomplete string values from input_json_delta", async () => {
const session = await createSession(); const session = await createSession();
const internal = session as unknown as { const internal: {
mapPartialEvent: (event: Record<string, unknown>) => AgentTimelineItem[]; mapPartialEvent: (event: Record<string, unknown>) => AgentTimelineItem[];
toolUseCache: Map<string, { input?: Record<string, unknown> }>; toolUseCache: Map<string, { input?: Record<string, unknown> }>;
}; } = asInternals(session);
const toolUseId = "tool-input-preview"; const toolUseId = "tool-input-preview";
const index = 8; const index = 8;
@@ -555,12 +556,12 @@ test("does not surface incomplete string values from input_json_delta", async ()
test("maps tool_result content shapes into deterministic string output", async () => { test("maps tool_result content shapes into deterministic string output", async () => {
const session = await createSession(); const session = await createSession();
const internal = session as unknown as { const internal: {
buildToolOutput: ( buildToolOutput: (
block: Record<string, unknown>, block: Record<string, unknown>,
entry: Record<string, unknown> | undefined, entry: Record<string, unknown> | undefined,
) => Record<string, unknown> | undefined; ) => Record<string, unknown> | undefined;
}; } = asInternals(session);
const toolEntry = { const toolEntry = {
id: "tool-1", id: "tool-1",
@@ -628,12 +629,12 @@ test("maps tool_result content shapes into deterministic string output", async (
test("Grep tool_result string content flows to a search detail with content", async () => { test("Grep tool_result string content flows to a search detail with content", async () => {
const session = await createSession(); const session = await createSession();
const internal = session as unknown as { const internal: {
buildToolOutput: ( buildToolOutput: (
block: Record<string, unknown>, block: Record<string, unknown>,
entry: Record<string, unknown> | undefined, entry: Record<string, unknown> | undefined,
) => Record<string, unknown> | undefined; ) => Record<string, unknown> | undefined;
}; } = asInternals(session);
const grepEntry = { const grepEntry = {
id: "tool-grep-1", id: "tool-grep-1",
@@ -890,13 +891,13 @@ test("plan approval exposes a resume-bypass action and can return to bypassPermi
await session.setMode("bypassPermissions"); await session.setMode("bypassPermissions");
await session.setMode("plan"); await session.setMode("plan");
const internal = session as unknown as { const internal: {
handlePermissionRequest: ( handlePermissionRequest: (
toolName: string, toolName: string,
input: Record<string, unknown>, input: Record<string, unknown>,
options: Record<string, unknown>, options: Record<string, unknown>,
) => Promise<unknown>; ) => Promise<unknown>;
}; } = asInternals(session);
const pendingResolution = internal.handlePermissionRequest( const pendingResolution = internal.handlePermissionRequest(
"ExitPlanMode", "ExitPlanMode",
@@ -956,12 +957,12 @@ test("plan approval exposes a resume-bypass action and can return to bypassPermi
test("reuses one autonomous run for unbound stream_event bursts with no foreground run", async () => { test("reuses one autonomous run for unbound stream_event bursts with no foreground run", async () => {
const session = await createSession(); const session = await createSession();
const internal = session as unknown as { const internal: {
turnState: "idle" | "foreground" | "autonomous"; turnState: "idle" | "foreground" | "autonomous";
nextTurnOrdinal: number; nextTurnOrdinal: number;
routeSdkMessageFromPump: (message: Record<string, unknown>) => void; routeSdkMessageFromPump: (message: Record<string, unknown>) => void;
autonomousTurn: { id: string } | null; autonomousTurn: { id: string } | null;
}; } = asInternals(session);
internal.turnState = "idle"; internal.turnState = "idle";
internal.routeSdkMessageFromPump({ internal.routeSdkMessageFromPump({
@@ -1352,9 +1353,9 @@ test("does not use stream_event uuid as assistant message identity when message_
expect(assistantText).toContain("HELLO WORLD"); expect(assistantText).toContain("HELLO WORLD");
const assembler = session as unknown as { const assembler: {
timelineAssembler: { messages: Map<string, unknown> }; timelineAssembler: { messages: Map<string, unknown> };
}; } = asInternals(session);
expect(assembler.timelineAssembler.messages.size).toBe(0); expect(assembler.timelineAssembler.messages.size).toBe(0);
await session.close(); await session.close();
@@ -18,6 +18,7 @@ import {
codexAppServerTurnInputFromPrompt, codexAppServerTurnInputFromPrompt,
} from "./codex-app-server-agent.js"; } from "./codex-app-server-agent.js";
import { createTestLogger } from "../../../test-utils/test-logger.js"; import { createTestLogger } from "../../../test-utils/test-logger.js";
import { asInternals as castInternals, createStub } from "../../test-utils/class-mocks.js";
interface CollaborationModeRecord { interface CollaborationModeRecord {
name: string; name: string;
@@ -71,7 +72,7 @@ function createSession(configOverrides: Partial<AgentSessionConfig> = {}): Codex
() => { () => {
throw new Error("Test session cannot spawn Codex app-server"); throw new Error("Test session cannot spawn Codex app-server");
}, },
) as unknown as CodexTestSession; ) as CodexTestSession;
session.connected = true; session.connected = true;
session.currentThreadId = "test-thread"; session.currentThreadId = "test-thread";
session.activeForegroundTurnId = "test-turn"; session.activeForegroundTurnId = "test-turn";
@@ -79,7 +80,7 @@ function createSession(configOverrides: Partial<AgentSessionConfig> = {}): Codex
} }
function asInternals(session: CodexTestSession): CodexSessionTestAccess { function asInternals(session: CodexTestSession): CodexSessionTestAccess {
return session as unknown as CodexSessionTestAccess; return castInternals<CodexSessionTestAccess>(session);
} }
describe("Codex app-server provider", () => { describe("Codex app-server provider", () => {
@@ -105,9 +106,9 @@ describe("Codex app-server provider", () => {
{}, {},
true, true,
); );
(session as unknown as { client: CodexClientLike }).client = fakeClient; castInternals<{ client: CodexClientLike }>(session).client = fakeClient;
await (session as unknown as { ensureThread: () => Promise<void> }).ensureThread(); await castInternals<{ ensureThread: () => Promise<void> }>(session).ensureThread();
const startCall = requests.find((req) => req.method === "thread/start"); const startCall = requests.find((req) => req.method === "thread/start");
expect(startCall).toBeDefined(); expect(startCall).toBeDefined();
@@ -134,9 +135,9 @@ describe("Codex app-server provider", () => {
throw new Error("Test session cannot spawn Codex app-server"); throw new Error("Test session cannot spawn Codex app-server");
}, },
); );
(session as unknown as { client: CodexClientLike }).client = fakeClient; castInternals<{ client: CodexClientLike }>(session).client = fakeClient;
await (session as unknown as { ensureThread: () => Promise<void> }).ensureThread(); await castInternals<{ ensureThread: () => Promise<void> }>(session).ensureThread();
const startCall = requests.find((req) => req.method === "thread/start"); const startCall = requests.find((req) => req.method === "thread/start");
expect(startCall).toBeDefined(); expect(startCall).toBeDefined();
@@ -349,7 +350,7 @@ describe("Codex app-server provider", () => {
}); });
session.activeForegroundTurnId = null; session.activeForegroundTurnId = null;
session.client = { request } as unknown as CodexClientLike; session.client = createStub<CodexClientLike>({ request });
await session.startTurn("Return JSON", { await session.startTurn("Return JSON", {
outputSchema: { outputSchema: {
@@ -405,7 +406,7 @@ describe("Codex app-server provider", () => {
}); });
session.activeForegroundTurnId = null; session.activeForegroundTurnId = null;
session.client = { request } as unknown as CodexClientLike; session.client = createStub<CodexClientLike>({ request });
await session.startTurn("/paseo-implement in a worktree, remember to use Claude for the UI"); await session.startTurn("/paseo-implement in a worktree, remember to use Claude for the UI");
@@ -1439,7 +1440,7 @@ describe("Codex app-server provider", () => {
}); });
session.activeForegroundTurnId = null; session.activeForegroundTurnId = null;
session.client = { request } as unknown as CodexClientLike; session.client = createStub<CodexClientLike>({ request });
const events: AgentStreamEvent[] = []; const events: AgentStreamEvent[] = [];
session.subscribe((event) => events.push(event)); session.subscribe((event) => events.push(event));
@@ -1,6 +1,7 @@
import { describe, expect, test, vi } from "vitest"; import { describe, expect, test, vi } from "vitest";
import type pino from "pino"; import type pino from "pino";
import { Session, type SessionOptions } from "./session.js"; import { Session, type SessionOptions } from "./session.js";
import { asInternals, createStub } from "./test-utils/class-mocks.js";
import type { import type {
WorkspaceGitListener, WorkspaceGitListener,
WorkspaceGitRuntimeSnapshot, WorkspaceGitRuntimeSnapshot,
@@ -150,20 +151,20 @@ function createSessionForWorkspaceGitWatchTests(): {
const session = new Session({ const session = new Session({
clientId: "test-client", clientId: "test-client",
onMessage: (message) => emitted.push(message as { type: string; payload: unknown }), onMessage: (message) => emitted.push(message as { type: string; payload: unknown }),
logger: logger as unknown as pino.Logger, logger: createStub<pino.Logger>(logger),
downloadTokenStore: {} as SessionOptions["downloadTokenStore"], downloadTokenStore: createStub<SessionOptions["downloadTokenStore"]>({}),
pushTokenStore: {} as SessionOptions["pushTokenStore"], pushTokenStore: createStub<SessionOptions["pushTokenStore"]>({}),
paseoHome: "/tmp/paseo-test", paseoHome: "/tmp/paseo-test",
agentManager: { agentManager: createStub<SessionOptions["agentManager"]>({
subscribe: () => () => {}, subscribe: () => () => {},
listAgents: () => [], listAgents: () => [],
getAgent: () => null, getAgent: () => null,
} as unknown as SessionOptions["agentManager"], }),
agentStorage: { agentStorage: createStub<SessionOptions["agentStorage"]>({
list: async () => [], list: async () => [],
get: async () => null, get: async () => null,
} as unknown as SessionOptions["agentStorage"], }),
projectRegistry: { projectRegistry: createStub<SessionOptions["projectRegistry"]>({
initialize: async () => {}, initialize: async () => {},
existsOnDisk: async () => true, existsOnDisk: async () => true,
list: async () => Array.from(projects.values()), list: async () => Array.from(projects.values()),
@@ -179,8 +180,8 @@ function createSessionForWorkspaceGitWatchTests(): {
remove: async (projectId: string) => { remove: async (projectId: string) => {
projects.delete(projectId); projects.delete(projectId);
}, },
} as unknown as SessionOptions["projectRegistry"], }),
workspaceRegistry: { workspaceRegistry: createStub<SessionOptions["workspaceRegistry"]>({
initialize: async () => {}, initialize: async () => {},
existsOnDisk: async () => true, existsOnDisk: async () => true,
list: async () => Array.from(workspaces.values()), list: async () => Array.from(workspaces.values()),
@@ -196,8 +197,8 @@ function createSessionForWorkspaceGitWatchTests(): {
remove: async (workspaceId: string) => { remove: async (workspaceId: string) => {
workspaces.delete(workspaceId); workspaces.delete(workspaceId);
}, },
} as unknown as SessionOptions["workspaceRegistry"], }),
checkoutDiffManager: { checkoutDiffManager: createStub<SessionOptions["checkoutDiffManager"]>({
subscribe: async () => ({ subscribe: async () => ({
initial: { cwd: "/tmp", files: [], error: null }, initial: { cwd: "/tmp", files: [], error: null },
unsubscribe: () => {}, unsubscribe: () => {},
@@ -210,15 +211,15 @@ function createSessionForWorkspaceGitWatchTests(): {
checkoutDiffFallbackRefreshTargetCount: 0, checkoutDiffFallbackRefreshTargetCount: 0,
}), }),
dispose: () => {}, dispose: () => {},
} as unknown as SessionOptions["checkoutDiffManager"], }),
workspaceGitService, workspaceGitService,
mcpBaseUrl: null, mcpBaseUrl: null,
stt: null, stt: null,
tts: null, tts: null,
terminalManager: null, terminalManager: null,
} as unknown as SessionOptions); });
(session as unknown as SessionInternals).listAgentPayloads = async () => []; asInternals<SessionInternals>(session).listAgentPayloads = async () => [];
return { return {
session, session,
@@ -267,7 +268,7 @@ describe("workspace git watch targets", () => {
test("emits one workspace_update when the workspace git service emits a changed snapshot", async () => { test("emits one workspace_update when the workspace git service emits a changed snapshot", async () => {
const { session, emitted, projects, workspaces, workspaceGitService, subscriptions } = const { session, emitted, projects, workspaces, workspaceGitService, subscriptions } =
createSessionForWorkspaceGitWatchTests(); createSessionForWorkspaceGitWatchTests();
const sessionAny = session as unknown as SessionInternals; const sessionAny = asInternals<SessionInternals>(session);
seedGitWorkspace({ seedGitWorkspace({
projects, projects,
workspaces, workspaces,
@@ -342,7 +343,7 @@ describe("workspace git watch targets", () => {
test("emits checkout_status_update to a client subscribed to the workspace git target", async () => { test("emits checkout_status_update to a client subscribed to the workspace git target", async () => {
const { session, emitted, projects, workspaces, workspaceGitService, subscriptions } = const { session, emitted, projects, workspaces, workspaceGitService, subscriptions } =
createSessionForWorkspaceGitWatchTests(); createSessionForWorkspaceGitWatchTests();
const sessionAny = session as unknown as SessionInternals; const sessionAny = asInternals<SessionInternals>(session);
seedGitWorkspace({ seedGitWorkspace({
projects, projects,
workspaces, workspaces,
@@ -405,7 +406,7 @@ describe("workspace git watch targets", () => {
test("embeds PR status in checkout_status_update for GitHub-inclusive snapshot pushes", async () => { test("embeds PR status in checkout_status_update for GitHub-inclusive snapshot pushes", async () => {
const { session, emitted, projects, workspaces, subscriptions } = const { session, emitted, projects, workspaces, subscriptions } =
createSessionForWorkspaceGitWatchTests(); createSessionForWorkspaceGitWatchTests();
const sessionAny = session as unknown as SessionInternals; const sessionAny = asInternals<SessionInternals>(session);
seedGitWorkspace({ seedGitWorkspace({
projects, projects,
workspaces, workspaces,
@@ -11,6 +11,7 @@ import { expect, test, vi } from "vitest";
import { Session, type SessionOptions } from "./session.js"; import { Session, type SessionOptions } from "./session.js";
import type { SessionOutboundMessage } from "../shared/messages.js"; import type { SessionOutboundMessage } from "../shared/messages.js";
import { createNoopWorkspaceGitService } from "./test-utils/workspace-git-service-stub.js"; import { createNoopWorkspaceGitService } from "./test-utils/workspace-git-service-stub.js";
import { asInternals, createStub } from "./test-utils/class-mocks.js";
import { import {
createPersistedProjectRecord, createPersistedProjectRecord,
createPersistedWorkspaceRecord, createPersistedWorkspaceRecord,
@@ -87,11 +88,11 @@ function createHarness(input: {
clientId: "test", clientId: "test",
appVersion: null, appVersion: null,
onMessage: (m) => emitted.push(m), onMessage: (m) => emitted.push(m),
logger: logger as unknown as SessionOptions["logger"], logger: createStub<SessionOptions["logger"]>(logger),
downloadTokenStore: {} as unknown as SessionOptions["downloadTokenStore"], downloadTokenStore: createStub<SessionOptions["downloadTokenStore"]>({}),
pushTokenStore: {} as unknown as SessionOptions["pushTokenStore"], pushTokenStore: createStub<SessionOptions["pushTokenStore"]>({}),
paseoHome: mkdtempSync(path.join(tmpdir(), "paseo-invariant-test-")), paseoHome: mkdtempSync(path.join(tmpdir(), "paseo-invariant-test-")),
agentManager: { agentManager: createStub<SessionOptions["agentManager"]>({
subscribe: () => () => {}, subscribe: () => () => {},
listAgents: () => [], listAgents: () => [],
getAgent: () => null, getAgent: () => null,
@@ -99,12 +100,12 @@ function createHarness(input: {
archiveSnapshot: async () => ({}), archiveSnapshot: async () => ({}),
clearAgentAttention: async () => {}, clearAgentAttention: async () => {},
notifyAgentState: () => {}, notifyAgentState: () => {},
} as unknown as SessionOptions["agentManager"], }),
agentStorage: { agentStorage: createStub<SessionOptions["agentStorage"]>({
list: async () => [], list: async () => [],
get: async () => null, get: async () => null,
} as unknown as SessionOptions["agentStorage"], }),
projectRegistry: { projectRegistry: createStub<SessionOptions["projectRegistry"]>({
initialize: async () => {}, initialize: async () => {},
existsOnDisk: async () => true, existsOnDisk: async () => true,
list: async () => Array.from(projects.values()), list: async () => Array.from(projects.values()),
@@ -119,8 +120,8 @@ function createHarness(input: {
remove: async (id: string) => { remove: async (id: string) => {
projects.delete(id); projects.delete(id);
}, },
} as unknown as SessionOptions["projectRegistry"], }),
workspaceRegistry: { workspaceRegistry: createStub<SessionOptions["workspaceRegistry"]>({
initialize: async () => {}, initialize: async () => {},
existsOnDisk: async () => true, existsOnDisk: async () => true,
list: async () => Array.from(workspaces.values()), list: async () => Array.from(workspaces.values()),
@@ -135,11 +136,11 @@ function createHarness(input: {
remove: async (id: string) => { remove: async (id: string) => {
workspaces.delete(id); workspaces.delete(id);
}, },
} as unknown as SessionOptions["workspaceRegistry"], }),
chatService: {} as unknown as SessionOptions["chatService"], chatService: createStub<SessionOptions["chatService"]>({}),
scheduleService: {} as unknown as SessionOptions["scheduleService"], scheduleService: createStub<SessionOptions["scheduleService"]>({}),
loopService: {} as unknown as SessionOptions["loopService"], loopService: createStub<SessionOptions["loopService"]>({}),
checkoutDiffManager: { checkoutDiffManager: createStub<SessionOptions["checkoutDiffManager"]>({
subscribe: async () => ({ subscribe: async () => ({
initial: { cwd: "/tmp", files: [], error: null }, initial: { cwd: "/tmp", files: [], error: null },
unsubscribe: () => {}, unsubscribe: () => {},
@@ -152,12 +153,12 @@ function createHarness(input: {
checkoutDiffFallbackRefreshTargetCount: 0, checkoutDiffFallbackRefreshTargetCount: 0,
}), }),
dispose: () => {}, dispose: () => {},
} as unknown as SessionOptions["checkoutDiffManager"], }),
workspaceGitService, workspaceGitService,
daemonConfigStore: { daemonConfigStore: createStub<SessionOptions["daemonConfigStore"]>({
get: () => ({ mcp: { injectIntoAgents: false }, providers: {} }), get: () => ({ mcp: { injectIntoAgents: false }, providers: {} }),
onChange: () => () => {}, onChange: () => () => {},
} as unknown as SessionOptions["daemonConfigStore"], }),
mcpBaseUrl: null, mcpBaseUrl: null,
stt: null, stt: null,
tts: null, tts: null,
@@ -168,7 +169,7 @@ function createHarness(input: {
} }
async function openProject(session: Session, cwd: string, requestId = "req-1") { async function openProject(session: Session, cwd: string, requestId = "req-1") {
await (session as unknown as { handleMessage(m: unknown): Promise<unknown> }).handleMessage({ await asInternals<{ handleMessage(m: unknown): Promise<unknown> }>(session).handleMessage({
type: "open_project_request", type: "open_project_request",
cwd, cwd,
requestId, requestId,
@@ -436,11 +437,9 @@ test("S12: findWorkspaceByDirectory does not return archived ancestor via prefix
workspaces: [dirWorkspace("/parent", archivedAt)], workspaces: [dirWorkspace("/parent", archivedAt)],
projects: [dirProject("/parent", archivedAt)], projects: [dirProject("/parent", archivedAt)],
}); });
const found = await ( const found = await asInternals<{
h.session as unknown as { findWorkspaceByDirectory(cwd: string): Promise<unknown>;
findWorkspaceByDirectory(cwd: string): Promise<unknown>; }>(h.session).findWorkspaceByDirectory("/parent/child");
}
).findWorkspaceByDirectory("/parent/child");
expect(found).toBeNull(); expect(found).toBeNull();
}); });
@@ -6,6 +6,7 @@ import { afterEach, describe, expect, test, vi } from "vitest";
import { Session } from "./session.js"; import { Session } from "./session.js";
import type { SessionOptions } from "./session.js"; import type { SessionOptions } from "./session.js";
import { createTestPaseoDaemon } from "./test-utils/paseo-daemon.js"; import { createTestPaseoDaemon } from "./test-utils/paseo-daemon.js";
import { asInternals, createStub } from "./test-utils/class-mocks.js";
interface SessionInternals { interface SessionInternals {
archiveAgentForClose(agentId: string): Promise<{ archivedAt: string }>; archiveAgentForClose(agentId: string): Promise<{ archivedAt: string }>;
@@ -90,51 +91,53 @@ describe("snapshot mutation ownership boundary", () => {
error: vi.fn(), error: vi.fn(),
}; };
const session = new Session({ const session = asInternals<SessionInternals>(
clientId: "test-client", new Session({
onMessage, clientId: "test-client",
logger: logger as unknown as SessionOptions["logger"], onMessage,
downloadTokenStore: {} as unknown as SessionOptions["downloadTokenStore"], logger: createStub<SessionOptions["logger"]>(logger),
pushTokenStore: {} as unknown as SessionOptions["pushTokenStore"], downloadTokenStore: createStub<SessionOptions["downloadTokenStore"]>({}),
paseoHome: "/tmp/paseo-test", pushTokenStore: createStub<SessionOptions["pushTokenStore"]>({}),
agentManager: { paseoHome: "/tmp/paseo-test",
subscribe: () => () => {}, agentManager: createStub<SessionOptions["agentManager"]>({
listAgents: () => [], subscribe: () => () => {},
getAgent: () => null, listAgents: () => [],
archiveSnapshot, getAgent: () => null,
updateAgentMetadata, archiveSnapshot,
} as unknown as SessionOptions["agentManager"], updateAgentMetadata,
agentStorage: { }),
list: async () => [], agentStorage: createStub<SessionOptions["agentStorage"]>({
get: async () => storedRecord, list: async () => [],
applySnapshot: directStorageWrite, get: async () => storedRecord,
upsert: directStorageWrite, applySnapshot: directStorageWrite,
} as unknown as SessionOptions["agentStorage"], upsert: directStorageWrite,
projectRegistry: { }),
initialize: async () => {}, projectRegistry: createStub<SessionOptions["projectRegistry"]>({
existsOnDisk: async () => true, initialize: async () => {},
list: async () => [], existsOnDisk: async () => true,
get: async () => null, list: async () => [],
upsert: async () => {}, get: async () => null,
archive: async () => {}, upsert: async () => {},
remove: async () => {}, archive: async () => {},
} as unknown as SessionOptions["projectRegistry"], remove: async () => {},
workspaceRegistry: { }),
initialize: async () => {}, workspaceRegistry: createStub<SessionOptions["workspaceRegistry"]>({
existsOnDisk: async () => true, initialize: async () => {},
list: async () => [], existsOnDisk: async () => true,
get: async () => null, list: async () => [],
upsert: async () => {}, get: async () => null,
archive: async () => {}, upsert: async () => {},
remove: async () => {}, archive: async () => {},
} as unknown as SessionOptions["workspaceRegistry"], remove: async () => {},
createAgentMcpTransport: async () => { }),
throw new Error("not used"); createAgentMcpTransport: async () => {
}, throw new Error("not used");
stt: null, },
tts: null, stt: null,
terminalManager: null, tts: null,
}) as unknown as SessionInternals; terminalManager: null,
}),
);
const archiveResult = await session.archiveAgentForClose("agent-1"); const archiveResult = await session.archiveAgentForClose("agent-1");
expect(archiveSnapshot).toHaveBeenCalledTimes(1); expect(archiveSnapshot).toHaveBeenCalledTimes(1);
@@ -0,0 +1,57 @@
/**
* Test helpers for typed stubs and internal access without scatter of
* `as unknown as` casts across test files.
*
* WHY createStub contains one cast:
* TypeScript's structural type system cannot accept Partial<T> as T when T has
* private class members — no object-literal syntax satisfies private fields
* from outside the class. Centralising the cast here adds real runtime value
* over a bare `obj as unknown as T` scattered across tests:
* - A Proxy intercepts every property access.
* - Calling any unstubbed method throws immediately with a specific message
* ("createStub: 'listAgents' was called but not stubbed") instead of
* silently returning undefined and causing a confusing downstream failure.
*
* Alternatives rejected:
* - mockOf<T>(p): return p as unknown as T — same single cast, zero runtime
* benefit; this is the "option 4 trap" the task spec explicitly flags.
* - Object.create(ctor.prototype): only works for concrete classes, not for
* interfaces (ProjectRegistry, WorkspaceRegistry) or external library types
* (pino.Logger, http.Server); Object.create returns `any` so a cast is
* still needed in the generic path.
* - class FakeT implements T: verbose for large classes (AgentManager has
* 30+ public methods); must be kept in sync whenever the class grows.
*/
/**
* Creates a typed test stub for any class or interface T.
* Stubs only the methods/properties provided; any other property access throws.
*
* Keys of `stubs` are checked against T at compile time (preventing typos).
* Any value type is accepted for each key, so `vi.fn()` always satisfies the
* slot regardless of the original method's return type.
*/
export function createStub<T extends object>(stubs: { [K in keyof T]?: unknown }): T {
return new Proxy(stubs as Record<string | symbol, unknown>, {
get(target, prop, receiver) {
if (Reflect.has(target, prop)) return Reflect.get(target, prop, receiver);
if (typeof prop === "symbol") return undefined;
return (..._args: unknown[]): never => {
throw new Error(`createStub: "${String(prop)}" was called but not stubbed`);
};
},
}) as unknown as T; // one justified cast — rationale in file-level comment
}
/**
* Widens a real object to a test-internal interface T without a double assertion.
*
* Use when tests need to access private fields or call private methods of a
* real class instance (e.g. `asInternals<WebSocketServerInternals>(server)`).
* The parameter is typed `unknown` so TypeScript accepts the single `as T`
* assertion — narrowing from the top type is safe, unlike `as unknown as T`
* which short-circuits structural checking on a concrete source type.
*/
export function asInternals<T>(obj: unknown): T {
return obj as T;
}
@@ -4,78 +4,99 @@ import type { ProviderSnapshotEntry } from "../agent/agent-sdk-types.js";
import { ProviderSnapshotManager } from "../agent/provider-snapshot-manager.js"; import { ProviderSnapshotManager } from "../agent/provider-snapshot-manager.js";
import type { SessionOptions } from "../session.js"; import type { SessionOptions } from "../session.js";
import type { SessionOutboundMessage } from "../../shared/messages.js"; import type { SessionOutboundMessage } from "../../shared/messages.js";
import { asInternals, createStub } from "./class-mocks.js";
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Unsafe stub wrappers — as unknown as is ONLY here, never in scope test files // Typed stub wrappers — unsafe cast is in createStub (class-mocks.ts), never
// directly in test files. Wrapper signatures narrow the accepted key set so
// callers get compile-time feedback on typos in method names.
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
export function asSessionLogger(stub: object): SessionOptions["logger"] { export function asSessionLogger(stub: {
return stub as unknown as SessionOptions["logger"]; [K in keyof SessionOptions["logger"]]?: unknown;
}): SessionOptions["logger"] {
return createStub<SessionOptions["logger"]>(stub);
} }
export function asAgentManager(stub: object): SessionOptions["agentManager"] { export function asAgentManager(stub: {
return stub as unknown as SessionOptions["agentManager"]; [K in keyof SessionOptions["agentManager"]]?: unknown;
}): SessionOptions["agentManager"] {
return createStub<SessionOptions["agentManager"]>(stub);
} }
export function asAgentStorage(stub: object): SessionOptions["agentStorage"] { export function asAgentStorage(stub: {
return stub as unknown as SessionOptions["agentStorage"]; [K in keyof SessionOptions["agentStorage"]]?: unknown;
}): SessionOptions["agentStorage"] {
return createStub<SessionOptions["agentStorage"]>(stub);
} }
export function asDownloadTokenStore(): SessionOptions["downloadTokenStore"] { export function asDownloadTokenStore(): SessionOptions["downloadTokenStore"] {
return {} as unknown as SessionOptions["downloadTokenStore"]; return createStub<SessionOptions["downloadTokenStore"]>({});
} }
export function asPushTokenStore(): SessionOptions["pushTokenStore"] { export function asPushTokenStore(): SessionOptions["pushTokenStore"] {
return {} as unknown as SessionOptions["pushTokenStore"]; return createStub<SessionOptions["pushTokenStore"]>({});
} }
export function asChatService(): SessionOptions["chatService"] { export function asChatService(): SessionOptions["chatService"] {
return {} as unknown as SessionOptions["chatService"]; return createStub<SessionOptions["chatService"]>({});
} }
export function asScheduleService(): SessionOptions["scheduleService"] { export function asScheduleService(): SessionOptions["scheduleService"] {
return {} as unknown as SessionOptions["scheduleService"]; return createStub<SessionOptions["scheduleService"]>({});
} }
export function asLoopService(): SessionOptions["loopService"] { export function asLoopService(): SessionOptions["loopService"] {
return {} as unknown as SessionOptions["loopService"]; return createStub<SessionOptions["loopService"]>({});
} }
export function asCheckoutDiffManager(stub: object): SessionOptions["checkoutDiffManager"] { export function asCheckoutDiffManager(stub: {
return stub as unknown as SessionOptions["checkoutDiffManager"]; [K in keyof SessionOptions["checkoutDiffManager"]]?: unknown;
}): SessionOptions["checkoutDiffManager"] {
return createStub<SessionOptions["checkoutDiffManager"]>(stub);
} }
export function asDaemonConfigStore(stub: object): SessionOptions["daemonConfigStore"] { export function asDaemonConfigStore(stub: {
return stub as unknown as SessionOptions["daemonConfigStore"]; [K in keyof SessionOptions["daemonConfigStore"]]?: unknown;
}): SessionOptions["daemonConfigStore"] {
return createStub<SessionOptions["daemonConfigStore"]>(stub);
} }
export function asTerminalManager(stub: object): NonNullable<SessionOptions["terminalManager"]> { export function asTerminalManager(stub: {
return stub as unknown as NonNullable<SessionOptions["terminalManager"]>; [K in keyof NonNullable<SessionOptions["terminalManager"]>]?: unknown;
}): NonNullable<SessionOptions["terminalManager"]> {
return createStub<NonNullable<SessionOptions["terminalManager"]>>(stub);
} }
export function asGitHubService(stub: object): NonNullable<SessionOptions["github"]> { export function asGitHubService(stub: {
return stub as unknown as NonNullable<SessionOptions["github"]>; [K in keyof NonNullable<SessionOptions["github"]>]?: unknown;
}): NonNullable<SessionOptions["github"]> {
return createStub<NonNullable<SessionOptions["github"]>>(stub);
} }
export function asWorkspaceGitService(stub: object): SessionOptions["workspaceGitService"] { export function asWorkspaceGitService(stub: {
return stub as unknown as SessionOptions["workspaceGitService"]; [K in keyof SessionOptions["workspaceGitService"]]?: unknown;
}): SessionOptions["workspaceGitService"] {
return createStub<SessionOptions["workspaceGitService"]>(stub);
} }
export function asScriptRouteStore(stub: object): SessionOptions["scriptRouteStore"] { export function asScriptRouteStore(stub: {
return stub as unknown as SessionOptions["scriptRouteStore"]; [K in keyof SessionOptions["scriptRouteStore"]]?: unknown;
}): SessionOptions["scriptRouteStore"] {
return createStub<SessionOptions["scriptRouteStore"]>(stub);
} }
export function asWorkspaceScriptRuntimeStore(stub: object): SessionOptions["scriptRuntimeStore"] { export function asWorkspaceScriptRuntimeStore(stub: {
return stub as unknown as SessionOptions["scriptRuntimeStore"]; [K in keyof SessionOptions["scriptRuntimeStore"]]?: unknown;
}): SessionOptions["scriptRuntimeStore"] {
return createStub<SessionOptions["scriptRuntimeStore"]>(stub);
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Private session access — moves the unsafe cast out of scope files // Private session access — delegates to asInternals so test files need no cast
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
export function asSessionInternals<T>(session: unknown): T { export { asInternals as asSessionInternals };
return session as unknown as T;
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Type guard for SessionOutboundMessage — avoids casting unknown in test emit overrides // Type guard for SessionOutboundMessage — avoids casting unknown in test emit overrides
@@ -134,7 +155,7 @@ export function createProviderSnapshotManagerStub(): {
}; };
on.mockImplementation(() => stub); on.mockImplementation(() => stub);
off.mockImplementation(() => stub); off.mockImplementation(() => stub);
const manager = stub as unknown as ProviderSnapshotManager; const manager = createStub<ProviderSnapshotManager>(stub);
return { return {
manager, manager,
getSnapshot, getSnapshot,
@@ -9,6 +9,7 @@ import type { FileBackedChatService } from "./chat/chat-service.js";
import type { LoopService } from "./loop-service.js"; import type { LoopService } from "./loop-service.js";
import type { ScheduleService } from "./schedule/service.js"; import type { ScheduleService } from "./schedule/service.js";
import type { CheckoutDiffManager } from "./checkout-diff-manager.js"; import type { CheckoutDiffManager } from "./checkout-diff-manager.js";
import { asInternals, createStub } from "./test-utils/class-mocks.js";
const wsModuleMock = vi.hoisted(() => { const wsModuleMock = vi.hoisted(() => {
class MockWebSocketServer { class MockWebSocketServer {
@@ -92,14 +93,14 @@ function createServer(agentManagerOverrides?: Record<string, unknown>) {
}; };
const server = new VoiceAssistantWebSocketServer( const server = new VoiceAssistantWebSocketServer(
{} as unknown as HTTPServer, createStub<HTTPServer>({}),
createLogger() as unknown as pino.Logger, createStub<pino.Logger>(createLogger()),
"srv-test", "srv-test",
agentManager as unknown as AgentManager, createStub<AgentManager>(agentManager),
{} as unknown as AgentStorage, createStub<AgentStorage>({}),
{} as unknown as DownloadTokenStore, createStub<DownloadTokenStore>({}),
"/tmp/paseo-test", "/tmp/paseo-test",
daemonConfigStore as unknown as DaemonConfigStore, createStub<DaemonConfigStore>(daemonConfigStore),
null, null,
{ allowedOrigins: new Set() }, { allowedOrigins: new Set() },
undefined, undefined,
@@ -113,10 +114,10 @@ function createServer(agentManagerOverrides?: Record<string, unknown>) {
undefined, undefined,
undefined, undefined,
undefined, undefined,
{} as unknown as FileBackedChatService, createStub<FileBackedChatService>({}),
{} as unknown as LoopService, createStub<LoopService>({}),
{} as unknown as ScheduleService, createStub<ScheduleService>({}),
{ createStub<CheckoutDiffManager>({
subscribe: vi.fn(), subscribe: vi.fn(),
scheduleRefreshForCwd: vi.fn(), scheduleRefreshForCwd: vi.fn(),
getMetrics: vi.fn(() => ({ getMetrics: vi.fn(() => ({
@@ -126,7 +127,7 @@ function createServer(agentManagerOverrides?: Record<string, unknown>) {
checkoutDiffFallbackRefreshTargetCount: 0, checkoutDiffFallbackRefreshTargetCount: 0,
})), })),
dispose: vi.fn(), dispose: vi.fn(),
} as unknown as CheckoutDiffManager, }),
); );
return { server, agentManager }; return { server, agentManager };
@@ -167,7 +168,7 @@ function connectClient(
} | null, } | null,
) { ) {
const ws = createOpenSocket(); const ws = createOpenSocket();
(server as unknown as WebSocketServerInternals).sessions.set(ws, { asInternals<WebSocketServerInternals>(server).sessions.set(ws, {
session: createSessionWithActivity(activity), session: createSessionWithActivity(activity),
clientId: "client-test", clientId: "client-test",
appVersion: null, appVersion: null,
@@ -207,7 +208,7 @@ describe("VoiceAssistantWebSocketServer notification payloads", () => {
getLastAssistantMessage, getLastAssistantMessage,
}); });
await (server as unknown as WebSocketServerInternals).broadcastAgentAttention({ await asInternals<WebSocketServerInternals>(server).broadcastAgentAttention({
agentId: "agent-1", agentId: "agent-1",
provider: "claude", provider: "claude",
reason: "finished", reason: "finished",
@@ -237,7 +238,7 @@ describe("VoiceAssistantWebSocketServer notification payloads", () => {
getLastAssistantMessage, getLastAssistantMessage,
}); });
await (server as unknown as WebSocketServerInternals).broadcastAgentAttention({ await asInternals<WebSocketServerInternals>(server).broadcastAgentAttention({
agentId: "agent-2", agentId: "agent-2",
provider: "claude", provider: "claude",
reason: "finished", reason: "finished",
@@ -263,7 +264,7 @@ describe("VoiceAssistantWebSocketServer notification payloads", () => {
lastActivityAt: new Date(nowMs - 300_000), lastActivityAt: new Date(nowMs - 300_000),
}); });
await (server as unknown as WebSocketServerInternals).broadcastAgentAttention({ await asInternals<WebSocketServerInternals>(server).broadcastAgentAttention({
agentId: "agent-X", agentId: "agent-X",
provider: "claude", provider: "claude",
reason: "finished", reason: "finished",
@@ -278,7 +279,7 @@ describe("VoiceAssistantWebSocketServer notification payloads", () => {
const { server } = createServer(); const { server } = createServer();
const ws = connectClient(server, null); const ws = connectClient(server, null);
await (server as unknown as WebSocketServerInternals).broadcastAgentAttention({ await asInternals<WebSocketServerInternals>(server).broadcastAgentAttention({
agentId: "agent-no-heartbeat", agentId: "agent-no-heartbeat",
provider: "claude", provider: "claude",
reason: "finished", reason: "finished",
@@ -292,7 +293,7 @@ describe("VoiceAssistantWebSocketServer notification payloads", () => {
const { server } = createServer(); const { server } = createServer();
const ws = connectClient(server, null); const ws = connectClient(server, null);
await (server as unknown as WebSocketServerInternals).broadcastAgentAttention({ await asInternals<WebSocketServerInternals>(server).broadcastAgentAttention({
agentId: "agent-no-heartbeat", agentId: "agent-no-heartbeat",
provider: "claude", provider: "claude",
reason: "error", reason: "error",
@@ -9,6 +9,7 @@ import type { FileBackedChatService } from "./chat/chat-service.js";
import type { LoopService } from "./loop-service.js"; import type { LoopService } from "./loop-service.js";
import type { ScheduleService } from "./schedule/service.js"; import type { ScheduleService } from "./schedule/service.js";
import type { CheckoutDiffManager } from "./checkout-diff-manager.js"; import type { CheckoutDiffManager } from "./checkout-diff-manager.js";
import { asInternals, createStub } from "./test-utils/class-mocks.js";
import { import {
asUint8Array, asUint8Array,
decodeTerminalStreamFrame, decodeTerminalStreamFrame,
@@ -191,10 +192,10 @@ function createServer(options?: { speechReadiness?: SpeechReadinessSnapshot | nu
onChange: vi.fn(() => () => {}), onChange: vi.fn(() => () => {}),
}; };
return new VoiceAssistantWebSocketServer( return new VoiceAssistantWebSocketServer(
{} as unknown as HTTPServer, createStub<HTTPServer>({}),
createLogger() as unknown as pino.Logger, createStub<pino.Logger>(createLogger()),
"srv_test", "srv_test",
{ createStub<AgentManager>({
setAgentAttentionCallback: vi.fn(), setAgentAttentionCallback: vi.fn(),
getAgent: vi.fn(() => null), getAgent: vi.fn(() => null),
getMetricsSnapshot: vi.fn(() => ({ getMetricsSnapshot: vi.fn(() => ({
@@ -204,11 +205,11 @@ function createServer(options?: { speechReadiness?: SpeechReadinessSnapshot | nu
pendingPermissionAgents: 0, pendingPermissionAgents: 0,
erroredAgents: 0, erroredAgents: 0,
})), })),
} as unknown as AgentManager, }),
{} as unknown as AgentStorage, createStub<AgentStorage>({}),
{} as unknown as DownloadTokenStore, createStub<DownloadTokenStore>({}),
"/tmp/paseo-test", "/tmp/paseo-test",
daemonConfigStore as unknown as DaemonConfigStore, createStub<DaemonConfigStore>(daemonConfigStore),
null, null,
{ allowedOrigins: new Set() }, { allowedOrigins: new Set() },
undefined, undefined,
@@ -227,10 +228,10 @@ function createServer(options?: { speechReadiness?: SpeechReadinessSnapshot | nu
undefined, undefined,
undefined, undefined,
undefined, undefined,
{} as unknown as FileBackedChatService, createStub<FileBackedChatService>({}),
{} as unknown as LoopService, createStub<LoopService>({}),
{} as unknown as ScheduleService, createStub<ScheduleService>({}),
{ createStub<CheckoutDiffManager>({
subscribe: vi.fn(), subscribe: vi.fn(),
scheduleRefreshForCwd: vi.fn(), scheduleRefreshForCwd: vi.fn(),
getMetrics: vi.fn(() => ({ getMetrics: vi.fn(() => ({
@@ -240,7 +241,7 @@ function createServer(options?: { speechReadiness?: SpeechReadinessSnapshot | nu
checkoutDiffFallbackRefreshTargetCount: 0, checkoutDiffFallbackRefreshTargetCount: 0,
})), })),
dispose: vi.fn(), dispose: vi.fn(),
} as unknown as CheckoutDiffManager, }),
); );
} }
@@ -366,7 +367,7 @@ async function attachDirectAndHello(params: {
socket: MockSocket; socket: MockSocket;
clientId: string; clientId: string;
}) { }) {
await (params.server as unknown as WebSocketServerInternals).attachSocket( await asInternals<WebSocketServerInternals>(params.server).attachSocket(
params.socket, params.socket,
createDirectRequest(), createDirectRequest(),
); );
@@ -426,10 +427,7 @@ describe("relay external socket reconnect behavior", () => {
const server = createServer(); const server = createServer();
const socket = new MockSocket(); const socket = new MockSocket();
await (server as unknown as WebSocketServerInternals).attachSocket( await asInternals<WebSocketServerInternals>(server).attachSocket(socket, createDirectRequest());
socket,
createDirectRequest(),
);
socket.emit( socket.emit(
"message", "message",
JSON.stringify( JSON.stringify(
@@ -460,10 +458,7 @@ describe("relay external socket reconnect behavior", () => {
closeReason = typeof reason === "string" ? reason : ""; closeReason = typeof reason === "string" ? reason : "";
}); });
await (server as unknown as WebSocketServerInternals).attachSocket( await asInternals<WebSocketServerInternals>(server).attachSocket(socket, createDirectRequest());
socket,
createDirectRequest(),
);
await vi.advanceTimersByTimeAsync(15_000); await vi.advanceTimersByTimeAsync(15_000);
expect(closeCode).toBe(4001); expect(closeCode).toBe(4001);