diff --git a/packages/cli/src/commands/extensions/consent.ts b/packages/cli/src/commands/extensions/consent.ts index 775fe7b4360..57f2fa3edcf 100644 --- a/packages/cli/src/commands/extensions/consent.ts +++ b/packages/cli/src/commands/extensions/consent.ts @@ -165,7 +165,10 @@ export function extensionConsentString( output.push( t('Installing extension "{{name}}".', { name: extensionConfig.name }), ); - if (typeof extensionConfig.description === 'string' && extensionConfig.description) { + if ( + typeof extensionConfig.description === 'string' && + extensionConfig.description + ) { output.push(stripAnsi(extensionConfig.description)); } output.push( diff --git a/packages/cli/src/commands/extensions/utils.ts b/packages/cli/src/commands/extensions/utils.ts index 93e33529fd2..825200efa00 100644 --- a/packages/cli/src/commands/extensions/utils.ts +++ b/packages/cli/src/commands/extensions/utils.ts @@ -54,7 +54,10 @@ export function extensionToOutputString( const status = workspaceEnabled ? chalk.green('✓') : chalk.red('✗'); let output = `${inline ? '' : status} ${extension.config.name} (${extension.config.version})`; - if (typeof extension.config.description === 'string' && extension.config.description) { + if ( + typeof extension.config.description === 'string' && + extension.config.description + ) { output += `\n ${t('Description:')} ${stripAnsi(extension.config.description)}`; } output += `\n ${t('Path:')} ${extension.path}`; diff --git a/packages/cli/src/ui/hooks/useGeminiStream.ts b/packages/cli/src/ui/hooks/useGeminiStream.ts index 34f09dc9e75..5daf82d9133 100644 --- a/packages/cli/src/ui/hooks/useGeminiStream.ts +++ b/packages/cli/src/ui/hooks/useGeminiStream.ts @@ -2450,7 +2450,8 @@ export const useGeminiStream = ( } for (const toolCall of restorableToolCalls) { - const filePath = (toolCall.request.args['file_path'] ?? toolCall.request.args['notebook_path']) as string; + const filePath = (toolCall.request.args['file_path'] ?? + toolCall.request.args['notebook_path']) as string; if (!filePath) { onDebugMessage( `Skipping restorable tool call due to missing file_path: ${toolCall.request.name}`, @@ -2501,14 +2502,7 @@ export const useGeminiStream = ( } }; saveRestorableToolCalls(); - }, [ - toolCalls, - config, - onDebugMessage, - history, - geminiClient, - storage, - ]); + }, [toolCalls, config, onDebugMessage, history, geminiClient, storage]); // ─── Unified notification queue (cron + background agents) ────── const notificationQueueRef = useRef< diff --git a/packages/core/src/config/config.test.ts b/packages/core/src/config/config.test.ts index deeaf5020a6..7bfa0f732fa 100644 --- a/packages/core/src/config/config.test.ts +++ b/packages/core/src/config/config.test.ts @@ -3551,8 +3551,9 @@ describe('Model Switching and Config Updates', () => { } it('resolves getters to the runtime view inside the frame, instance fields outside', async () => { - const { runWithRuntimeContentGenerator } = - await import('../agents/runtime/agent-context.js'); + const { runWithRuntimeContentGenerator } = await import( + '../agents/runtime/agent-context.js' + ); const config = new Config(baseParams); const parentGenerator = { generateContentStream: vi.fn(), @@ -3599,8 +3600,9 @@ describe('Model Switching and Config Updates', () => { }); it('falls back to the parent model id when the runtime view config has no model', async () => { - const { runWithRuntimeContentGenerator } = - await import('../agents/runtime/agent-context.js'); + const { runWithRuntimeContentGenerator } = await import( + '../agents/runtime/agent-context.js' + ); const config = new Config(baseParams); setInstanceFields( config, diff --git a/packages/core/src/core/client.test.ts b/packages/core/src/core/client.test.ts index ae1a7e1d0d0..1c8fb9d8f73 100644 --- a/packages/core/src/core/client.test.ts +++ b/packages/core/src/core/client.test.ts @@ -1588,6 +1588,14 @@ describe('Gemini Client (client.ts)', () => { await client.resetChat(); expect(client['lastInjectedDate']).toBeUndefined(); }); + + it('resets Hook microcompaction checkpoint', async () => { + client['lastHookMicrocompactionTimestamp'] = Date.now(); + + await client.resetChat(); + + expect(client['lastHookMicrocompactionTimestamp']).toBeNull(); + }); }); describe('history mutation invalidates FileReadCache', () => { @@ -1829,6 +1837,25 @@ describe('Gemini Client (client.ts)', () => { expect(client['lastApiCompletionTimestamp']).toBeNull(); }); + + it('seeds Hook microcompaction checkpoint on user turns', async () => { + client['lastHookMicrocompactionTimestamp'] = null; + const before = Date.now(); + + const gen = client.sendMessageStream( + [{ text: 'Hello' }], + new AbortController().signal, + 'prompt-hook-seed', + { type: SendMessageType.UserQuery }, + ); + for await (const _ of gen) { + /* drain */ + } + + expect(client['lastHookMicrocompactionTimestamp']).toBeGreaterThanOrEqual( + before, + ); + }); }); describe('microcompaction FileReadCache invalidation', () => { @@ -1924,6 +1951,246 @@ describe('Gemini Client (client.ts)', () => { expect(markReadEvictedFromHistory).toHaveBeenCalledTimes(1); }); + it('does not abort the turn when microcompaction cleanup fails', async () => { + const { markReadEvictedFromHistory } = mockFileReadCacheStub(); + markReadEvictedFromHistory.mockImplementation(() => { + throw new Error('cache disarm failed'); + }); + + const { history } = await makeReadFileResponses(6); + client['chat'] = { + addHistory: vi.fn(), + getHistory: vi.fn().mockReturnValue(history), + setHistory: vi.fn(), + } as unknown as GeminiChat; + client['lastApiCompletionTimestamp'] = Date.now() - 90 * 60_000; + + const events: ServerGeminiStreamEvent[] = []; + const stream = client.sendMessageStream( + [{ text: 'hi' }], + new AbortController().signal, + 'prompt-mc-error-boundary', + { type: SendMessageType.UserQuery }, + ); + for await (const event of stream) { + events.push(event); + } + + expect(events).toEqual([ + { type: GeminiEventType.Content, value: 'response' }, + ]); + }); + + it('microcompacts old tool results on Hook continuations', async () => { + const { clear, markReadEvictedFromHistory } = mockFileReadCacheStub(); + + const { history } = await makeReadFileResponses(6); + const setHistory = vi.fn(); + client['chat'] = { + addHistory: vi.fn(), + getHistory: vi.fn().mockReturnValue(history), + setHistory, + } as unknown as GeminiChat; + client['lastApiCompletionTimestamp'] = Date.now(); + client['lastHookMicrocompactionTimestamp'] = Date.now() - 90 * 60_000; + + const stream = client.sendMessageStream( + [{ text: 'continue goal' }], + new AbortController().signal, + 'prompt-mc-hook', + { type: SendMessageType.Hook }, + ); + for await (const _ of stream) { + /* drain */ + } + + expect(setHistory).toHaveBeenCalled(); + expect(clear).not.toHaveBeenCalled(); + expect(markReadEvictedFromHistory).toHaveBeenCalledTimes(1); + expect(client['lastHookMicrocompactionTimestamp']).toBeGreaterThan( + Date.now() - 60_000, + ); + }); + + it('does not abort Hook continuations when microcompaction cleanup fails', async () => { + const { markReadEvictedFromHistory } = mockFileReadCacheStub(); + markReadEvictedFromHistory.mockImplementation(() => { + throw new Error('hook cache disarm failed'); + }); + + const { history } = await makeReadFileResponses(6); + client['chat'] = { + addHistory: vi.fn(), + getHistory: vi.fn().mockReturnValue(history), + setHistory: vi.fn(), + } as unknown as GeminiChat; + client['lastApiCompletionTimestamp'] = Date.now(); + const checkpoint = Date.now() - 90 * 60_000; + client['lastHookMicrocompactionTimestamp'] = checkpoint; + mockClientDebugLogger.error.mockClear(); + + const events: ServerGeminiStreamEvent[] = []; + const stream = client.sendMessageStream( + [{ text: 'continue goal' }], + new AbortController().signal, + 'prompt-mc-hook-error-boundary', + { type: SendMessageType.Hook }, + ); + for await (const event of stream) { + events.push(event); + } + + expect(events).toEqual([ + { type: GeminiEventType.Content, value: 'response' }, + ]); + expect(mockClientDebugLogger.error).toHaveBeenCalledWith( + expect.stringContaining( + 'microcompactHistory failed: hook cache disarm failed', + ), + ); + expect(client['lastHookMicrocompactionTimestamp']).toBe(checkpoint); + }); + + it('skips the next Hook microcompaction after one just ran', async () => { + const { clear, markReadEvictedFromHistory } = mockFileReadCacheStub(); + + const { history } = await makeReadFileResponses(6); + const setHistory = vi.fn(); + client['chat'] = { + addHistory: vi.fn(), + getHistory: vi.fn().mockReturnValue(history), + setHistory, + } as unknown as GeminiChat; + client['lastApiCompletionTimestamp'] = Date.now(); + client['lastHookMicrocompactionTimestamp'] = Date.now() - 90 * 60_000; + + const firstStream = client.sendMessageStream( + [{ text: 'continue goal' }], + new AbortController().signal, + 'prompt-mc-hook-fire', + { type: SendMessageType.Hook }, + ); + for await (const _ of firstStream) { + /* drain */ + } + + const checkpointAfterFire = client['lastHookMicrocompactionTimestamp']; + expect(setHistory).toHaveBeenCalled(); + expect(checkpointAfterFire).toBeGreaterThan(Date.now() - 60_000); + + setHistory.mockClear(); + clear.mockClear(); + markReadEvictedFromHistory.mockClear(); + + const secondStream = client.sendMessageStream( + [{ text: 'continue goal again' }], + new AbortController().signal, + 'prompt-mc-hook-skip', + { type: SendMessageType.Hook }, + ); + for await (const _ of secondStream) { + /* drain */ + } + + expect(client['lastHookMicrocompactionTimestamp']).toBe( + checkpointAfterFire, + ); + expect(setHistory).not.toHaveBeenCalled(); + expect(clear).not.toHaveBeenCalled(); + expect(markReadEvictedFromHistory).not.toHaveBeenCalled(); + }); + + it('initializes Hook microcompaction from the last API completion timestamp', async () => { + const { clear, markReadEvictedFromHistory } = mockFileReadCacheStub(); + + const { history } = await makeReadFileResponses(6); + const setHistory = vi.fn(); + client['chat'] = { + addHistory: vi.fn(), + getHistory: vi.fn().mockReturnValue(history), + setHistory, + } as unknown as GeminiChat; + client['lastApiCompletionTimestamp'] = Date.now() - 90 * 60_000; + client['lastHookMicrocompactionTimestamp'] = null; + + const stream = client.sendMessageStream( + [{ text: 'continue goal' }], + new AbortController().signal, + 'prompt-mc-hook-init', + { type: SendMessageType.Hook }, + ); + for await (const _ of stream) { + /* drain */ + } + + expect(setHistory).toHaveBeenCalled(); + expect(clear).not.toHaveBeenCalled(); + expect(markReadEvictedFromHistory).toHaveBeenCalledTimes(1); + expect(client['lastHookMicrocompactionTimestamp']).toBeGreaterThan( + Date.now() - 60_000, + ); + }); + + it('does not microcompact Hook continuations when the checkpoint is recent', async () => { + const { clear, markReadEvictedFromHistory } = mockFileReadCacheStub(); + + const { history } = await makeReadFileResponses(6); + const setHistory = vi.fn(); + client['chat'] = { + addHistory: vi.fn(), + getHistory: vi.fn().mockReturnValue(history), + setHistory, + } as unknown as GeminiChat; + client['lastApiCompletionTimestamp'] = Date.now() - 90 * 60_000; + client['lastHookMicrocompactionTimestamp'] = Date.now(); + + const stream = client.sendMessageStream( + [{ text: 'continue goal' }], + new AbortController().signal, + 'prompt-mc-hook-recent', + { type: SendMessageType.Hook }, + ); + for await (const _ of stream) { + /* drain */ + } + + expect(setHistory).not.toHaveBeenCalled(); + expect(clear).not.toHaveBeenCalled(); + expect(markReadEvictedFromHistory).not.toHaveBeenCalled(); + }); + + it('seeds Hook microcompaction checkpoint to now when no API call completed', async () => { + const { clear, markReadEvictedFromHistory } = mockFileReadCacheStub(); + + const { history } = await makeReadFileResponses(6); + const setHistory = vi.fn(); + client['chat'] = { + addHistory: vi.fn(), + getHistory: vi.fn().mockReturnValue(history), + setHistory, + } as unknown as GeminiChat; + client['lastApiCompletionTimestamp'] = null; + client['lastHookMicrocompactionTimestamp'] = null; + const before = Date.now(); + + const stream = client.sendMessageStream( + [{ text: 'continue goal' }], + new AbortController().signal, + 'prompt-mc-hook-no-api-completion', + { type: SendMessageType.Hook }, + ); + for await (const _ of stream) { + /* drain */ + } + + expect(client['lastHookMicrocompactionTimestamp']).toBeGreaterThanOrEqual( + before, + ); + expect(setHistory).not.toHaveBeenCalled(); + expect(clear).not.toHaveBeenCalled(); + expect(markReadEvictedFromHistory).not.toHaveBeenCalled(); + }); + it('falls back to a blanket clear when blanked reads cannot be linked to a path (id-less provider)', async () => { // Provider did not populate functionCall.id, so microcompaction // cannot recover the blanked reads' file paths. Leaving their @@ -2240,6 +2507,35 @@ describe('Gemini Client (client.ts)', () => { expect(markReadEvictedFromHistory).toHaveBeenCalled(); }); + it('does not reset the Hook checkpoint when Cron skips microcompaction', async () => { + const { clear, markReadEvictedFromHistory } = mockFileReadCacheStub(); + const { history } = await makeReadFileResponses(6); + const setHistory = vi.fn(); + client['chat'] = { + addHistory: vi.fn(), + getHistory: vi.fn().mockReturnValue(history), + setHistory, + } as unknown as GeminiChat; + client['lastApiCompletionTimestamp'] = Date.now(); + const checkpoint = Date.now() - 90 * 60_000; + client['lastHookMicrocompactionTimestamp'] = checkpoint; + + const stream = client.sendMessageStream( + [{ text: 'cron job' }], + new AbortController().signal, + 'prompt-cron-hook-checkpoint', + { type: SendMessageType.Cron }, + ); + for await (const _ of stream) { + /* drain */ + } + + expect(client['lastHookMicrocompactionTimestamp']).toBe(checkpoint); + expect(setHistory).not.toHaveBeenCalled(); + expect(clear).not.toHaveBeenCalled(); + expect(markReadEvictedFromHistory).not.toHaveBeenCalled(); + }); + it('does not run microcompaction on SendMessageType.Retry', async () => { const { clear, markReadEvictedFromHistory } = mockFileReadCacheStub(); const { history } = await makeReadFileResponses(6); diff --git a/packages/core/src/core/client.ts b/packages/core/src/core/client.ts index 122a61cedbb..690747f6253 100644 --- a/packages/core/src/core/client.ts +++ b/packages/core/src/core/client.ts @@ -224,6 +224,8 @@ export class GeminiClient { * so the idle check is skipped until the first API call completes. */ private lastApiCompletionTimestamp: number | null = null; + /** Cleanup checkpoint for long-running Hook continuations such as /goal. */ + private lastHookMicrocompactionTimestamp: number | null = null; constructor(private readonly config: Config) { this.loopDetector = new LoopDetectionService(config); @@ -589,6 +591,7 @@ export class GeminiClient { this.surfacedRelevantAutoMemoryPaths.clear(); this.cachedGitStatus = undefined; this.lastApiCompletionTimestamp = null; + this.lastHookMicrocompactionTimestamp = null; // startChat() rewrites the chat to its initial state. Any prior // read_file tool results the FileReadCache still tracks are no // longer in history, so a follow-up Read would serve a placeholder @@ -1328,6 +1331,78 @@ export class GeminiClient { this.toolCallCount += 1; } + private async microcompactIdleHistory( + lastCompletionTimestamp: number | null, + ): Promise { + try { + const mcResult = microcompactHistory( + this.getHistoryShallow(), + lastCompletionTimestamp, + this.config.getClearContextOnIdle(), + ); + if (!mcResult.meta) { + return false; + } + + const m = mcResult.meta; + this.getChat().setHistory(mcResult.history); + // Disarm only the blanked files' fast-path, keeping + // read-before-write state intact (issue #4239; rationale on + // FileReadEntry.readResidentInHistory). Any blanked read we + // can't disarm surgically forces the old blanket wipe so a + // later Read can't get a dangling file_unchanged placeholder. + const fileReadCache = this.config.getFileReadCache(); + if (m.unresolvedEvictedReads > 0) { + debugLogger.debug( + `[FILE_READ_CACHE] clear after microcompaction ` + + `(${m.unresolvedEvictedReads} unresolved blanked read(s))`, + ); + fileReadCache.clear(); + } else { + // Concurrent stats — don't serialize N FS round-trips + // before the next turn. + const statResults = await Promise.all( + m.evictedReadPaths.map((p) => + fsPromises.stat(p).catch(() => undefined), + ), + ); + // A path is surgically disarmed only if it stats AND its + // inode matches the recorded entry. A failed stat or inode + // miss could leave a stale entry armed, so fall back to the + // blanket wipe if any path is unresolvable. + let fullyDisarmed = true; + for (const stats of statResults) { + if (!stats || !fileReadCache.markReadEvictedFromHistory(stats)) { + fullyDisarmed = false; + } + } + if (fullyDisarmed) { + debugLogger.debug( + `[FILE_READ_CACHE] disarmed fast-path for ` + + `${m.evictedReadPaths.length} file(s) after microcompaction`, + ); + } else { + debugLogger.debug( + '[FILE_READ_CACHE] clear after microcompaction ' + + '(an evicted path was unresolvable)', + ); + fileReadCache.clear(); + } + } + debugLogger.debug( + `[TIME-BASED MC] gap ${m.gapMinutes}min > ${m.thresholdMinutes}min, ` + + `cleared ${m.toolsCleared} tool result(s) + ${m.mediaCleared} media (~${m.tokensSaved} tokens), ` + + `kept ${m.toolsKept} tool / ${m.mediaKept} media`, + ); + return true; + } catch (err) { + debugLogger.error( + `[TIME-BASED MC] microcompactHistory failed: ${err instanceof Error ? err.message : String(err)}`, + ); + return false; + } + } + async *sendMessageStream( request: PartListUnion, signal: AbortSignal, @@ -1527,84 +1602,25 @@ export class GeminiClient { } } - // Idle cleanup: clear old tool results when idle > threshold. - // Runs on UserQuery, Cron, and Hook messages. Hook is required - // for goal-mode loops where the model drives continuation without - // user input — without this, tool results accumulate indefinitely - // and cause OOM (old_space exhaustion). - // ToolResult, Retry, Notification are excluded: ToolResult fires - // on every tool-call return (O(history) overhead per call), and - // mid-loop compaction could blank results the model still needs. - const shouldCompact = + if ( messageType === SendMessageType.UserQuery || - messageType === SendMessageType.Cron || - messageType === SendMessageType.Hook; - if (shouldCompact) { - try { - const mcResult = microcompactHistory( - this.getHistoryShallow(), - this.lastApiCompletionTimestamp, - this.config.getClearContextOnIdle(), - ); - if (mcResult.meta) { - const m = mcResult.meta; - this.getChat().setHistory(mcResult.history); - // Disarm only the blanked files' fast-path, keeping - // read-before-write state intact (issue #4239; rationale on - // FileReadEntry.readResidentInHistory). Any blanked read we - // can't disarm surgically forces the old blanket wipe so a - // later Read can't get a dangling file_unchanged placeholder. - const fileReadCache = this.config.getFileReadCache(); - if (m.unresolvedEvictedReads > 0) { - debugLogger.debug( - `[FILE_READ_CACHE] clear after microcompaction ` + - `(${m.unresolvedEvictedReads} unresolved blanked read(s))`, - ); - fileReadCache.clear(); - } else { - // Concurrent stats — don't serialize N FS round-trips - // before the next turn. - const statResults = await Promise.all( - m.evictedReadPaths.map((p) => - fsPromises.stat(p).catch(() => undefined), - ), - ); - // A path is surgically disarmed only if it stats AND its - // inode matches the recorded entry. A failed stat or inode - // miss could leave a stale entry armed, so fall back to the - // blanket wipe if any path is unresolvable. - let fullyDisarmed = true; - for (const stats of statResults) { - if ( - !stats || - !fileReadCache.markReadEvictedFromHistory(stats) - ) { - fullyDisarmed = false; - } - } - if (fullyDisarmed) { - debugLogger.debug( - `[FILE_READ_CACHE] disarmed fast-path for ` + - `${m.evictedReadPaths.length} file(s) after microcompaction`, - ); - } else { - debugLogger.debug( - '[FILE_READ_CACHE] clear after microcompaction ' + - '(an evicted path was unresolvable)', - ); - fileReadCache.clear(); - } - } - debugLogger.debug( - `[TIME-BASED MC] gap ${m.gapMinutes}min > ${m.thresholdMinutes}min, ` + - `cleared ${m.toolsCleared} tool result(s) + ${m.mediaCleared} media (~${m.tokensSaved} tokens), ` + - `kept ${m.toolsKept} tool / ${m.mediaKept} media`, - ); - } - } catch (err) { - debugLogger.error( - `[TIME-BASED MC] microcompactHistory failed: ${err instanceof Error ? err.message : String(err)}`, - ); + messageType === SendMessageType.Cron + ) { + // Idle cleanup: clear old tool results when idle > threshold. + // Runs on user and cron messages. ToolResult and Retry are + // excluded; Hook continuations use a separate checkpoint below. + const compacted = await this.microcompactIdleHistory( + this.lastApiCompletionTimestamp, + ); + if (messageType === SendMessageType.UserQuery || compacted) { + this.lastHookMicrocompactionTimestamp = Date.now(); + } + } else if (messageType === SendMessageType.Hook) { + this.lastHookMicrocompactionTimestamp ??= + this.lastApiCompletionTimestamp ?? Date.now(); + const checkpoint = this.lastHookMicrocompactionTimestamp; + if (await this.microcompactIdleHistory(checkpoint)) { + this.lastHookMicrocompactionTimestamp = Date.now(); } } diff --git a/packages/core/src/skills/bundled/qc-helper/SKILL.md b/packages/core/src/skills/bundled/qc-helper/SKILL.md index 55a46cf4900..e825fd58603 100644 --- a/packages/core/src/skills/bundled/qc-helper/SKILL.md +++ b/packages/core/src/skills/bundled/qc-helper/SKILL.md @@ -120,7 +120,7 @@ When the user asks about configuration, the primary reference is `docs/configura | Permissions | `permissions.allow/ask/deny` | `docs/configuration/settings.md`, `docs/features/approval-mode.md` | | MCP Servers | `mcpServers.*`, `mcp.*` | `docs/configuration/settings.md`, `docs/features/mcp.md` | | Tool Approval | `tools.approvalMode` | `docs/configuration/settings.md`, `docs/features/approval-mode.md`, `docs/features/auto-mode.md` | -| Hooks | `hooks.*` | `docs/configuration/settings.md`, `docs/features/hooks.md` | +| Hooks | `hooks.*` | `docs/configuration/settings.md`, `docs/features/hooks.md` | | Model | `model.name`, `modelProviders` | `docs/configuration/settings.md`, `docs/configuration/model-providers.md` | | General/UI | `general.*`, `ui.*`, `ide.*`, `output.*` | `docs/configuration/settings.md` | | Context | `context.*` | `docs/configuration/settings.md` | diff --git a/scripts/tests/dev.test.js b/scripts/tests/dev.test.js index a7af625ac8d..cd99e55e045 100644 --- a/scripts/tests/dev.test.js +++ b/scripts/tests/dev.test.js @@ -12,6 +12,8 @@ const { spawnMock, platformMock, existsSyncMock } = vi.hoisted(() => ({ existsSyncMock: vi.fn(() => false), })); +const normalizePath = (filePath) => String(filePath).replaceAll('\\', '/'); + vi.mock('node:child_process', () => ({ spawn: spawnMock, })); @@ -57,7 +59,7 @@ describe('scripts/dev.js launcher', () => { it('spawns Node without a shell on Windows when local tsx cli.mjs exists', async () => { platformMock.mockReturnValue('win32'); existsSyncMock.mockImplementation((filePath) => - String(filePath).endsWith('node_modules/tsx/dist/cli.mjs'), + normalizePath(filePath).endsWith('node_modules/tsx/dist/cli.mjs'), ); Object.defineProperty(process, 'execPath', { configurable: true, @@ -67,29 +69,29 @@ describe('scripts/dev.js launcher', () => { await import('../dev.js?direct-node'); - expect(spawnMock).toHaveBeenCalledWith( - 'C:\\Program Files\\nodejs\\node.exe', - [ - expect.stringContaining('node_modules/tsx/dist/cli.mjs'), - expect.stringContaining('packages/cli/index.ts'), - '--help', - ], - expect.objectContaining({ shell: false }), - ); + const [command, args, options] = spawnMock.mock.calls[0]; + expect(command).toBe('C:\\Program Files\\nodejs\\node.exe'); + expect(args.map(normalizePath)).toEqual([ + expect.stringContaining('node_modules/tsx/dist/cli.mjs'), + expect.stringContaining('packages/cli/index.ts'), + '--help', + ]); + expect(options).toEqual(expect.objectContaining({ shell: false })); }); it('keeps shell fallback for Windows tsx.cmd resolution', async () => { platformMock.mockReturnValue('win32'); existsSyncMock.mockImplementation((filePath) => - String(filePath).endsWith('node_modules/.bin/tsx.cmd'), + normalizePath(filePath).endsWith('node_modules/.bin/tsx.cmd'), ); await import('../dev.js?cmd-fallback'); - expect(spawnMock).toHaveBeenCalledWith( - expect.stringContaining('tsx.cmd'), - [expect.stringContaining('packages/cli/index.ts')], - expect.objectContaining({ shell: true }), - ); + const [command, args, options] = spawnMock.mock.calls[0]; + expect(normalizePath(command)).toContain('tsx.cmd'); + expect(args.map(normalizePath)).toEqual([ + expect.stringContaining('packages/cli/index.ts'), + ]); + expect(options).toEqual(expect.objectContaining({ shell: true })); }); });