diff --git a/src/utils/agentToolFilter.ts b/src/utils/agentToolFilter.ts new file mode 100644 index 000000000..a9c3e2d28 --- /dev/null +++ b/src/utils/agentToolFilter.ts @@ -0,0 +1,23 @@ +/** + * filterParentToolsForFork — gate layer 2 for subagent tool inheritance. + * + * The fork path of AgentTool (and its sibling resumeAgent) sets + * `useExactTools: true` and passes `toolUseContext.options.tools` to + * `runAgent` as `availableTools`. With `useExactTools=true`, runAgent + * skips `resolveAgentTools`, which means the gate layer 1 + * (`ALL_AGENT_DISALLOWED_TOOLS`) — which only takes effect inside + * `filterToolsForAgent` — is bypassed entirely on fork paths. + * + * This filter applies the same disallow-list to the parent tool array + * before it reaches the fork. Both new-fork (AgentTool.tsx) and + * resumed-fork (resumeAgent.ts) paths must call this. + * + * See docs/jira/LOCAL-WIRING-DESIGN.md §4.5 / §5.5 for design rationale. + */ + +import { ALL_AGENT_DISALLOWED_TOOLS } from '../constants/tools.js' +import type { Tool } from '../Tool.js' + +export function filterParentToolsForFork(parentTools: readonly Tool[]): Tool[] { + return parentTools.filter(t => !ALL_AGENT_DISALLOWED_TOOLS.has(t.name)) +} diff --git a/tests/mocks/auth.ts b/tests/mocks/auth.ts new file mode 100644 index 000000000..7c0da17a7 --- /dev/null +++ b/tests/mocks/auth.ts @@ -0,0 +1,31 @@ +/** + * Shared mock for `src/utils/auth.js`. Use it via: + * + * import { authMock } from '../../tests/mocks/auth' + * mock.module('src/utils/auth.js', authMock) + * + * Tests that need different return values can override the helper used by + * the suite (e.g. by extending this object and re-registering with mock.module). + * Always extend here rather than inlining a different shape per test, so the + * surface stays consistent when `auth.ts` exports change. + */ +export const authMock = () => ({ + // Mirrors the production contract: src/utils/auth.ts returns + // Promise ("did the access token change") and a token object that + // carries scopes, subscriptionType, expiresAt, etc. Tests that branch on + // these values must see the full shape so they can not silently drift away + // from production. + checkAndRefreshOAuthTokenIfNeeded: async () => false, + getClaudeAIOAuthTokens: () => ({ + accessToken: 'token', + refreshToken: null, + expiresAt: null, + scopes: ['user:inference'], + subscriptionType: null, + rateLimitTier: null, + }), + isClaudeAISubscriber: () => true, + isProSubscriber: () => false, + isMaxSubscriber: () => false, + isTeamSubscriber: () => false, +}) diff --git a/tests/mocks/axios.ts b/tests/mocks/axios.ts new file mode 100644 index 000000000..e21a1209c --- /dev/null +++ b/tests/mocks/axios.ts @@ -0,0 +1,121 @@ +/** + * Per-file axios mock helper. + * + * Each call to `setupAxiosMock()` registers its own `mock.module('axios', ...)` + * that only knows about the handle returned to that call. No shared state between + * test files — eliminates cross-file mock pollution. + * + * The real axios module is cached at first import (before any mock.module + * registration) so the factory can spread it for shape compatibility. + * + * Usage in a test file: + * + * import { setupAxiosMock } from '../../../tests/mocks/axios' + * + * const axiosHandle = setupAxiosMock() + * axiosHandle.stubs.get = (url, config) => Promise.resolve({ status: 200, data: {...}, headers: {}, statusText: 'OK', config }) + * axiosHandle.stubs.post = ... + * + * beforeAll(() => { axiosHandle.useStubs = true }) + * afterAll(() => { axiosHandle.useStubs = false }) + * + * If your suite needs an `isAxiosError` predicate that recognises plain + * objects with `isAxiosError: true`, set `axiosHandle.stubs.isAxiosError` — + * otherwise the real axios's predicate is used. + */ + +import { mock } from 'bun:test' + +// eslint-disable-next-line @typescript-eslint/no-require-imports +const _realAxios = require('axios') as Record +const _realDefault = ((_realAxios.default as + | Record + | undefined) ?? _realAxios) as Record + +type AnyFn = (...args: any[]) => unknown + +export type AxiosMethodStubs = { + get?: AnyFn + post?: AnyFn + put?: AnyFn + patch?: AnyFn + delete?: AnyFn + head?: AnyFn + options?: AnyFn + request?: AnyFn + isAxiosError?: (e: unknown) => boolean + isCancel?: (e: unknown) => boolean + create?: AnyFn +} + +export type AxiosMockHandle = { + useStubs: boolean + stubs: AxiosMethodStubs +} + +/** + * Register a mock for `axios` scoped to this test file. + * Each call creates an independent mock.module registration — no shared + * handles array, no cross-file state. + */ +export function setupAxiosMock(): AxiosMockHandle { + const handle: AxiosMockHandle = { useStubs: false, stubs: {} } + + mock.module('axios', () => { + const route = (method: keyof AxiosMethodStubs): AnyFn => { + const realFn = _realDefault[method] as AnyFn | undefined + return (...args: unknown[]) => { + if (handle.useStubs) { + const stub = handle.stubs[method] as AnyFn | undefined + if (stub) return stub(...args) + } + if (typeof realFn === 'function') return realFn(...args) + throw new Error(`axios.${method} is not available on real axios`) + } + } + + const verbs: (keyof AxiosMethodStubs)[] = [ + 'get', + 'post', + 'put', + 'patch', + 'delete', + 'head', + 'options', + 'request', + 'create', + ] + + const routedDefault: Record = { ..._realDefault } + for (const v of verbs) { + routedDefault[v] = route(v) + } + + routedDefault.isAxiosError = (e: unknown) => { + if (handle.useStubs && handle.stubs.isAxiosError) { + return handle.stubs.isAxiosError(e) + } + const realPredicate = _realDefault.isAxiosError as + | ((e: unknown) => boolean) + | undefined + return realPredicate ? realPredicate(e) : false + } + routedDefault.isCancel = (e: unknown) => { + if (handle.useStubs && handle.stubs.isCancel) { + return handle.stubs.isCancel(e) + } + const realPredicate = _realDefault.isCancel as + | ((e: unknown) => boolean) + | undefined + return realPredicate ? realPredicate(e) : false + } + + return { + ..._realAxios, + ...routedDefault, + default: routedDefault, + } + }) + + return handle +} diff --git a/tests/mocks/childProcess.ts b/tests/mocks/childProcess.ts new file mode 100644 index 000000000..37219d105 --- /dev/null +++ b/tests/mocks/childProcess.ts @@ -0,0 +1,45 @@ +/** + * Shared mock for `node:child_process`. + * + * Usage: + * import { mock } from 'bun:test' + * import { childProcessMock, execFileMock, execFileSyncMock } from 'tests/mocks/childProcess' + * mock.module('node:child_process', () => childProcessMock) + * + * Call `execFileMock.mockImplementation(...)` or `execFileSyncMock.mockImplementation(...)` + * before each test that needs specific behavior. + */ +import { mock } from 'bun:test' + +// execFile: node-style callback (cmd, args, opts?, callback) +export const execFileMock = mock( + ( + _cmd: string, + _args: string[], + _optsOrCb?: unknown, + _cb?: (err: Error | null, stdout: string, stderr: string) => void, + ) => { + const cb = + typeof _optsOrCb === 'function' + ? (_optsOrCb as ( + err: Error | null, + stdout: string, + stderr: string, + ) => void) + : _cb + if (cb) cb(null, '', '') + return null + }, +) + +// execFileSync: synchronous (returns Buffer) +export const execFileSyncMock = mock( + (_cmd: string, _args: string[], _opts?: unknown): Buffer => { + return Buffer.from('') + }, +) + +export const childProcessMock = { + execFile: execFileMock, + execFileSync: execFileSyncMock, +} diff --git a/tests/mocks/log.ts b/tests/mocks/log.ts new file mode 100644 index 000000000..711e6f715 --- /dev/null +++ b/tests/mocks/log.ts @@ -0,0 +1,24 @@ +/** + * Shared mock for src/utils/log.ts + * + * Cuts the bootstrap/state.ts dependency chain (module-level realpathSync + randomUUID). + * Must be called via mock.module("src/utils/log.ts", logMock) BEFORE any import that + * transitively depends on log.ts. + * + * Exported as a factory so each call produces a fresh object (mock.module requirement). + */ +export function logMock() { + return { + logError: () => {}, + getLogDisplayTitle: () => '', + dateToFilename: (d: Date) => d.toISOString().replace(/[:.]/g, '-'), + attachErrorLogSink: () => {}, + getInMemoryErrors: () => [] as Array<{ error: string; timestamp: string }>, + loadErrorLogs: async () => [], + getErrorLogByIndex: async () => null, + logMCPError: () => {}, + logMCPDebug: () => {}, + captureAPIRequest: () => {}, + _resetErrorLogForTesting: () => {}, + } +} diff --git a/tests/mocks/state.ts b/tests/mocks/state.ts new file mode 100644 index 000000000..84886995a --- /dev/null +++ b/tests/mocks/state.ts @@ -0,0 +1,91 @@ +/** + * Shared partial mock for src/bootstrap/state.ts + * + * Covers the most commonly imported exports plus their transitive callers. + * Add exports here when new tests need them — never mock exports that don't exist. + * + * Usage: + * import { stateMock } from '../../../tests/mocks/state' + * mock.module('src/bootstrap/state.js', stateMock) + */ +export function stateMock() { + const noop = () => {} + return { + // Session identity + getSessionId: () => 'mock-session-id', + regenerateSessionId: noop, + getParentSessionId: () => undefined, + switchSession: noop, + onSessionSwitch: () => () => {}, + + // CWD / project + getOriginalCwd: () => '/mock/cwd', + getSessionProjectDir: () => null, + getProjectRoot: () => '/mock/project', + getCwdState: () => '/mock/cwd', + setCwdState: noop, + setOriginalCwd: noop, + setProjectRoot: noop, + + // Direct-connect + getDirectConnectServerUrl: () => undefined, + setDirectConnectServerUrl: noop, + + // Duration / cost accumulators + addToTotalDurationState: noop, + resetTotalDurationStateAndCost_FOR_TESTS_ONLY: noop, + addToTotalCostState: noop, + getTotalCostUSD: () => 0, + getTotalAPIDuration: () => 0, + getTotalDuration: () => 0, + getTotalAPIDurationWithoutRetries: () => 0, + getTotalToolDuration: () => 0, + addToToolDuration: noop, + + // Turn stats + getTurnHookDurationMs: () => 0, + addToTurnHookDuration: noop, + resetTurnHookDuration: noop, + getTurnHookCount: () => 0, + getTurnToolDurationMs: () => 0, + resetTurnToolDuration: noop, + getTurnToolCount: () => 0, + getTurnClassifierDurationMs: () => 0, + addToTurnClassifierDuration: noop, + resetTurnClassifierDuration: noop, + getTurnClassifierCount: () => 0, + + // Stats store + getStatsStore: () => ({}), + setStatsStore: noop, + + // Interaction time + updateLastInteractionTime: noop, + flushInteractionTime: noop, + + // Lines changed + addToTotalLinesChanged: noop, + getTotalLinesAdded: () => 0, + getTotalLinesRemoved: () => 0, + + // Token counts + getTotalInputTokens: () => 0, + getTotalOutputTokens: () => 0, + getTotalCacheReadInputTokens: () => 0, + getTotalCacheCreationInputTokens: () => 0, + getTotalWebSearchRequests: () => 0, + getTurnOutputTokens: () => 0, + getCurrentTurnTokenBudget: () => null, + + // API request state + setLastAPIRequest: noop, + getLastAPIRequest: () => null, + setLastAPIRequestMessages: noop, + getLastAPIRequestMessages: () => [], + + // Various getters (add as needed) + getIsNonInteractiveSession: () => false, + getSdkAgentProgressSummariesEnabled: () => false, + addSlowOperation: noop, + } +} diff --git a/tests/mocks/toolContext.ts b/tests/mocks/toolContext.ts new file mode 100644 index 000000000..424f9acff --- /dev/null +++ b/tests/mocks/toolContext.ts @@ -0,0 +1,52 @@ +/** + * Shared minimal ToolUseContext stub for tool unit tests. + * + * Provides only the fields tools actually access in tests: + * - getAppState() returns a context with empty rule arrays for every source + * - toolUseId / parentMessageId / assistantMessageId / turnId can be + * overridden per test for budget tracking tests + * + * Usage: + * import { mockToolContext } from 'tests/mocks/toolContext' + * const ctx = mockToolContext({ toolUseId: 't1' }) + * + * Per memory feedback "Mock dependency not subject" — this exists so each + * tool test file does not redefine the same partial stub. + */ + +const emptyRules = { + user: [], + project: [], + local: [], + session: [], + cliArg: [], +} + +export interface MockToolContextOptions { + toolUseId?: string + parentMessageId?: string + assistantMessageId?: string + turnId?: string + /** Override toolPermissionContext fields (e.g. mode, alwaysAllowRules). */ + permissionOverrides?: Record +} + +export function mockToolContext(opts: MockToolContextOptions = {}): never { + return { + toolUseId: opts.toolUseId, + parentMessageId: opts.parentMessageId, + assistantMessageId: opts.assistantMessageId, + turnId: opts.turnId, + getAppState: () => ({ + toolPermissionContext: { + mode: 'default', + additionalWorkingDirectories: new Set(), + alwaysAllowRules: { ...emptyRules }, + alwaysDenyRules: { ...emptyRules }, + alwaysAskRules: { ...emptyRules }, + isBypassPermissionsModeAvailable: false, + ...(opts.permissionOverrides ?? {}), + }, + }), + } as never +}